import { Link, useParams } from "@tanstack/react-router"; import { useEffect, useState, useContext, useRef, useCallback } from "react"; import { toast } from "sonner"; import apiClient from "../lib/api-client"; import type { LibrespotAlbumType, LibrespotArtistType, LibrespotTrackType, LibrespotImage } from "@/types/librespot"; import { QueueContext, getStatus } from "../contexts/queue-context"; import { useSettings } from "../contexts/settings-context"; import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa"; import { AlbumCard } from "../components/AlbumCard"; // Narrow type for the artist info response additions type ArtistInfoResponse = LibrespotArtistType & { biography?: Array<{ text?: string; portrait_group?: { image?: LibrespotImage[] } }>; portrait_group?: { image?: LibrespotImage[] }; top_track?: Array<{ country: string; track: string[] }>; album_group?: string[]; single_group?: string[]; compilation_group?: string[]; appears_on_group?: string[]; }; export const Artist = () => { const { artistId } = useParams({ from: "/artist/$artistId" }); const [artist, setArtist] = useState(null); const [artistAlbums, setArtistAlbums] = useState([]); const [artistSingles, setArtistSingles] = useState([]); const [artistCompilations, setArtistCompilations] = useState([]); const [artistAppearsOn, setArtistAppearsOn] = useState([]); const [topTracks, setTopTracks] = useState([]); const [bannerUrl, setBannerUrl] = useState(null); const [isWatched, setIsWatched] = useState(false); const [artistStatus, setArtistStatus] = useState(null); const [error, setError] = useState(null); const context = useContext(QueueContext); const { settings } = useSettings(); const sentinelRef = useRef(null); // Pagination state const ALBUM_BATCH = 12; const [albumOffset, setAlbumOffset] = useState(0); const [singleOffset, setSingleOffset] = useState(0); const [compOffset, setCompOffset] = useState(0); const [appearsOffset, setAppearsOffset] = useState(0); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); // assume more until we learn otherwise if (!context) { throw new Error("useQueue must be used within a QueueProvider"); } const { addItem, items } = context; // Preload commonly used icons ASAP (before first buttons need them) useEffect(() => { const i = new Image(); i.src = "/download.svg"; return () => { /* no-op */ }; }, []); // Track queue status mapping const trackStatuses = topTracks.reduce((acc, t) => { const qi = items.find(item => item.downloadType === "track" && item.spotifyId === t.id); acc[t.id] = qi ? getStatus(qi) : null; return acc; }, {} as Record); // Helper: fetch a batch of albums by ids const fetchAlbumsByIds = useCallback(async (ids: string[]): Promise => { const results = await Promise.all( ids.map((id) => apiClient.get(`/album/info?id=${id}`).then(r => r.data).catch(() => null)) ); return results.filter(Boolean) as LibrespotAlbumType[]; }, []); // Fetch artist info & first page of albums useEffect(() => { if (!artistId) return; let cancelled = false; const fetchInitial = async () => { setLoading(true); setError(null); setArtistAlbums([]); setArtistSingles([]); setArtistCompilations([]); setArtistAppearsOn([]); setAlbumOffset(0); setSingleOffset(0); setCompOffset(0); setAppearsOffset(0); setHasMore(true); setBannerUrl(null); // reset hero; will lazy-load below try { const resp = await apiClient.get(`/artist/info?id=${artistId}`); const data: ArtistInfoResponse = resp.data; if (cancelled) return; if (data?.id && data?.name) { // set artist meta setArtist(data); // Lazy-load banner image after render const bioEntry = Array.isArray(data.biography) && data.biography.length > 0 ? data.biography[0] : undefined; const portraitImages = data.portrait_group?.image ?? bioEntry?.portrait_group?.image ?? []; const allImages = [...(portraitImages ?? []), ...((data.images as LibrespotImage[] | undefined) ?? [])]; const candidateBanner = allImages.sort((a, b) => (b?.width ?? 0) - (a?.width ?? 0))[0]?.url || "/placeholder.jpg"; // Use async preload to avoid blocking initial paint setTimeout(() => { const img = new Image(); img.src = candidateBanner; img.onload = () => { if (!cancelled) setBannerUrl(candidateBanner); }; }, 0); // top tracks (if provided) const topTrackIds = Array.isArray(data.top_track) && data.top_track.length > 0 ? data.top_track[0].track.slice(0, 10) : []; if (topTrackIds.length) { const tracksFull = await Promise.all( topTrackIds.map((id) => apiClient.get(`/track/info?id=${id}`).then(r => r.data).catch(() => null)) ); if (!cancelled) setTopTracks(tracksFull.filter(Boolean) as LibrespotTrackType[]); } else { if (!cancelled) setTopTracks([]); } // Progressive album loading: album -> single -> compilation -> appears_on const albumIds = data.album_group ?? []; const singleIds = data.single_group ?? []; const compIds = data.compilation_group ?? []; const appearsIds = data.appears_on_group ?? []; // Determine initial number based on screen size: 4 on small screens const isSmallScreen = typeof window !== "undefined" && !window.matchMedia("(min-width: 640px)").matches; const initialTarget = isSmallScreen ? 4 : ALBUM_BATCH; // Load initial sets from each group in order until initialTarget reached let aOff = 0, sOff = 0, cOff = 0, apOff = 0; let loaded = 0; let aList: LibrespotAlbumType[] = []; let sList: LibrespotAlbumType[] = []; let cList: LibrespotAlbumType[] = []; let apList: LibrespotAlbumType[] = []; if (albumIds.length > 0 && loaded < initialTarget) { const take = albumIds.slice(0, initialTarget - loaded); aList = await fetchAlbumsByIds(take); aOff = take.length; loaded += aList.length; } if (singleIds.length > 0 && loaded < initialTarget) { const take = singleIds.slice(0, initialTarget - loaded); sList = await fetchAlbumsByIds(take); sOff = take.length; loaded += sList.length; } if (compIds.length > 0 && loaded < initialTarget) { const take = compIds.slice(0, initialTarget - loaded); cList = await fetchAlbumsByIds(take); cOff = take.length; loaded += cList.length; } if (appearsIds.length > 0 && loaded < initialTarget) { const take = appearsIds.slice(0, initialTarget - loaded); apList = await fetchAlbumsByIds(take); apOff = take.length; loaded += apList.length; } if (!cancelled) { setArtistAlbums(aList); setArtistSingles(sList); setArtistCompilations(cList); setArtistAppearsOn(apList); // Store offsets for next loads setAlbumOffset(aOff); setSingleOffset(sOff); setCompOffset(cOff); setAppearsOffset(apOff); // Determine if more remain setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (compIds.length > cOff) || (appearsIds.length > apOff)); } } else { setError("Could not load artist data."); } // fetch watch status try { const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`); if (!cancelled) setIsWatched(watchStatusResponse.data.is_watched); } catch (e) { // ignore watch status errors console.warn("Failed to load watch status", e); } } catch (err) { if (!cancelled) { console.error(err); setError("Failed to load artist page"); } } finally { if (!cancelled) setLoading(false); } }; fetchInitial(); return () => { cancelled = true; }; }, [artistId, fetchAlbumsByIds]); // Fetch more albums (next page) const fetchMoreAlbums = useCallback(async () => { if (!artistId || loadingMore || loading || !hasMore || !artist) return; setLoadingMore(true); try { const albumIds = artist.album_group ?? []; const singleIds = artist.single_group ?? []; const compIds = artist.compilation_group ?? []; const appearsIds = artist.appears_on_group ?? []; const nextA: LibrespotAlbumType[] = []; const nextS: LibrespotAlbumType[] = []; const nextC: LibrespotAlbumType[] = []; const nextAp: LibrespotAlbumType[] = []; let aOff = albumOffset, sOff = singleOffset, cOff = compOffset, apOff = appearsOffset; const totalLoaded = () => nextA.length + nextS.length + nextC.length + nextAp.length; if (aOff < albumIds.length && totalLoaded() < ALBUM_BATCH) { const remaining = ALBUM_BATCH - totalLoaded(); const take = albumIds.slice(aOff, aOff + remaining); nextA.push(...await fetchAlbumsByIds(take)); aOff += take.length; } if (sOff < singleIds.length && totalLoaded() < ALBUM_BATCH) { const remaining = ALBUM_BATCH - totalLoaded(); const take = singleIds.slice(sOff, sOff + remaining); nextS.push(...await fetchAlbumsByIds(take)); sOff += take.length; } if (cOff < compIds.length && totalLoaded() < ALBUM_BATCH) { const remaining = ALBUM_BATCH - totalLoaded(); const take = compIds.slice(cOff, cOff + remaining); nextC.push(...await fetchAlbumsByIds(take)); cOff += take.length; } if (apOff < appearsIds.length && totalLoaded() < ALBUM_BATCH) { const remaining = ALBUM_BATCH - totalLoaded(); const take = appearsIds.slice(apOff, apOff + remaining); nextAp.push(...await fetchAlbumsByIds(take)); apOff += take.length; } setArtistAlbums((cur) => cur.concat(nextA)); setArtistSingles((cur) => cur.concat(nextS)); setArtistCompilations((cur) => cur.concat(nextC)); setArtistAppearsOn((cur) => cur.concat(nextAp)); setAlbumOffset(aOff); setSingleOffset(sOff); setCompOffset(cOff); setAppearsOffset(apOff); setHasMore((albumIds.length > aOff) || (singleIds.length > sOff) || (compIds.length > cOff) || (appearsIds.length > apOff)); } catch (err) { console.error("Failed to load more albums", err); toast.error("Failed to load more albums"); setHasMore(false); } finally { setLoadingMore(false); } }, [artistId, loadingMore, loading, hasMore, artist, albumOffset, singleOffset, compOffset, appearsOffset, fetchAlbumsByIds]); // IntersectionObserver to trigger fetchMoreAlbums when sentinel is visible useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; if (!hasMore) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { fetchMoreAlbums(); } }); }, { root: null, rootMargin: "400px", // start loading a bit before the sentinel enters viewport threshold: 0.1, } ); observer.observe(sentinel); return () => observer.disconnect(); }, [fetchMoreAlbums, hasMore]); // --- existing handlers (unchanged) --- const handleDownloadTrack = (track: LibrespotTrackType) => { if (!track.id) return; toast.info(`Adding ${track.name} to queue...`); addItem({ spotifyId: track.id, type: "track", name: track.name }); }; const handleDownloadAlbum = (album: LibrespotAlbumType) => { toast.info(`Adding ${album.name} to queue...`); addItem({ spotifyId: album.id, type: "album", name: album.name }); }; const handleDownloadArtist = async () => { setArtistStatus("downloading"); if (!artistId || !artist) return; try { toast.info(`Downloading ${artist.name} discography...`); // Call the artist download endpoint which returns album task IDs const response = await apiClient.get(`/artist/download/${artistId}`); if (response.data.queued_albums?.length > 0) { setArtistStatus("queued"); toast.success(`${artist.name} discography queued successfully!`, { description: `${response.data.queued_albums.length} albums added to queue.`, }); } else { setArtistStatus(null); toast.info("No new albums to download for this artist."); } } catch (error: any) { setArtistStatus("error"); console.error("Artist download failed:", error); toast.error("Failed to download artist", { description: error.response?.data?.error || "An unexpected error occurred.", }); } }; const handleDownloadGroup = async (group: "album" | "single" | "compilation" | "appears_on") => { if (!artistId || !artist) return; try { toast.info(`Queueing ${group} downloads for ${artist.name}...`); const response = await apiClient.get(`/artist/download/${artistId}?album_type=${group}`); const count = response.data?.queued_albums?.length ?? 0; if (count > 0) { toast.success(`Queued ${count} ${group}${count > 1 ? "s" : ""}.`); } else { toast.info(`No new ${group} releases to download.`); } } catch (error: any) { console.error(`Failed to queue ${group} downloads:`, error); toast.error(`Failed to queue ${group} downloads`, { description: error.response?.data?.error || "An unexpected error occurred.", }); } }; const handleToggleWatch = async () => { if (!artistId || !artist) return; try { if (isWatched) { await apiClient.delete(`/artist/watch/${artistId}`); toast.success(`Removed ${artist.name} from watchlist.`); } else { await apiClient.put(`/artist/watch/${artistId}`); toast.success(`Added ${artist.name} to watchlist.`); } setIsWatched(!isWatched); } catch (err) { toast.error("Failed to update watchlist."); console.error(err); } }; if (error) { return
{error}
; } if (loading && !artist) { return
Loading...
; } if (!artist) { return
Artist data could not be fully loaded. Please try again later.
; } return (
{/* Hero banner using highest resolution image (lazy-loaded) */}

{artist.name}

{settings?.watch?.enabled && ( )}
{topTracks.length > 0 && (

Top Tracks

{topTracks.map((track, index) => (
= 5 ? "hidden sm:flex" : ""}`} > {track.name}
))}
)} {/* Albums */} {artistAlbums.length > 0 && (

Albums

{artistAlbums.map((album) => ( handleDownloadAlbum(album)} /> ))}
)} {/* Singles */} {artistSingles.length > 0 && (

Singles

{artistSingles.map((album) => ( handleDownloadAlbum(album)} /> ))}
)} {/* Compilations */} {artistCompilations.length > 0 && (

Compilations

{artistCompilations.map((album) => ( handleDownloadAlbum(album)} /> ))}
)} {/* Appears On */} {artistAppearsOn.length > 0 && (

Appears On

{artistAppearsOn.map((album) => ( handleDownloadAlbum(album)} /> ))}
)} {/* sentinel + loading */}
{loadingMore &&
Loading more...
} {!hasMore && !loading &&
End of discography
} {/* fallback load more button for browsers that block IntersectionObserver or for manual control */} {hasMore && !loadingMore && ( )}
); };