+ return (
About
-
-
-
-
- {activeTab === "bug_report" && (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- if (val)
- setBugType(val);
- }} className="justify-start w-full cursor-pointer">
-
- Track
-
-
- Album
-
-
- Playlist
-
-
- Artist
-
-
-
-
-
- setSpotifyUrl(e.target.value)}/>
-
-
-
-
-
-
-
-
-
)}
+
- {activeTab === "feature_request" && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
)}
-
- {activeTab === "faq" && (
-
-
-
- Frequently Asked Questions
-
-
- {faqs.map((faq, index) => (
-
- {faq.q}
-
-
- {faq.a}
-
-
))}
-
-
-
- )}
{activeTab === "projects" && (
@@ -402,8 +175,13 @@ ${contextContent}`;
openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
-
-
{" "}
+
+

+ {repoStats["SpotiDownloader"]?.latestVersion && (
+ {repoStats["SpotiDownloader"].latestVersion}
+ )}
+
+
SpotiDownloader
@@ -447,13 +225,17 @@ ${contextContent}`;
openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
-
-
{" "}
+
+

+ {repoStats["SpotiFLAC-Next"]?.latestVersion && (
+ {repoStats["SpotiFLAC-Next"].latestVersion}
+ )}
+
+
SpotiFLAC Next
- Get Spotify tracks in Hi-Res lossless FLACs — no account
- required.
+Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
{repoStats["SpotiFLAC-Next"] && (
@@ -493,8 +275,13 @@ ${contextContent}`;
openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
-
-
{" "}
+
+

+ {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (
+ {repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
+ )}
+
+
Twitter/X Media Batch Downloader
@@ -543,21 +330,51 @@ ${contextContent}`;
)}
- {activeTab === "support" && (
-
-
Support Me
-
- If this software is useful and brings you value, consider
- supporting the project on Ko-fi. Your support helps keep
- development going.
-
-
+ {activeTab === "support" && (
+
+
+
+
+
+

+
+
Support via Ko-fi
+
+ Enjoying the project? You can support ongoing development by buying me a coffee.
+
+
+
+
-
-
+
+
+
+
+
+

+
+
+
USDT (TRC20)
+
+ Crypto donations are also accepted. Scan the QR code or copy the address.
+
+
+
+
+ THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
+
+
+
+
)}
diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx
index 6f4b63a..0de7380 100644
--- a/frontend/src/components/AlbumInfo.tsx
+++ b/frontend/src/components/AlbumInfo.tsx
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
+import { getSettings } from "@/lib/settings";
+import { downloadCover } from "@/lib/api";
+import { useState } from "react";
+import { toastWithSound as toast } from "@/lib/toast-with-sound";
+import { joinPath, sanitizePath } from "@/lib/utils";
+import { parseTemplate, type TemplateData } from "@/lib/settings";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
@@ -70,6 +76,65 @@ interface AlbumInfoProps {
onBack?: () => void;
}
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
+ const settings = getSettings();
+ const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
+ const handleDownloadAlbumCover = async () => {
+ if (!albumInfo.images)
+ return;
+ setDownloadingAlbumCover(true);
+ try {
+ const os = settings.operatingSystem;
+ let outputDir = settings.downloadPath;
+ const albumName = albumInfo.name;
+ const artistName = albumInfo.artists;
+ const placeholder = "__SLASH_PLACEHOLDER__";
+ const templateData: TemplateData = {
+ artist: artistName?.replace(/\//g, placeholder),
+ album: albumName?.replace(/\//g, placeholder),
+ album_artist: artistName?.replace(/\//g, placeholder),
+ title: albumName?.replace(/\//g, placeholder),
+ year: albumInfo.release_date?.substring(0, 4),
+ date: albumInfo.release_date,
+ };
+ if (settings.folderTemplate) {
+ const folderPath = parseTemplate(settings.folderTemplate, templateData);
+ if (folderPath) {
+ const parts = folderPath.split("/").filter((p: string) => p.trim());
+ for (const part of parts) {
+ outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
+ }
+ }
+ }
+ const response = await downloadCover({
+ cover_url: albumInfo.images,
+ track_name: albumName,
+ artist_name: "",
+ album_name: "",
+ album_artist: "",
+ release_date: "",
+ output_dir: outputDir,
+ filename_format: "title",
+ track_number: false,
+ position: 0,
+ disc_number: 0,
+ });
+ if (response.success) {
+ if (response.already_exists)
+ toast.info("Cover already exists");
+ else
+ toast.success("Album cover downloaded");
+ }
+ else {
+ toast.error(response.error || "Failed to download cover");
+ }
+ }
+ catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to download cover");
+ }
+ finally {
+ setDownloadingAlbumCover(false);
+ }
+ };
return (
{onBack && (
@@ -79,7 +144,19 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
)}
- {albumInfo.images && (

)}
+ {albumInfo.images && (
+

+
+
+
+
+
+ Download Album Cover
+
+
+
)}
Album
diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx
new file mode 100644
index 0000000..0edd19b
--- /dev/null
+++ b/frontend/src/components/ApiStatusTab.tsx
@@ -0,0 +1,74 @@
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
+import { CheckAPIStatus } from "../../wailsjs/go/main/App";
+import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
+interface ApiSource {
+ id: string;
+ type: string;
+ name: string;
+ url: string;
+}
+const SOURCES: ApiSource[] = [
+ { id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
+ { id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
+ { id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
+ { id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
+ { id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
+ { id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
+ { id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
+ { id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
+ { id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
+ { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.fun" },
+ { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.fun" },
+];
+export function ApiStatusTab() {
+ const [statuses, setStatuses] = useState
>({});
+ const [isCheckingAll, setIsCheckingAll] = useState(false);
+ const checkStatus = async (sourceId: string, apiType: string, url: string) => {
+ setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
+ try {
+ const isOnline = await CheckAPIStatus(apiType, url);
+ setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
+ }
+ catch (error) {
+ setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
+ }
+ };
+ const checkAll = async () => {
+ setIsCheckingAll(true);
+ const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
+ await Promise.allSettled(promises);
+ setIsCheckingAll(false);
+ };
+ useEffect(() => {
+ checkAll();
+ }, []);
+ return (
+
+
+
+
+
+ {SOURCES.map((source) => {
+ const status = statuses[source.id] || "idle";
+ return (
+
+ {source.type === "tidal" ?
: source.type === "amazon" ?
:
}
+
{source.name}
+
+
+
+ {status === "checking" &&
}
+ {status === "online" &&
}
+ {status === "offline" &&
}
+ {status === "idle" &&
}
+
+
);
+ })}
+
+
);
+}
diff --git a/frontend/src/components/DragDropTextarea.tsx b/frontend/src/components/DragDropTextarea.tsx
deleted file mode 100644
index e542642..0000000
--- a/frontend/src/components/DragDropTextarea.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import { useState, useEffect } from "react";
-import type { DragEvent } from "react";
-import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
-import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-interface UploadedFile {
- id: string;
- name: string;
- url: string;
- type: 'image' | 'video' | 'unknown';
- status: 'uploading' | 'done' | 'error';
- error?: string;
-}
-interface DragDropMediaProps {
- value: string;
- onChange: (value: string) => void;
- className?: string;
-}
-export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
- const [isDragging, setIsDragging] = useState(false);
- const [files, setFiles] = useState(() => {
- if (!value)
- return [];
- return value.split('\n').filter(line => line.trim()).map((line, i) => {
- const match = line.match(/!\[(.*?)\]\((.*?)\)/);
- if (match) {
- return {
- id: `init-${i}-${Date.now()}`,
- name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
- url: match[2] || line,
- type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
- status: 'done'
- };
- }
- return {
- id: `init-${i}-${Date.now()}`,
- name: 'unknown',
- url: line,
- type: 'image',
- status: 'done'
- };
- });
- });
- useEffect(() => {
- const newValue = files
- .filter(f => f.status === 'done' && f.url)
- .map(f => f.url)
- .join('\n');
- if (newValue !== value) {
- onChange(newValue);
- }
- }, [files]);
- const handleDragOver = (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(true);
- };
- const handleDragLeave = (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
- };
- const handleDrop = async (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
- if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
- await handleFiles(Array.from(e.dataTransfer.files));
- }
- };
- const handleFiles = async (fileList: File[]) => {
- const timestamp = Date.now();
- const newFiles: UploadedFile[] = fileList.map((f, i) => ({
- id: `drop-${timestamp}-${i}`,
- name: f.name,
- url: '',
- type: f.type.startsWith('video') ? 'video' : 'image',
- status: 'uploading'
- }));
- setFiles(prev => [...prev, ...newFiles]);
- for (let i = 0; i < fileList.length; i++) {
- const file = fileList[i];
- const fileId = newFiles[i].id;
- try {
- const base64 = await fileToBase64(file);
- const result = await UploadImageBytes(file.name, base64);
- setFiles(prev => prev.map(f => f.id === fileId
- ? { ...f, status: 'done', url: result }
- : f));
- }
- catch (err: any) {
- console.error("Upload failed", err);
- setFiles(prev => prev.map(f => f.id === fileId
- ? { ...f, status: 'error', error: err.message || "Upload failed" }
- : f));
- }
- }
- };
- const handleSelectFile = async () => {
- try {
- const paths = await SelectImageVideo();
- if (paths && paths.length > 0) {
- const timestamp = Date.now();
- const newFiles: UploadedFile[] = paths.map((p, i) => ({
- id: `select-${timestamp}-${i}`,
- name: p.split(/[\\/]/).pop() || 'unknown',
- url: '',
- type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
- status: 'uploading'
- }));
- setFiles(prev => [...prev, ...newFiles]);
- for (let i = 0; i < paths.length; i++) {
- const path = paths[i];
- const fileId = newFiles[i].id;
- try {
- const result = await UploadImage(path);
- setFiles(prev => prev.map(f => f.id === fileId
- ? { ...f, status: 'done', url: result }
- : f));
- }
- catch (err: any) {
- setFiles(prev => prev.map(f => f.id === fileId
- ? { ...f, status: 'error', error: err.message }
- : f));
- }
- }
- }
- }
- catch (err: any) {
- console.error("Select file failed", err);
- }
- };
- const removeFile = (index: number) => {
- setFiles(prev => prev.filter((_, i) => i !== index));
- };
- return ( {
- if (e.target === e.currentTarget)
- handleSelectFile();
- }}>
- {files.length === 0 && (
-
- Drop media here or click to browse
- Supports PNG, JPG, MP4, MOV
-
)}
-
-
- {files.map((file, i) => (
- {file.type === 'video' ?
:
}
-
-
-
{file.name}
-
- {file.status === 'uploading' && Uploading...}
- {file.status === 'done' && Ready}
- {file.status === 'error' && {file.error || 'Failed'}}
-
-
-
-
-
))}
-
-
-
- {isDragging && (
-
-
- Drop files to add
-
-
)}
-
);
-}
-const fileToBase64 = (file: File): Promise => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => resolve(reader.result as string);
- reader.onerror = (error) => reject(error);
- });
-};
diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx
index 07705e5..b0c8a31 100644
--- a/frontend/src/components/FileManagerPage.tsx
+++ b/frontend/src/components/FileManagerPage.tsx
@@ -549,7 +549,7 @@ export function FileManagerPage() {
- Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}
+ Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}
@@ -571,7 +571,7 @@ export function FileManagerPage() {
- Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac
+ Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac
)}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index e2d678f..6378151 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
- Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
+ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
);
diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx
index f07e3c1..c508105 100644
--- a/frontend/src/components/PlatformIcons.tsx
+++ b/frontend/src/components/PlatformIcons.tsx
@@ -16,8 +16,3 @@ export const AmazonIcon = ({ className = "w-4 h-4" }: {
);
-export const DeezerIcon = ({ className = "w-4 h-4" }: {
- className?: string;
-}) => (
);
diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx
index 997d17b..f8022b8 100644
--- a/frontend/src/components/PlaylistInfo.tsx
+++ b/frontend/src/components/PlaylistInfo.tsx
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
+import { getSettings } from "@/lib/settings";
+import { downloadCover } from "@/lib/api";
+import { useState } from "react";
+import { toastWithSound as toast } from "@/lib/toast-with-sound";
+import { joinPath, sanitizePath } from "@/lib/utils";
+import { parseTemplate, type TemplateData } from "@/lib/settings";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
@@ -81,6 +87,66 @@ interface PlaylistInfoProps {
onBack?: () => void;
}
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
+ const settings = getSettings();
+ const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
+ const handleDownloadPlaylistCover = async () => {
+ if (!playlistInfo.cover)
+ return;
+ setDownloadingPlaylistCover(true);
+ try {
+ const os = settings.operatingSystem;
+ let outputDir = settings.downloadPath;
+ const playlistName = playlistInfo.owner.name;
+ const placeholder = "__SLASH_PLACEHOLDER__";
+ const templateData: TemplateData = {
+ artist: "",
+ album: "",
+ album_artist: "",
+ title: playlistName.replace(/\//g, placeholder),
+ playlist: playlistName.replace(/\//g, placeholder),
+ };
+ if (settings.createPlaylistFolder && playlistName) {
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
+ }
+ if (settings.folderTemplate) {
+ const folderPath = parseTemplate(settings.folderTemplate, templateData);
+ if (folderPath) {
+ const parts = folderPath.split("/").filter((p: string) => p.trim());
+ for (const part of parts) {
+ outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
+ }
+ }
+ }
+ const response = await downloadCover({
+ cover_url: playlistInfo.cover,
+ track_name: playlistName,
+ artist_name: "",
+ album_name: "",
+ album_artist: "",
+ release_date: "",
+ output_dir: outputDir,
+ filename_format: "title",
+ track_number: false,
+ position: 0,
+ disc_number: 0,
+ });
+ if (response.success) {
+ if (response.already_exists)
+ toast.info("Cover already exists");
+ else
+ toast.success("Playlist cover downloaded");
+ }
+ else {
+ toast.error(response.error || "Failed to download cover");
+ }
+ }
+ catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to download cover");
+ }
+ finally {
+ setDownloadingPlaylistCover(false);
+ }
+ };
return (
{onBack && (
@@ -90,7 +156,19 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
)}
- {playlistInfo.cover && (

)}
+ {playlistInfo.cover && (
+

+
+
+
+
+
+ Download Playlist Cover
+
+
+
)}
Playlist
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index eea17bd..9614b13 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -1,7 +1,8 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
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, ArrowUpDown, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
@@ -244,6 +245,13 @@ interface 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
(null);
+ const [resultFilter, setResultFilter] = useState("");
+ const [sortOrders, setSortOrders] = useState>({
+ tracks: "default",
+ albums: "default",
+ artists: "default",
+ playlists: "default",
+ });
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
@@ -317,6 +325,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
limit: SEARCH_LIMIT,
});
setSearchResults(results);
+ setResultFilter("");
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
setHasMore({
@@ -456,6 +465,88 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
return searchResults.playlists.length;
}
};
+ const sortedResults = useMemo(() => {
+ if (!searchResults)
+ return { tracks: [], albums: [], artists: [], playlists: [] };
+ const filterStr = resultFilter.toLowerCase();
+ let tracks = [...searchResults.tracks];
+ if (filterStr) {
+ tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
+ }
+ const tSort = sortOrders.tracks;
+ if (tSort !== 'default') {
+ tracks.sort((a, b) => {
+ if (tSort === 'title-asc')
+ return (a.name || '').localeCompare(b.name || '');
+ if (tSort === 'title-desc')
+ return (b.name || '').localeCompare(a.name || '');
+ if (tSort === 'artist-asc')
+ return (a.artists || '').localeCompare(b.artists || '');
+ if (tSort === 'artist-desc')
+ return (b.artists || '').localeCompare(a.artists || '');
+ if (tSort === 'duration-desc')
+ return (b.duration_ms || 0) - (a.duration_ms || 0);
+ if (tSort === 'duration-asc')
+ return (a.duration_ms || 0) - (b.duration_ms || 0);
+ return 0;
+ });
+ }
+ let albums = [...searchResults.albums];
+ if (filterStr) {
+ albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
+ }
+ const alSort = sortOrders.albums;
+ if (alSort !== 'default') {
+ albums.sort((a, b) => {
+ if (alSort === 'title-asc')
+ return (a.name || '').localeCompare(b.name || '');
+ if (alSort === 'title-desc')
+ return (b.name || '').localeCompare(a.name || '');
+ if (alSort === 'artist-asc')
+ return (a.artists || '').localeCompare(b.artists || '');
+ if (alSort === 'artist-desc')
+ return (b.artists || '').localeCompare(a.artists || '');
+ if (alSort === 'year-desc')
+ return (b.release_date || '').localeCompare(a.release_date || '');
+ if (alSort === 'year-asc')
+ return (a.release_date || '').localeCompare(b.release_date || '');
+ return 0;
+ });
+ }
+ let artists = [...searchResults.artists];
+ if (filterStr) {
+ artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
+ }
+ const arSort = sortOrders.artists;
+ if (arSort !== 'default') {
+ artists.sort((a, b) => {
+ if (arSort === 'name-asc')
+ return (a.name || '').localeCompare(b.name || '');
+ if (arSort === 'name-desc')
+ return (b.name || '').localeCompare(a.name || '');
+ return 0;
+ });
+ }
+ let playlists = [...searchResults.playlists];
+ if (filterStr) {
+ playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
+ }
+ const pSort = sortOrders.playlists;
+ if (pSort !== 'default') {
+ playlists.sort((a, b) => {
+ if (pSort === 'title-asc')
+ return (a.name || '').localeCompare(b.name || '');
+ if (pSort === 'title-desc')
+ return (b.name || '').localeCompare(a.name || '');
+ if (pSort === 'owner-asc')
+ return (a.owner || '').localeCompare(b.owner || '');
+ if (pSort === 'owner-desc')
+ return (b.owner || '').localeCompare(a.owner || '');
+ return 0;
+ });
+ }
+ return { tracks, albums, artists, playlists };
+ }, [searchResults, sortOrders, resultFilter]);
const tabs: {
key: ResultTab;
label: string;
@@ -490,6 +581,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
+ setResultFilter("");
}}>
)}
@@ -550,7 +642,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
)}
{!isSearching && hasAnyResults && (<>
-
+
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0)
@@ -563,9 +655,54 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
})}
+
+
+
+ setResultFilter(e.target.value)} className="pl-10 pr-8"/>
+ {resultFilter && ()}
+
+
+
+
{activeTab === "tracks" &&
- searchResults?.tracks.map((track) => (