);
}
diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx
index de3c825..2334db1 100644
--- a/frontend/src/components/PlaylistInfo.tsx
+++ b/frontend/src/components/PlaylistInfo.tsx
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
-import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
+import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
@@ -78,10 +78,16 @@ interface PlaylistInfoProps {
external_urls: string;
}) => void;
onTrackClick: (track: TrackMetadata) => void;
+ 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, }: PlaylistInfoProps) {
+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) {
return (
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index 022b30a..23f9d22 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -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, Info, 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";
@@ -9,6 +9,34 @@ import type { HistoryItem } from "@/components/FetchHistory";
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";
+const FETCH_PLACEHOLDERS = [
+ "https://open.spotify.com/track/...",
+ "https://open.spotify.com/album/...",
+ "https://open.spotify.com/playlist/...",
+ "https://open.spotify.com/artist/..."
+];
+const SEARCH_PLACEHOLDERS = [
+ "Golden",
+ "Taylor Swift",
+ "The Weeknd",
+ "Starboy",
+ "Joji",
+ "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 getRegionName = (code: string) => {
+ try {
+ if (code === "XK")
+ return "Kosovo";
+ return regionNames.of(code) || code;
+ }
+ catch (e) {
+ return code;
+ }
+};
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
@@ -25,8 +53,10 @@ interface SearchBarProps {
hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
+ region: string;
+ onRegionChange: (region: string) => void;
}
-export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: 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 [isSearching, setIsSearching] = useState(false);
@@ -41,6 +71,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
playlists: false,
});
const searchTimeoutRef = useRef | null>(null);
+ const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
+ const placeholderText = useTypingEffect(placeholders);
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
@@ -202,172 +234,167 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{ key: "playlists", label: "Playlists" },
];
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!searchMode ? (<>
- Supports track, album, playlist, and artist URLs
- Note: Playlist must be public (not private)
- >) : (Search for tracks, albums, artists, or playlists
)}
-
-
-
+
+
+
+
+
+
+ {searchMode ? "Fetch Mode" : "Search Mode"}
+
+
-
-
- {!searchMode ? (<>
-
onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
- {url && ()}
- >) : (<>
- setSearchQuery(e.target.value)} className="pr-8"/>
- {searchQuery && ()}
+ >)}
+
- {!searchMode && (
- {loading ? (<>
-
- Fetching...
- >) : (<>
-
- Fetch
- >)}
- )}
-
-
+ {!searchMode && (<>
+
+
+ {loading ? (<>
+
+ Fetching...
+ >) : (<>
+
+ Fetch
+ >)}
+
+ >)}
+
- {!searchMode && !hasResult && (
)}
+ {!searchMode && !hasResult && ()}
-
- {searchMode && (
-
- {!searchQuery && !searchResults && recentSearches.length > 0 && (
-
Recent Searches
-
- {recentSearches.map((query) => (
setSearchQuery(query)}>
-
{query}
-
{
+ {searchMode && (
+ {!searchQuery && !searchResults && recentSearches.length > 0 && (
+
Recent Searches
+
+ {recentSearches.map((query) => (
setSearchQuery(query)}>
+ {query}
+ {
e.stopPropagation();
removeRecentSearch(query);
}}>
-
-
-
))}
-
-
)}
+
+
+ ))}
+
+
)}
- {isSearching && (
-
- Searching...
-
)}
+ {isSearching && (
+
+ Searching...
+
)}
- {!isSearching && searchQuery && !hasAnyResults && (
- No results found for "{searchQuery}"
-
)}
+ {!isSearching && searchQuery && !hasAnyResults && (
+ No results found for "{searchQuery}"
+
)}
- {!isSearching && hasAnyResults && (<>
-
-
- {tabs.map((tab) => {
+ {!isSearching && hasAnyResults && (<>
+
+ {tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0)
return null;
return ( 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})
- );
+ {tab.label} ({count})
+ );
})}
-
+
-
-
-
- {activeTab === "tracks" && searchResults?.tracks.map((track) => (
handleResultClick(track.external_urls)}>
- {track.images ? (
) : ()}
-
-
{track.name}
-
{track.artists}
-
-
- {formatDuration(track.duration_ms || 0)}
-
- ))}
+
+ {activeTab === "tracks" &&
+ searchResults?.tracks.map((track) => (
handleResultClick(track.external_urls)}>
+ {track.images ? (
) : ()}
+
+
+
{track.name}
+ {track.is_explicit && (
+ E
+ )}
+
+
+ {track.artists}
+
+
+
+ {formatDuration(track.duration_ms || 0)}
+
+ ))}
-
- {activeTab === "albums" && searchResults?.albums.map((album) => (
handleResultClick(album.external_urls)}>
- {album.images ? (
) : ()}
-
-
{album.name}
-
{album.artists}
-
-
- {album.release_date || ""}
-
- ))}
+ {activeTab === "albums" &&
+ searchResults?.albums.map((album) => (
handleResultClick(album.external_urls)}>
+ {album.images ? (
) : ()}
+
+
{album.name}
+
+ {album.artists}
+
+
+
+ {album.release_date || ""}
+
+ ))}
-
- {activeTab === "artists" && searchResults?.artists.map((artist) => (
handleResultClick(artist.external_urls)}>
- {artist.images ? (
) : ()}
-
-
{artist.name}
-
Artist
-
- ))}
+ {activeTab === "artists" &&
+ searchResults?.artists.map((artist) => (
handleResultClick(artist.external_urls)}>
+ {artist.images ? (
) : ()}
+
+
{artist.name}
+
Artist
+
+ ))}
-
- {activeTab === "playlists" && searchResults?.playlists.map((playlist) => (
handleResultClick(playlist.external_urls)}>
- {playlist.images ? (
) : ()}
-
-
{playlist.name}
-
- {playlist.owner || ""}
-
-
- ))}
-
+ {activeTab === "playlists" &&
+ searchResults?.playlists.map((playlist) => (
handleResultClick(playlist.external_urls)}>
+ {playlist.images ? (
) : ()}
+
+
{playlist.name}
+
+ {playlist.owner || ""}
+
+
+ ))}
+
-
- {hasMore[activeTab] && (
-
- {isLoadingMore ? (<>
-
- Loading...
- >) : (<>
-
- Load More
- >)}
-
+ {hasMore[activeTab] && (
+
+ {isLoadingMore ? (<>
+
+ Loading...
+ >) : (<>
+
+ Load More
+ >)}
+
+
)}
+ >)}
)}
- >)}
-
)}
-
);
+ );
}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
index 1ee3dec..9442865 100644
--- a/frontend/src/components/SettingsPage.tsx
+++ b/frontend/src/components/SettingsPage.tsx
@@ -39,11 +39,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
const [tempSettings, setTempSettings] = useState(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
- const [showHiResWarning, setShowHiResWarning] = useState(false);
- const [pendingQuality, setPendingQuality] = useState<{
- type: 'tidal' | 'qobuz' | 'auto';
- value: string;
- } | null>(null);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
@@ -121,53 +116,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
}
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
- if (value === "HI_RES_LOSSLESS") {
- setPendingQuality({ type: 'tidal', value });
- setShowHiResWarning(true);
- return;
- }
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
};
const handleQobuzQualityChange = (value: "6" | "7") => {
- if (value === "7") {
- setPendingQuality({ type: 'qobuz', value });
- setShowHiResWarning(true);
- }
- else {
- setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
- }
+ setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
const handleAutoQualityChange = async (value: "16" | "24") => {
- if (value === "24") {
- setPendingQuality({ type: 'auto', value });
- setShowHiResWarning(true);
- return;
- }
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
- const handleConfirmHiRes = () => {
- if (pendingQuality) {
- if (pendingQuality.type === 'tidal') {
- setTempSettings((prev) => ({ ...prev, tidalQuality: pendingQuality.value as "LOSSLESS" | "HI_RES_LOSSLESS" }));
- }
- else if (pendingQuality.type === 'qobuz') {
- setTempSettings((prev) => ({ ...prev, qobuzQuality: pendingQuality.value as "6" | "7" }));
- }
- else if (pendingQuality.type === 'auto') {
- setTempSettings((prev) => ({ ...prev, autoQuality: pendingQuality.value as "16" | "24" }));
- }
- }
- setShowHiResWarning(false);
- setPendingQuality(null);
- };
- return (
+ return (
Settings
-
+
-
+
setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
@@ -179,7 +143,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
-
+
-
+
-
+
-
+
-
+
+
+ {((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
+ (tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") ||
+ (tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (
+
+ setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
+
+
+
)}
+
@@ -356,7 +330,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
-
+
@@ -394,7 +368,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
-
+
@@ -432,7 +406,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
-
+
setShowResetConfirm(true)} className="gap-1.5">
Reset to Default
@@ -459,19 +433,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
-
+
);
}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index c0f6158..332bdbd 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -37,7 +37,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
- Download History
+ History
diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx
index 3610191..6b4053b 100644
--- a/frontend/src/components/TrackInfo.tsx
+++ b/frontend/src/components/TrackInfo.tsx
@@ -31,8 +31,9 @@ interface TrackInfoProps {
onCheckAvailability?: (spotifyId: string, isrc?: 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;
}
-export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
+export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
@@ -45,7 +46,12 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
return plays;
return num.toLocaleString();
};
- return (
+ return (
+ {onBack && (
+
+
+
+
)}
@@ -60,6 +66,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
{track.name}
+ {track.is_explicit && (E)}
{isSkipped ? () : isDownloaded ? () : isFailed ? () : null}
{track.artists}
diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx
index e89db0e..ae6b2bf 100644
--- a/frontend/src/components/TrackList.tsx
+++ b/frontend/src/components/TrackList.tsx
@@ -221,6 +221,8 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{onTrackClick ? (
onTrackClick(track)}>
{track.name}
) : (
{track.name})}
+ {track.is_explicit && (
E)}
+
{skippedTracks.has(track.isrc) ? (
) : downloadedTracks.has(track.isrc) ? (
) : failedTracks.has(track.isrc) ? (
) : null}
diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..a587434
--- /dev/null
+++ b/frontend/src/components/ui/scroll-area.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import { cn } from "@/lib/utils";
+const ScrollArea = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+ ));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+const ScrollBar = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, orientation = "vertical", ...props }, ref) => (
+
+ ));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+export { ScrollArea, ScrollBar };
diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts
index 6214d6b..2614dc9 100644
--- a/frontend/src/hooks/useDownload.ts
+++ b/frontend/src/hooks/useDownload.ts
@@ -19,6 +19,7 @@ interface CheckFileExistenceRequest {
filename_format?: string;
include_track_number?: boolean;
audio_format?: string;
+ relative_path?: string;
}
interface FileExistenceResult {
spotify_id: string;
@@ -29,7 +30,7 @@ interface FileExistenceResult {
}
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
const SkipDownloadItem = (itemID: string, filePath: string): Promise => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
-export function useDownload() {
+export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState(null);
@@ -141,7 +142,7 @@ export function useDownload() {
if (spotifyId) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
- const urlsJson = await GetStreamingURLs(spotifyId);
+ const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson);
}
catch (err) {
@@ -372,8 +373,9 @@ export function useDownload() {
year: yearValue,
playlist: folderName?.replace(/\//g, placeholder),
};
- const useAlbumTag = settings.folderTemplate?.includes("{album}");
- if (folderName && (!isAlbum || !useAlbumTag)) {
+ const folderTemplate = settings.folderTemplate || "";
+ const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
+ if (folderName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
@@ -391,7 +393,7 @@ export function useDownload() {
if (spotifyId) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
- const urlsJson = await GetStreamingURLs(spotifyId);
+ const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson);
}
catch (err) {
diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts
index 4e2d60e..4bc67cd 100644
--- a/frontend/src/hooks/useMetadata.ts
+++ b/frontend/src/hooks/useMetadata.ts
@@ -2,13 +2,11 @@ import { useState } from "react";
import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger";
+import { AddFetchHistory } from "../../wailsjs/go/main/App";
import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() {
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState(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;
@@ -27,6 +25,57 @@ export function useMetadata() {
return "artist";
return "unknown";
};
+ const saveToHistory = async (url: string, data: SpotifyMetadataResponse) => {
+ try {
+ let name = "";
+ let info = "";
+ let image = "";
+ let type = "unknown";
+ if ("track" in data) {
+ type = "track";
+ name = data.track.name;
+ info = data.track.artists;
+ image = (data.track.images && data.track.images.length > 0) ? data.track.images : "";
+ }
+ else if ("album_info" in data) {
+ type = "album";
+ name = data.album_info.name;
+ info = `${data.track_list.length} tracks`;
+ image = data.album_info.images;
+ }
+ else if ("playlist_info" in data) {
+ type = "playlist";
+ if (data.playlist_info.name) {
+ name = data.playlist_info.name;
+ }
+ else if (data.playlist_info.owner.name) {
+ name = data.playlist_info.owner.name;
+ }
+ info = `${data.playlist_info.tracks.total} tracks`;
+ image = data.playlist_info.cover || "";
+ }
+ else if ("artist_info" in data) {
+ type = "artist";
+ name = data.artist_info.name;
+ info = `${data.artist_info.total_albums || data.album_list.length} albums`;
+ image = data.artist_info.images;
+ }
+ const jsonStr = JSON.stringify(data);
+ await AddFetchHistory({
+ id: crypto.randomUUID(),
+ url: url,
+ type: type,
+ name: name,
+ info: info,
+ image: image,
+ data: jsonStr,
+ timestamp: Math.floor(Date.now() / 1000)
+ });
+ }
+ catch (err) {
+ console.error("Failed to save fetch history:", err);
+ }
+ };
const fetchMetadataDirectly = async (url: string) => {
const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`);
@@ -35,7 +84,8 @@ export function useMetadata() {
setMetadata(null);
try {
const startTime = Date.now();
- const data = await fetchSpotifyMetadata(url);
+ const timeout = urlType === "artist" ? 60 : 300;
+ const data = await fetchSpotifyMetadata(url, true, 1.0, timeout);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("playlist_info" in data) {
const playlistInfo = data.playlist_info;
@@ -56,6 +106,7 @@ export function useMetadata() {
}
}
setMetadata(data);
+ saveToHistory(url, data);
if ("track" in data) {
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
@@ -84,6 +135,17 @@ export function useMetadata() {
setLoading(false);
}
};
+ const loadFromCache = (cachedData: string) => {
+ try {
+ const data = JSON.parse(cachedData);
+ setMetadata(data);
+ toast.success("Loaded from cache");
+ }
+ catch (err) {
+ console.error("Failed to load from cache:", err);
+ toast.error("Failed to load from cache");
+ }
+ };
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
logger.warning("empty url provided");
@@ -97,43 +159,15 @@ export function useMetadata() {
logger.debug("converted to discography url");
}
if (isArtistUrl) {
- logger.info("artist url detected, showing timeout dialog");
- setPendingUrl(urlToFetch);
+ logger.info("artist url detected");
setPendingArtistName(null);
- setShowTimeoutDialog(true);
+ await fetchMetadataDirectly(urlToFetch);
}
else {
await fetchMetadataDirectly(urlToFetch);
}
return urlToFetch;
};
- const handleConfirmFetch = async () => {
- setShowTimeoutDialog(false);
- logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
- logger.debug(`url: ${pendingUrl}`);
- setLoading(true);
- setMetadata(null);
- try {
- const startTime = Date.now();
- const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
- setMetadata(data);
- if ("artist_info" in data) {
- logger.success(`fetched artist: ${data.artist_info.name}`);
- logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
- }
- logger.info(`fetch completed in ${elapsed}s`);
- toast.success("Metadata fetched successfully");
- }
- catch (err) {
- const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
- logger.error(`fetch failed: ${errorMsg}`);
- toast.error(errorMsg);
- }
- finally {
- setLoading(false);
- }
- };
const handleAlbumClick = (album: {
id: string;
name: string;
@@ -150,9 +184,8 @@ export function useMetadata() {
}) => {
logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
- setPendingUrl(artistUrl);
setPendingArtistName(artist.name);
- setShowTimeoutDialog(true);
+ await fetchMetadataDirectly(artistUrl);
return artistUrl;
};
const handleConfirmAlbumFetch = async () => {
@@ -179,6 +212,7 @@ export function useMetadata() {
}
}
setMetadata(data);
+ saveToHistory(albumUrl, data);
if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
@@ -200,18 +234,15 @@ export function useMetadata() {
return {
loading,
metadata,
- showTimeoutDialog,
- setShowTimeoutDialog,
- timeoutValue,
- setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
- handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
+ loadFromCache,
+ resetMetadata: () => setMetadata(null),
};
}
diff --git a/frontend/src/hooks/usePreview.ts b/frontend/src/hooks/usePreview.ts
index f9ff87a..72c3abe 100644
--- a/frontend/src/hooks/usePreview.ts
+++ b/frontend/src/hooks/usePreview.ts
@@ -1,10 +1,18 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState(null);
const [currentAudio, setCurrentAudio] = useState(null);
const [playingTrack, setPlayingTrack] = useState(null);
+ useEffect(() => {
+ return () => {
+ if (currentAudio) {
+ currentAudio.pause();
+ currentAudio.currentTime = 0;
+ }
+ };
+ }, [currentAudio]);
const playPreview = async (trackId: string, trackName: string) => {
try {
if (playingTrack === trackId && currentAudio) {
diff --git a/frontend/src/hooks/useTypingEffect.ts b/frontend/src/hooks/useTypingEffect.ts
new file mode 100644
index 0000000..9f2b795
--- /dev/null
+++ b/frontend/src/hooks/useTypingEffect.ts
@@ -0,0 +1,35 @@
+import { useState, useEffect } from 'react';
+export function useTypingEffect(texts: string[], typingSpeed: number = 50, deletingSpeed: number = 50, pauseDuration: number = 1500) {
+ const [displayedText, setDisplayedText] = useState('');
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [textIndex, setTextIndex] = useState(0);
+ useEffect(() => {
+ setDisplayedText("");
+ setIsDeleting(false);
+ setTextIndex(0);
+ }, [texts]);
+ useEffect(() => {
+ const currentText = texts[textIndex % texts.length];
+ let timer: ReturnType;
+ if (isDeleting) {
+ timer = setTimeout(() => {
+ setDisplayedText((prev) => prev.substring(0, prev.length - 1));
+ }, deletingSpeed);
+ }
+ else {
+ timer = setTimeout(() => {
+ setDisplayedText((prev) => currentText.substring(0, prev.length + 1));
+ }, typingSpeed);
+ }
+ if (!isDeleting && displayedText === currentText) {
+ clearTimeout(timer);
+ timer = setTimeout(() => setIsDeleting(true), pauseDuration);
+ }
+ else if (isDeleting && displayedText === '') {
+ setIsDeleting(false);
+ setTextIndex((prev) => (prev + 1) % texts.length);
+ }
+ return () => clearTimeout(timer);
+ }, [displayedText, isDeleting, textIndex, texts, typingSpeed, deletingSpeed, pauseDuration]);
+ return displayedText;
+}
diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts
index 93b98ee..9d9d61d 100644
--- a/frontend/src/lib/settings.ts
+++ b/frontend/src/lib/settings.ts
@@ -25,6 +25,7 @@ export interface Settings {
amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
autoQuality: "16" | "24";
+ allowFallback: boolean;
}
export const FOLDER_PRESETS: Record {
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
+ if (!('allowFallback' in parsed)) {
+ parsed.allowFallback = true;
+ }
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!;
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 17f7244..4567feb 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -28,6 +28,7 @@ export interface TrackMetadata {
publisher?: string;
plays?: string;
status?: string;
+ is_explicit?: boolean;
}
export interface TrackResponse {
track: TrackMetadata;
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..d5337f4
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+declare const __APP_VERSION__: string;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 67039fd..261196c 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,11 @@
import path from "path";
+import fs from "fs";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
+const wailsJsonPath = path.resolve(__dirname, "../wails.json");
+const wailsJson = JSON.parse(fs.readFileSync(wailsJsonPath, "utf-8"));
+const appVersion = wailsJson.info.productVersion;
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -9,4 +13,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ define: {
+ __APP_VERSION__: JSON.stringify(appVersion),
+ },
});
diff --git a/wails.json b/wails.json
index 69551b5..77fdc2e 100644
--- a/wails.json
+++ b/wails.json
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
- "productVersion": "7.0.6",
+ "productVersion": "7.0.7",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",