This commit is contained in:
afkarxyz
2026-02-12 01:08:44 +07:00
parent df56049db2
commit cb3a6a32cb
24 changed files with 946 additions and 486 deletions
+2 -2
View File
@@ -48,9 +48,9 @@ interface AlbumInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
+4 -4
View File
@@ -67,9 +67,9 @@ interface ArtistInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -491,8 +491,8 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
{filteredAlbumGroups.map(([albumName, data]) => {
const tracksWithIsrc = data.tracks.filter(t => t.isrc);
const isSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(t => selectedTracks.includes(t.isrc!));
const tracksWithId = data.tracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
<div className="grid gap-1.5 leading-none flex-1">
+2 -2
View File
@@ -54,9 +54,9 @@ interface PlaylistInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
+418 -152
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { CloudDownload, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
@@ -10,12 +10,13 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
import { useTypingEffect } from "@/hooks/useTypingEffect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const FETCH_PLACEHOLDERS = [
"https://open.spotify.com/track/...",
"https://open.spotify.com/album/...",
"https://open.spotify.com/playlist/...",
"https://open.spotify.com/artist/..."
"https://open.spotify.com/artist/...",
];
const SEARCH_PLACEHOLDERS = [
"Golden",
@@ -23,10 +24,194 @@ const SEARCH_PLACEHOLDERS = [
"The Weeknd",
"Starboy",
"Joji",
"Die For You"
"Die For You",
];
const REGIONS = ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"];
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
const REGIONS = [
"AD",
"AE",
"AG",
"AL",
"AM",
"AO",
"AR",
"AT",
"AU",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BN",
"BO",
"BR",
"BS",
"BT",
"BW",
"BZ",
"CA",
"CD",
"CG",
"CH",
"CI",
"CL",
"CM",
"CO",
"CR",
"CV",
"CW",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"ES",
"ET",
"FI",
"FJ",
"FM",
"FR",
"GA",
"GB",
"GD",
"GE",
"GH",
"GM",
"GN",
"GQ",
"GR",
"GT",
"GW",
"GY",
"HK",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IN",
"IQ",
"IS",
"IT",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KR",
"KW",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MG",
"MH",
"MK",
"ML",
"MN",
"MO",
"MR",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NE",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NZ",
"OM",
"PA",
"PE",
"PG",
"PH",
"PK",
"PL",
"PS",
"PT",
"PW",
"PY",
"QA",
"RO",
"RS",
"RW",
"SA",
"SB",
"SC",
"SE",
"SG",
"SI",
"SK",
"SL",
"SM",
"SN",
"SR",
"ST",
"SV",
"SZ",
"TD",
"TG",
"TH",
"TJ",
"TL",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"US",
"UY",
"UZ",
"VC",
"VE",
"VN",
"VU",
"WS",
"XK",
"ZA",
"ZM",
"ZW",
];
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
const getRegionName = (code: string) => {
try {
if (code === "XK")
@@ -56,7 +241,7 @@ interface SearchBarProps {
region: string;
onRegionChange: (region: string) => void;
}
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange }: SearchBarProps) {
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
@@ -70,6 +255,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
artists: false,
playlists: false,
});
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
const [invalidUrl, setInvalidUrl] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
const placeholderText = useTypingEffect(placeholders);
@@ -125,7 +312,10 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
const results = await SearchSpotify({
query: searchQuery,
limit: SEARCH_LIMIT,
});
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
@@ -181,10 +371,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
if (!prev)
return prev;
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
tracks: activeTab === "tracks"
? [...prev.tracks, ...moreResults]
: prev.tracks,
albums: activeTab === "albums"
? [...prev.albums, ...moreResults]
: prev.albums,
artists: activeTab === "artists"
? [...prev.artists, ...moreResults]
: prev.artists,
playlists: activeTab === "playlists"
? [...prev.playlists, ...moreResults]
: prev.playlists,
});
return updated;
});
@@ -201,6 +399,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
setIsLoadingMore(false);
}
};
const isSpotifyUrl = (text: string) => {
const trimmed = text.trim();
if (!trimmed)
return true;
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
if (!isUrl)
return true;
return (trimmed.includes("spotify.com") ||
trimmed.includes("spotify.link") ||
trimmed.startsWith("spotify:"));
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchMode)
return;
const pastedText = e.clipboardData.getData("text");
if (pastedText && !isSpotifyUrl(pastedText)) {
e.preventDefault();
setInvalidUrl(pastedText);
setShowInvalidUrlDialog(true);
}
};
const handleFetchWithValidation = () => {
if (!isSpotifyUrl(url)) {
setInvalidUrl(url);
setShowInvalidUrlDialog(true);
return;
}
onFetch();
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
@@ -210,18 +437,23 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const hasAnyResults = searchResults &&
(searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults)
return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
case "tracks":
return searchResults.tracks.length;
case "albums":
return searchResults.albums.length;
case "artists":
return searchResults.artists.length;
case "playlists":
return searchResults.playlists.length;
}
};
const tabs: {
@@ -234,167 +466,201 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{ key: "playlists", label: "Playlists" },
];
return (<div className="space-y-4">
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
{searchMode ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
</TooltipContent>
</Tooltip>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
{searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
</TooltipContent>
</Tooltip>
<div className="relative flex-1">
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder={placeholderText} 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 cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
<div className="relative flex-1">
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} 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 cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}>
<XCircle className="h-4 w-4"/>
</button>)}
</>)}
</div>
<XCircle className="h-4 w-4"/>
</button>)}
</>)}
</div>
{!searchMode && (<>
<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
{r} <span className="text-muted-foreground">({getRegionName(r)})</span>
</SelectItem>))}
</SelectContent>
</Select>
<Button onClick={onFetch} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>)}
</Button>
</>)}
</div>
{!searchMode && (<>
<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
</span>
</SelectItem>))}
</SelectContent>
</Select>
<Button onClick={handleFetchWithValidation} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>)}
</Button>
</>)}
</div>
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<span>{query}</span>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<span>{query}</span>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
removeRecentSearch(query);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
</div>)}
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
</div>)}
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>)}
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>)}
{!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
{!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0)
return null;
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground")}>
{tab.label} ({count})
</button>);
{tab.label} ({count})
</button>);
})}
</div>
</div>
<div className="grid gap-2">
{activeTab === "tracks" &&
<div className="grid gap-2">
{activeTab === "tracks" &&
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<p className="font-medium truncate">{track.name}</p>
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
E
</span>)}
</div>
<p className="text-sm text-muted-foreground truncate">
{track.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>))}
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<p className="font-medium truncate">{track.name}</p>
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
E
</span>)}
</div>
<p className="text-sm text-muted-foreground truncate">
{track.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>))}
{activeTab === "albums" &&
{activeTab === "albums" &&
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">
{album.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.release_date || ""}
</span>
</button>))}
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">
{album.artists}
</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.release_date || ""}
</span>
</button>))}
{activeTab === "artists" &&
{activeTab === "artists" &&
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>))}
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>))}
{activeTab === "playlists" &&
{activeTab === "playlists" &&
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner || ""}
</p>
</div>
</button>))}
</div>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner || ""}
</p>
</div>
</button>))}
</div>
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<>
<Spinner />
Loading...
</>) : (<>
<ChevronDown className="h-4 w-4"/>
Load More
</>)}
</Button>
</div>)}
</>)}
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<>
<Spinner />
Loading...
</>) : (<>
<ChevronDown className="h-4 w-4"/>
Load More
</>)}
</Button>
</div>)}
</div>);
</>)}
</div>)}
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Invalid URL</DialogTitle>
<DialogDescription>
Only Spotify links are allowed in Fetch mode.
</DialogDescription>
</DialogHeader>
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
{invalidUrl}
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Cancel
</Button>
<Button onClick={() => {
onSearchModeChange(true);
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Switch to Search
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+8
View File
@@ -11,6 +11,14 @@ export function TitleBar() {
if (settings) {
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
}
const handleSettingsUpdate = (event: any) => {
const updatedSettings = event.detail;
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
}
};
window.addEventListener('settingsUpdated', handleSettingsUpdate);
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
}, []);
const handleSpotFetchAPIToggle = () => {
const newValue = !useSpotFetchAPI;
+6 -6
View File
@@ -26,9 +26,9 @@ interface TrackInfoProps {
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
onBack?: () => void;
@@ -95,9 +95,9 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</div>)}
</div>
</div>
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
{track.spotify_id && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
@@ -134,7 +134,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
+17 -17
View File
@@ -33,11 +33,11 @@ interface TrackListProps {
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: {
@@ -104,15 +104,15 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
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);
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
@@ -149,9 +149,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
}
return pages;
};
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected = tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
const allSelected = tracksWithId.length > 0 &&
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
@@ -197,7 +197,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<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)}/>)}
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5">
@@ -223,7 +223,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</span>) : (<span className="font-medium">{track.name}</span>)}
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
@@ -270,14 +270,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</td>
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip>
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
<Button onClick={() => onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent>
</Tooltip>)}
{track.spotify_id && (<Tooltip>
@@ -315,7 +315,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>