import { useState, useEffect, useCallback, useLayoutEffect } 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, updateSettings } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } 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 App() { const [currentPage, setCurrentPage] = useState("main"); 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 [brewPath, setBrewPath] = useState(""); 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); const brew = await GetBrewPath(); setBrewPath(brew); } 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(); loadHistory(); const handleScroll = () => { setShowScrollTop(window.scrollY > 300); }; window.addEventListener("scroll", handleScroll); return () => { mediaQuery.removeEventListener("change", handleChange); window.removeEventListener("scroll", handleScroll); }; }, []); const handleEnableSpotFetchApi = async () => { try { await updateSettings({ useSpotFetchAPI: true }); metadata.setShowApiModal(false); toast.success("SpotFetch API enabled! You can now try fetching again."); } catch (err) { console.error("Failed to enable SpotFetch API:", err); toast.error("Failed to update settings"); } }; const scrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }, []); 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 loadHistory = () => { try { const saved = localStorage.getItem(HISTORY_KEY); if (saved) { const deduped = dedupeHistoryItems(JSON.parse(saved)); setFetchHistory(deduped); localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped)); } } catch (err) { console.error("Failed to load history:", err); } }; const handleInstallFFmpeg = async (useBrew: boolean = false) => { 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 = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg(); EventsOff("ffmpeg:progress"); EventsOff("ffmpeg:status"); if (response.success) { toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "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 saveHistory = (history: HistoryItem[]) => { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); } catch (err) { console.error("Failed to save history:", err); } }; 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 = [newItem, ...filtered].slice(0, MAX_HISTORY); saveHistory(updated); return updated; }); }; const removeFromHistory = (id: string) => { setFetchHistory((prev) => { const updated = prev.filter((h) => h.id !== id); saveHistory(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. { }}> FFmpeg Required {brewPath ? (<> FFmpeg is essential for SpotiFLAC to function properly. Homebrew detected. Recommended: brew install ffmpeg ) : (<> FFmpeg is essential for SpotiFLAC to function properly. This setup will download about 100-200MB 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 && ()} {brewPath ? () : ()}
SpotFetch API Recommended Direct fetch failed. This usually happens when your country is blocked by Spotify or your IP is restricted. Would you like to enable the SpotFetch API to bypass this?
); } export default App;