import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { TitleBar } from "@/components/TitleBar"; import { Sidebar, type PageType } from "@/components/Sidebar"; import { Header } from "@/components/Header"; import { SearchBar } from "@/components/SearchBar"; import { TrackInfo } from "@/components/TrackInfo"; import { AlbumInfo } from "@/components/AlbumInfo"; import { PlaylistInfo } from "@/components/PlaylistInfo"; import { ArtistInfo } from "@/components/ArtistInfo"; import { DownloadQueue } from "@/components/DownloadQueue"; import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioConverterPage } from "@/components/AudioConverterPage"; import { AudioResamplerPage } from "@/components/AudioResamplerPage"; import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import { AboutPage } from "@/components/AboutPage"; import { HistoryPage } from "@/components/HistoryPage"; import type { HistoryItem } from "@/components/FetchHistory"; import { useDownload } from "@/hooks/useDownload"; import { useMetadata } from "@/hooks/useMetadata"; import { useLyrics } from "@/hooks/useLyrics"; import { useCover } from "@/hooks/useCover"; import { useAvailability } from "@/hooks/useAvailability"; import { ensureApiStatusCheckStarted } from "@/lib/api-status"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { buildPlaylistFolderName } from "@/lib/playlist"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } | null { const trimmed = url.trim(); if (!trimmed) { return null; } const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); if (spotifyUriMatch) { return { type: spotifyUriMatch[1].toLowerCase(), id: spotifyUriMatch[2], }; } try { const parsed = new URL(trimmed); const segments = parsed.pathname.split("/").filter(Boolean); const supportedTypes = new Set(["track", "album", "playlist", "artist"]); for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i].toLowerCase(); if (!supportedTypes.has(segment)) { continue; } const id = segments[i + 1]; if (id) { return { type: segment, id }; } } } catch { } return null; } function normalizeHistoryURL(url: string): string { const trimmed = url.trim(); if (!trimmed) return trimmed; const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); if (spotifyEntity) { return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`; } return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); } function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { const normalizedUrl = normalizeHistoryURL(url); const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); if (spotifyEntity) { return `${type}:${spotifyEntity.id}`; } return `${type}:${normalizedUrl}`; } function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { const seen = new Set(); const deduped: HistoryItem[] = []; for (const item of items) { const normalizedUrl = normalizeHistoryURL(item.url); const key = getHistoryIdentityKey(item.type, normalizedUrl); if (seen.has(key)) continue; seen.add(key); deduped.push({ ...item, url: normalizedUrl }); } return deduped; } function sortHistoryItems(items: HistoryItem[]): HistoryItem[] { return [...items].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); } function normalizeHistoryItems(items: HistoryItem[]): HistoryItem[] { return dedupeHistoryItems(sortHistoryItems(items)).slice(0, MAX_HISTORY); } function parseStoredHistory(value: string | null): HistoryItem[] { if (!value) { return []; } try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (err) { console.error("Failed to parse stored history:", err); return []; } } function App() { const [currentPage, setCurrentPage] = useState("main"); const contentScrollRef = useRef(null); const [spotifyUrl, setSpotifyUrl] = useState(""); const [selectedTracks, setSelectedTracks] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [sortBy, setSortBy] = useState("default"); const [currentListPage, setCurrentListPage] = useState(1); const [hasUpdate, setHasUpdate] = useState(false); const [releaseDate, setReleaseDate] = useState(null); const [fetchHistory, setFetchHistory] = useState([]); const [isSearchMode, setIsSearchMode] = useState(false); const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US"); useEffect(() => { localStorage.setItem("spotiflac_region", region); }, [region]); const [showScrollTop, setShowScrollTop] = useState(false); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [pendingPageChange, setPendingPageChange] = useState(null); const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const ITEMS_PER_PAGE = 50; const CURRENT_VERSION = __APP_VERSION__; const download = useDownload(region); const metadata = useMetadata(); const lyrics = useLyrics(); const cover = useCover(); const availability = useAvailability(); const downloadQueue = useDownloadQueueDialog(); const downloadProgress = useDownloadProgress(); const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null); const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); useLayoutEffect(() => { const savedSettings = getSettings(); if (savedSettings) { applyThemeMode(savedSettings.themeMode); applyTheme(savedSettings.theme); applyFont(savedSettings.fontFamily); } }, []); useEffect(() => { const initSettings = async () => { const settings = await loadSettings(); applyThemeMode(settings.themeMode); applyTheme(settings.theme); applyFont(settings.fontFamily); if (!settings.downloadPath) { const settingsWithDefaults = await getSettingsWithDefaults(); await saveSettings(settingsWithDefaults); } }; initSettings(); const checkFFmpeg = async () => { try { const installed = await CheckFFmpegInstalled(); setIsFFmpegInstalled(installed); } catch (err) { console.error("Failed to check FFmpeg:", err); setIsFFmpegInstalled(false); } }; checkFFmpeg(); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { const currentSettings = getSettings(); if (currentSettings.themeMode === "auto") { applyThemeMode("auto"); applyTheme(currentSettings.theme); } }; mediaQuery.addEventListener("change", handleChange); checkForUpdates(); ensureApiStatusCheckStarted(); void loadHistory(); return () => { mediaQuery.removeEventListener("change", handleChange); }; }, []); useEffect(() => { const contentElement = contentScrollRef.current; if (!contentElement) { return; } const handleScroll = () => { setShowScrollTop(contentElement.scrollTop > 300); }; handleScroll(); contentElement.addEventListener("scroll", handleScroll, { passive: true }); return () => { contentElement.removeEventListener("scroll", handleScroll); }; }, []); const scrollToTop = useCallback(() => { contentScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, []); useEffect(() => { contentScrollRef.current?.scrollTo({ top: 0, behavior: "auto" }); setShowScrollTop(false); }, [currentPage]); useEffect(() => { setSelectedTracks([]); setSearchQuery(""); download.resetDownloadedTracks(); lyrics.resetLyricsState(); cover.resetCoverState(); availability.clearAvailability(); setSortBy("default"); setCurrentListPage(1); }, [metadata.metadata]); const checkForUpdates = async () => { try { const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"); const data = await response.json(); const latestVersion = data.tag_name?.replace(/^v/, "") || ""; if (data.published_at) { setReleaseDate(data.published_at); } if (latestVersion && latestVersion > CURRENT_VERSION) { setHasUpdate(true); } } catch (err) { console.error("Failed to check for updates:", err); } }; const persistRecentHistory = useCallback(async (history: HistoryItem[]) => { try { await SaveRecentFetches(JSON.stringify(history)); } catch (err) { console.error("Failed to save recent fetches:", err); } }, []); const loadHistory = useCallback(async () => { try { const saved = parseStoredHistory(localStorage.getItem(HISTORY_KEY)); const persisted = parseStoredHistory(await GetRecentFetches()); const normalized = normalizeHistoryItems([...persisted, ...saved]); setFetchHistory(normalized); await persistRecentHistory(normalized); } catch (err) { console.error("Failed to load history:", err); } finally { localStorage.removeItem(HISTORY_KEY); } }, [persistRecentHistory]); const handleInstallFFmpeg = async () => { setIsInstallingFFmpeg(true); setFfmpegInstallProgress(0); setFfmpegInstallStatus("starting"); try { EventsOn("ffmpeg:progress", (progress: number) => { setFfmpegInstallProgress(progress); if (progress >= 100) { setFfmpegInstallStatus("extracting"); } else { setFfmpegInstallStatus("downloading"); } }); EventsOn("ffmpeg:status", (status: string) => { setFfmpegInstallStatus(status); }); const response = await DownloadFFmpeg(); EventsOff("ffmpeg:progress"); EventsOff("ffmpeg:status"); if (response.success) { toast.success("FFmpeg installed successfully!"); setIsFFmpegInstalled(true); } else { toast.error(`Failed to install FFmpeg: ${response.error}`); } } catch (error) { console.error("Error installing FFmpeg:", error); toast.error(`Error during FFmpeg installation: ${error}`); } finally { setIsInstallingFFmpeg(false); setFfmpegInstallProgress(0); setFfmpegInstallStatus(""); } }; const addToHistory = (item: Omit) => { setFetchHistory((prev) => { const normalizedUrl = normalizeHistoryURL(item.url); const identityKey = getHistoryIdentityKey(item.type, normalizedUrl); const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey); const newItem: HistoryItem = { ...item, url: normalizedUrl, id: crypto.randomUUID(), timestamp: Date.now(), }; const updated = normalizeHistoryItems([newItem, ...filtered]); void persistRecentHistory(updated); return updated; }); }; const removeFromHistory = (id: string) => { setFetchHistory((prev) => { if (!prev.some((h) => h.id === id)) return prev; const updated = prev.filter((h) => h.id !== id); void persistRecentHistory(updated); return updated; }); }; const handleHistorySelect = async (item: HistoryItem) => { setSpotifyUrl(item.url); const updatedUrl = await metadata.handleFetchMetadata(item.url); if (updatedUrl) { setSpotifyUrl(updatedUrl); } }; const handleFetchMetadata = async () => { const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl); if (updatedUrl) { setSpotifyUrl(updatedUrl); } }; useEffect(() => { if (!metadata.metadata || !spotifyUrl) return; let historyItem: Omit | null = null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; historyItem = { url: spotifyUrl, type: "track", name: track.name, artist: track.artists, image: track.images, }; } else if ("album_info" in metadata.metadata) { const { album_info } = metadata.metadata; historyItem = { url: spotifyUrl, type: "album", name: album_info.name, artist: `${album_info.total_tracks.toLocaleString()} tracks`, image: album_info.images, }; } else if ("playlist_info" in metadata.metadata) { const { playlist_info } = metadata.metadata; historyItem = { url: spotifyUrl, type: "playlist", name: playlist_info.owner.name, artist: `${playlist_info.tracks.total.toLocaleString()} tracks`, image: playlist_info.cover || playlist_info.owner.images || "", }; } else if ("artist_info" in metadata.metadata) { const { artist_info } = metadata.metadata; historyItem = { url: spotifyUrl, type: "artist", name: artist_info.name, artist: `${artist_info.total_albums.toLocaleString()} albums`, image: artist_info.images, }; } if (historyItem) { addToHistory(historyItem); } }, [metadata.metadata]); const handleSearchChange = (value: string) => { setSearchQuery(value); setCurrentListPage(1); }; const toggleTrackSelection = (id: string) => { setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]); }; const toggleSelectAll = (tracks: any[]) => { const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || ""); if (tracksWithId.length === 0) return; const allSelected = tracksWithId.every(id => selectedTracks.includes(id)); if (allSelected) { setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id))); } else { setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId]))); } }; const handleOpenFolder = async () => { const settings = getSettings(); if (!settings.downloadPath) { toast.error("Download path not set"); return; } try { await OpenFolder(settings.downloadPath); } catch (error) { console.error("Error opening folder:", error); toast.error(`Error opening folder: ${error}`); } }; const renderMetadata = () => { if (!metadata.metadata) return null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; const trackId = track.spotify_id || ""; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); } }} onBack={metadata.resetMetadata}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); } }} onTrackClick={async (track) => { if (track.external_urls) { setSpotifyUrl(track.external_urls); await metadata.handleFetchMetadata(track.external_urls); } }}/>); } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; const settings = getSettings(); const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName); return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); } }} onTrackClick={async (track) => { if (track.external_urls) { setSpotifyUrl(track.external_urls); await metadata.handleFetchMetadata(track.external_urls); } }}/>); } if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); } }} onPageChange={setCurrentListPage} onTrackClick={async (track) => { if (track.external_urls) { setSpotifyUrl(track.external_urls); await metadata.handleFetchMetadata(track.external_urls); } }}/>); } return null; }; const handlePageChange = (page: PageType) => { if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") { setPendingPageChange(page); setShowUnsavedChangesDialog(true); return; } setCurrentPage(page); }; const handleDiscardChanges = () => { setShowUnsavedChangesDialog(false); if (resetSettingsFn) { resetSettingsFn(); } const savedSettings = getSettings(); applyThemeMode(savedSettings.themeMode); applyTheme(savedSettings.theme); applyFont(savedSettings.fontFamily); if (pendingPageChange) { setCurrentPage(pendingPageChange); setPendingPageChange(null); } }; const handleCancelNavigation = () => { setShowUnsavedChangesDialog(false); setPendingPageChange(null); }; const renderPage = () => { switch (currentPage) { case "settings": return ; case "debug": return ; case "about": return ; case "history": return { metadata.loadFromCache(cachedData); setCurrentPage("main"); }}/>; case "audio-analysis": return ; case "audio-converter": return ; case "audio-resampler": return ; case "file-manager": return ; default: return (<>
Fetch Album Do you want to fetch metadata for this album? {metadata.selectedAlbum && (

{metadata.selectedAlbum.name}

)}
{ setSpotifyUrl(url); const updatedUrl = await metadata.handleFetchMetadata(url); if (updatedUrl) { setSpotifyUrl(updatedUrl); } }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/> {!isSearchMode && metadata.metadata && renderMetadata()} ); } }; return (
{renderPage()}
{showScrollTop && ()} Unsaved Changes You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost. Fetch Failed Metadata fetch failed. Try using a high-quality VPN such as Surfshark, ExpressVPN, Proton VPN, or a similar service. Choose a location that is not blocked by Spotify or the related service, such as the USA, UK, Germany, Netherlands, or Singapore. If you are already using a VPN, try switching to another server and fetch again. { }}> FFmpeg Required SpotiFLAC checks your system for FFmpeg and FFprobe first. If they are not available, the required binaries will be downloaded from GitHub. This setup downloads about 30-40MB of data. {isInstallingFFmpeg && (
{ffmpegInstallStatus === "extracting" ? (
Extracting...
Finalizing setup
) : (
Downloading... {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( {downloadProgress.mb_downloaded.toFixed(1)}MB {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`} )}
{ffmpegInstallProgress}%
)}
)} {!isInstallingFFmpeg && ()}
); } export default App;