v5.7-beta2
This commit is contained in:
+221
-1177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface AlbumInfoProps {
|
||||||
|
albumInfo: {
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
images: string;
|
||||||
|
release_date: string;
|
||||||
|
total_tracks: number;
|
||||||
|
};
|
||||||
|
trackList: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
onToggleTrack: (isrc: string) => void;
|
||||||
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumInfo({
|
||||||
|
albumInfo,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onPageChange,
|
||||||
|
}: AlbumInfoProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6">
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
{albumInfo.images && (
|
||||||
|
<img
|
||||||
|
src={albumInfo.images}
|
||||||
|
alt={albumInfo.name}
|
||||||
|
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Album</p>
|
||||||
|
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{albumInfo.artists}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumInfo.release_date}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumInfo.total_tracks} songs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDownloading && (
|
||||||
|
<DownloadProgress
|
||||||
|
progress={downloadProgress}
|
||||||
|
currentTrack={currentDownloadInfo}
|
||||||
|
onStop={onStopDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SearchAndSort
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>
|
||||||
|
<TrackList
|
||||||
|
tracks={trackList}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
selectedTracks={selectedTracks}
|
||||||
|
downloadedTracks={downloadedTracks}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={true}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface ArtistInfoProps {
|
||||||
|
artistInfo: {
|
||||||
|
name: string;
|
||||||
|
images: string;
|
||||||
|
followers: number;
|
||||||
|
genres: string[];
|
||||||
|
};
|
||||||
|
albumList: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images: string;
|
||||||
|
release_date: string;
|
||||||
|
album_type: string;
|
||||||
|
external_urls: string;
|
||||||
|
}>;
|
||||||
|
trackList: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
onToggleTrack: (isrc: string) => void;
|
||||||
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistInfo({
|
||||||
|
artistInfo,
|
||||||
|
albumList,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onAlbumClick,
|
||||||
|
onPageChange,
|
||||||
|
}: ArtistInfoProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6">
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
{artistInfo.images && (
|
||||||
|
<img
|
||||||
|
src={artistInfo.images}
|
||||||
|
alt={artistInfo.name}
|
||||||
|
className="w-48 h-48 rounded-full shadow-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<p className="text-sm font-medium">Artist</p>
|
||||||
|
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||||
|
{artistInfo.genres.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{albumList.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-2xl font-bold">Discography</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{albumList.map((album) => (
|
||||||
|
<div
|
||||||
|
key={album.id}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
onAlbumClick({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
external_urls: album.external_urls,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative mb-4">
|
||||||
|
{album.images && (
|
||||||
|
<img
|
||||||
|
src={album.images}
|
||||||
|
alt={album.name}
|
||||||
|
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{album.release_date?.split("-")[0]} • {album.album_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trackList.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} size="sm" variant="outline" className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDownloading && (
|
||||||
|
<DownloadProgress
|
||||||
|
progress={downloadProgress}
|
||||||
|
currentTrack={currentDownloadInfo}
|
||||||
|
onStop={onStopDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SearchAndSort
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>
|
||||||
|
<TrackList
|
||||||
|
tracks={trackList}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
selectedTracks={selectedTracks}
|
||||||
|
downloadedTracks={downloadedTracks}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={false}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { StopCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface DownloadProgressProps {
|
||||||
|
progress: number;
|
||||||
|
currentTrack: { name: string; artists: string } | null;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-2 mt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={progress} className="h-2 flex-1" />
|
||||||
|
<Button variant="destructive" size="sm" onClick={onStop}>
|
||||||
|
<StopCircle className="h-4 w-4 mr-2" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{progress}% -{" "}
|
||||||
|
{currentTrack
|
||||||
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
|
: "Preparing download..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Settings } from "@/components/Settings";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
version: string;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ version, hasUpdate }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<img
|
||||||
|
src="/icon.svg"
|
||||||
|
alt="SpotiFLAC"
|
||||||
|
className="w-12 h-12 cursor-pointer"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
className="text-4xl font-bold cursor-pointer"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
SpotiFLAC
|
||||||
|
</h1>
|
||||||
|
<div className="relative">
|
||||||
|
<Badge variant="default" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
v{version}
|
||||||
|
</a>
|
||||||
|
</Badge>
|
||||||
|
{hasUpdate && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 top-0 flex gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub Issues"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Report bug or request feature</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface PlaylistInfoProps {
|
||||||
|
playlistInfo: {
|
||||||
|
owner: {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
images: string;
|
||||||
|
};
|
||||||
|
tracks: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
followers: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
trackList: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
onToggleTrack: (isrc: string) => void;
|
||||||
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistInfo({
|
||||||
|
playlistInfo,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onPageChange,
|
||||||
|
}: PlaylistInfoProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6">
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
{playlistInfo.owner.images && (
|
||||||
|
<img
|
||||||
|
src={playlistInfo.owner.images}
|
||||||
|
alt={playlistInfo.owner.name}
|
||||||
|
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Playlist</p>
|
||||||
|
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{playlistInfo.tracks.total} songs</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDownloading && (
|
||||||
|
<DownloadProgress
|
||||||
|
progress={downloadProgress}
|
||||||
|
currentTrack={currentDownloadInfo}
|
||||||
|
onStop={onStopDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SearchAndSort
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>
|
||||||
|
<TrackList
|
||||||
|
tracks={trackList}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
selectedTracks={selectedTracks}
|
||||||
|
downloadedTracks={downloadedTracks}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={false}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Search, ArrowUpDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface SearchAndSortProps {
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAndSort({
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
}: SearchAndSortProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search tracks..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={sortBy} onValueChange={onSortChange}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
||||||
|
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
||||||
|
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||||
|
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Search, Info, XCircle } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
url: string;
|
||||||
|
loading: boolean;
|
||||||
|
onUrlChange: (url: string) => void;
|
||||||
|
onFetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6 py-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Supports track, album, playlist, and artist URLs</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="spotify-url"
|
||||||
|
placeholder="https://open.spotify.com/..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => onUrlChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
||||||
|
className="pr-8"
|
||||||
|
/>
|
||||||
|
{url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onUrlChange("")}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={onFetch} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
Fetching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface TrackInfoProps {
|
||||||
|
track: TrackMetadata & { album_name: string; release_date: string };
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloaded: boolean;
|
||||||
|
onDownload: (isrc: string, name: string, artists: string) => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackInfo({
|
||||||
|
track,
|
||||||
|
isDownloading,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloaded,
|
||||||
|
onDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
}: TrackInfoProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6">
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
{track.images && (
|
||||||
|
<img
|
||||||
|
src={track.images}
|
||||||
|
alt={track.name}
|
||||||
|
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Album</p>
|
||||||
|
<p className="font-medium truncate">{track.album_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||||
|
<p className="font-medium">{track.release_date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{track.isrc && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => onDownload(track.isrc, track.name, track.artists)}
|
||||||
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
|
>
|
||||||
|
{downloadingTrack === track.isrc ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isDownloaded && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Download, CheckCircle } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface TrackListProps {
|
||||||
|
tracks: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
hideAlbumColumn?: boolean;
|
||||||
|
onToggleTrack: (isrc: string) => void;
|
||||||
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackList({
|
||||||
|
tracks,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
showCheckboxes = false,
|
||||||
|
hideAlbumColumn = false,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onPageChange,
|
||||||
|
}: TrackListProps) {
|
||||||
|
let filteredTracks = tracks.filter((track) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
track.name.toLowerCase().includes(query) ||
|
||||||
|
track.artists.toLowerCase().includes(query) ||
|
||||||
|
track.album_name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
if (sortBy === "title-asc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
} else if (sortBy === "title-desc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
||||||
|
} else if (sortBy === "artist-asc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
||||||
|
} else if (sortBy === "artist-desc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
||||||
|
} else if (sortBy === "duration-asc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
||||||
|
} else if (sortBy === "duration-desc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
||||||
|
} else if (sortBy === "downloaded") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||||
|
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||||
|
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||||
|
});
|
||||||
|
} else if (sortBy === "not-downloaded") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||||
|
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||||
|
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = startIndex + itemsPerPage;
|
||||||
|
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||||
|
const allSelected =
|
||||||
|
tracksWithIsrc.length > 0 &&
|
||||||
|
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
{showCheckboxes && (
|
||||||
|
<th className="h-12 px-4 text-left align-middle w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
|
Title
|
||||||
|
</th>
|
||||||
|
{!hideAlbumColumn && (
|
||||||
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||||
|
Album
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedTracks.map((track, index) => (
|
||||||
|
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
{showCheckboxes && (
|
||||||
|
<td className="p-4 align-middle">
|
||||||
|
{track.isrc && (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTracks.includes(track.isrc)}
|
||||||
|
onCheckedChange={() => onToggleTrack(track.isrc)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||||
|
{startIndex + index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 align-middle">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{track.images && (
|
||||||
|
<img
|
||||||
|
src={track.images}
|
||||||
|
alt={track.name}
|
||||||
|
className="w-10 h-10 rounded object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{track.name}</span>
|
||||||
|
{downloadedTracks.has(track.isrc) && (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{track.artists}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{!hideAlbumColumn && (
|
||||||
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
{track.album_name}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
|
{formatDuration(track.duration_ms)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 align-middle text-center">
|
||||||
|
{track.isrc && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
|
>
|
||||||
|
{downloadingTrack === track.isrc ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPageChange(page);
|
||||||
|
}}
|
||||||
|
isActive={currentPage === page}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === totalPages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { downloadTrack } from "@/lib/api";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
export function useDownload() {
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
|
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||||
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
} | null>(null);
|
||||||
|
const shouldStopDownloadRef = useRef(false);
|
||||||
|
|
||||||
|
const downloadWithAutoFallback = async (
|
||||||
|
isrc: string,
|
||||||
|
settings: any,
|
||||||
|
trackName?: string,
|
||||||
|
artistName?: string,
|
||||||
|
albumName?: string,
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
let service = settings.downloader;
|
||||||
|
|
||||||
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
if (playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||||
|
|
||||||
|
if (isArtistDiscography) {
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (settings.artistSubfolder && artistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service === "auto") {
|
||||||
|
try {
|
||||||
|
const tidalResponse = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "tidal",
|
||||||
|
query,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tidalResponse.success) {
|
||||||
|
return tidalResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
service = "deezer";
|
||||||
|
} catch (tidalErr) {
|
||||||
|
service = "deezer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: service as "deezer" | "tidal",
|
||||||
|
query,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTrack = async (
|
||||||
|
isrc: string,
|
||||||
|
trackName?: string,
|
||||||
|
artistName?: string,
|
||||||
|
albumName?: string
|
||||||
|
) => {
|
||||||
|
if (!isrc) {
|
||||||
|
toast.error("No ISRC found for this track");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
isrc,
|
||||||
|
settings,
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
albumName,
|
||||||
|
undefined,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message);
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Download failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||||
|
} finally {
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadSelected = async (
|
||||||
|
selectedTracks: string[],
|
||||||
|
allTracks: TrackMetadata[],
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
if (selectedTracks.length === 0) {
|
||||||
|
toast.error("No tracks selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsDownloading(true);
|
||||||
|
setBulkDownloadType("selected");
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const total = selectedTracks.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedTracks.length; i++) {
|
||||||
|
if (shouldStopDownloadRef.current) {
|
||||||
|
toast.info(
|
||||||
|
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isrc = selectedTracks[i];
|
||||||
|
const track = allTracks.find((t) => t.isrc === isrc);
|
||||||
|
|
||||||
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
isrc,
|
||||||
|
settings,
|
||||||
|
track?.name,
|
||||||
|
track?.artists,
|
||||||
|
track?.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successCount++;
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
|
setIsDownloading(false);
|
||||||
|
setBulkDownloadType(null);
|
||||||
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAll = async (
|
||||||
|
tracks: TrackMetadata[],
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
||||||
|
|
||||||
|
if (tracksWithIsrc.length === 0) {
|
||||||
|
toast.error("No tracks available for download");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsDownloading(true);
|
||||||
|
setBulkDownloadType("all");
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const total = tracksWithIsrc.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
||||||
|
if (shouldStopDownloadRef.current) {
|
||||||
|
toast.info(
|
||||||
|
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracksWithIsrc[i];
|
||||||
|
|
||||||
|
setDownloadingTrack(track.isrc);
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
track.isrc,
|
||||||
|
settings,
|
||||||
|
track.name,
|
||||||
|
track.artists,
|
||||||
|
track.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successCount++;
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
|
setIsDownloading(false);
|
||||||
|
setBulkDownloadType(null);
|
||||||
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopDownload = () => {
|
||||||
|
shouldStopDownloadRef.current = true;
|
||||||
|
toast.info("Stopping download...");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDownloadedTracks = () => {
|
||||||
|
setDownloadedTracks(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadProgress,
|
||||||
|
isDownloading,
|
||||||
|
downloadingTrack,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadedTracks,
|
||||||
|
currentDownloadInfo,
|
||||||
|
handleDownloadTrack,
|
||||||
|
handleDownloadSelected,
|
||||||
|
handleDownloadAll,
|
||||||
|
handleStopDownload,
|
||||||
|
resetDownloadedTracks,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export function useMetadata() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||||
|
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||||
|
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||||
|
const [pendingUrl, setPendingUrl] = useState("");
|
||||||
|
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||||
|
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const fetchMetadataDirectly = async (url: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(url);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchMetadata = async (url: string) => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
toast.error("Please enter a Spotify URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlToFetch = url.trim();
|
||||||
|
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||||
|
|
||||||
|
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||||
|
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArtistUrl) {
|
||||||
|
setPendingUrl(urlToFetch);
|
||||||
|
setShowTimeoutDialog(true);
|
||||||
|
} else {
|
||||||
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlToFetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmFetch = async () => {
|
||||||
|
setShowTimeoutDialog(false);
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlbumClick = (album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
setShowAlbumDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAlbumFetch = async () => {
|
||||||
|
if (!selectedAlbum) return;
|
||||||
|
|
||||||
|
setShowAlbumDialog(false);
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Album metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setSelectedAlbum(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
metadata,
|
||||||
|
showTimeoutDialog,
|
||||||
|
setShowTimeoutDialog,
|
||||||
|
timeoutValue,
|
||||||
|
setTimeoutValue,
|
||||||
|
showAlbumDialog,
|
||||||
|
setShowAlbumDialog,
|
||||||
|
selectedAlbum,
|
||||||
|
handleFetchMetadata,
|
||||||
|
handleConfirmFetch,
|
||||||
|
handleAlbumClick,
|
||||||
|
handleConfirmAlbumFetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"companyName": "afkarxyz",
|
"companyName": "afkarxyz",
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "5.6",
|
"productVersion": "5.7",
|
||||||
"copyright": "Copyright © 2025",
|
"copyright": "Copyright © 2025",
|
||||||
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
|
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user