|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
import { Link, useParams } from "@tanstack/react-router";
|
|
|
|
import { Link, useParams } from "@tanstack/react-router";
|
|
|
|
import { useEffect, useState, useContext } from "react";
|
|
|
|
import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
|
|
|
import { toast } from "sonner";
|
|
|
|
import { toast } from "sonner";
|
|
|
|
import apiClient from "../lib/api-client";
|
|
|
|
import apiClient from "../lib/api-client";
|
|
|
|
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
|
|
|
|
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
|
|
|
|
@@ -18,58 +18,170 @@ export const Artist = () => {
|
|
|
|
const context = useContext(QueueContext);
|
|
|
|
const context = useContext(QueueContext);
|
|
|
|
const { settings } = useSettings();
|
|
|
|
const { settings } = useSettings();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Pagination state
|
|
|
|
|
|
|
|
const LIMIT = 20; // tune as you like
|
|
|
|
|
|
|
|
const [offset, setOffset] = useState<number>(0);
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
|
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
|
|
|
|
|
|
|
const [hasMore, setHasMore] = useState<boolean>(true); // assume more until we learn otherwise
|
|
|
|
|
|
|
|
|
|
|
|
if (!context) {
|
|
|
|
if (!context) {
|
|
|
|
throw new Error("useQueue must be used within a QueueProvider");
|
|
|
|
throw new Error("useQueue must be used within a QueueProvider");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const { addItem } = context;
|
|
|
|
const { addItem } = context;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const applyFilters = useCallback(
|
|
|
|
const fetchArtistData = async () => {
|
|
|
|
(items: AlbumType[]) => {
|
|
|
|
if (!artistId) return;
|
|
|
|
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
|
|
|
|
try {
|
|
|
|
},
|
|
|
|
const response = await apiClient.get(`/artist/info?id=${artistId}`);
|
|
|
|
[settings?.explicitFilter]
|
|
|
|
const artistData = response.data;
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if we have artist data in the response
|
|
|
|
// Helper to dedupe albums by id
|
|
|
|
if (artistData?.id && artistData?.name) {
|
|
|
|
const dedupeAppendAlbums = (current: AlbumType[], incoming: AlbumType[]) => {
|
|
|
|
// Set artist info directly from the response
|
|
|
|
const seen = new Set(current.map((a) => a.id));
|
|
|
|
|
|
|
|
const filtered = incoming.filter((a) => !seen.has(a.id));
|
|
|
|
|
|
|
|
return current.concat(filtered);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch artist info & first page of albums
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (!artistId) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const fetchInitial = async () => {
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
setAlbums([]);
|
|
|
|
|
|
|
|
setOffset(0);
|
|
|
|
|
|
|
|
setHasMore(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=0`);
|
|
|
|
|
|
|
|
const data = resp.data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (data?.id && data?.name) {
|
|
|
|
|
|
|
|
// set artist meta
|
|
|
|
setArtist({
|
|
|
|
setArtist({
|
|
|
|
id: artistData.id,
|
|
|
|
id: data.id,
|
|
|
|
name: artistData.name,
|
|
|
|
name: data.name,
|
|
|
|
images: artistData.images || [],
|
|
|
|
images: data.images || [],
|
|
|
|
external_urls: artistData.external_urls || { spotify: "" },
|
|
|
|
external_urls: data.external_urls || { spotify: "" },
|
|
|
|
followers: artistData.followers || { total: 0 },
|
|
|
|
followers: data.followers || { total: 0 },
|
|
|
|
genres: artistData.genres || [],
|
|
|
|
genres: data.genres || [],
|
|
|
|
popularity: artistData.popularity || 0,
|
|
|
|
popularity: data.popularity || 0,
|
|
|
|
type: artistData.type || 'artist',
|
|
|
|
type: data.type || "artist",
|
|
|
|
uri: artistData.uri || ''
|
|
|
|
uri: data.uri || "",
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Check if we have albums data
|
|
|
|
// top tracks (if provided)
|
|
|
|
if (artistData?.albums?.items && artistData.albums.items.length > 0) {
|
|
|
|
if (Array.isArray(data.top_tracks)) {
|
|
|
|
setAlbums(artistData.albums.items);
|
|
|
|
setTopTracks(data.top_tracks);
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
setError("No albums found for this artist.");
|
|
|
|
setTopTracks([]);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// albums pagination info
|
|
|
|
|
|
|
|
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || [];
|
|
|
|
|
|
|
|
const total: number | undefined = data?.albums?.total;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setAlbums(items);
|
|
|
|
|
|
|
|
setOffset(items.length);
|
|
|
|
|
|
|
|
if (typeof total === "number") {
|
|
|
|
|
|
|
|
setHasMore(items.length < total);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// If server didn't return total, default behavior: stop when an empty page arrives.
|
|
|
|
|
|
|
|
setHasMore(items.length > 0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
setError("Could not load artist data.");
|
|
|
|
setError("Could not load artist data.");
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTopTracks([]);
|
|
|
|
// fetch watch status
|
|
|
|
|
|
|
|
try {
|
|
|
|
const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
|
|
|
|
const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
|
|
|
|
setIsWatched(watchStatusResponse.data.is_watched);
|
|
|
|
if (!cancelled) setIsWatched(watchStatusResponse.data.is_watched);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
// ignore watch status errors
|
|
|
|
|
|
|
|
console.warn("Failed to load watch status", e);
|
|
|
|
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
} catch (err) {
|
|
|
|
setError("Failed to load artist page");
|
|
|
|
if (!cancelled) {
|
|
|
|
console.error(err);
|
|
|
|
console.error(err);
|
|
|
|
|
|
|
|
setError("Failed to load artist page");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
if (!cancelled) setLoading(false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
fetchArtistData();
|
|
|
|
fetchInitial();
|
|
|
|
}, [artistId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}, [artistId, LIMIT]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch more albums (next page)
|
|
|
|
|
|
|
|
const fetchMoreAlbums = useCallback(async () => {
|
|
|
|
|
|
|
|
if (!artistId || loadingMore || loading || !hasMore) return;
|
|
|
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const resp = await apiClient.get(`/artist/info?id=${artistId}&limit=${LIMIT}&offset=${offset}`);
|
|
|
|
|
|
|
|
const data = resp.data;
|
|
|
|
|
|
|
|
const items: AlbumType[] = (data?.albums?.items as AlbumType[]) || [];
|
|
|
|
|
|
|
|
const total: number | undefined = data?.albums?.total;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setAlbums((cur) => dedupeAppendAlbums(cur, items));
|
|
|
|
|
|
|
|
setOffset((cur) => cur + items.length);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof total === "number") {
|
|
|
|
|
|
|
|
setHasMore((prev) => prev && offset + items.length < total);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// if server doesn't expose total, stop when we get fewer than LIMIT items
|
|
|
|
|
|
|
|
setHasMore(items.length === LIMIT);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
console.error("Failed to load more albums", err);
|
|
|
|
|
|
|
|
toast.error("Failed to load more albums");
|
|
|
|
|
|
|
|
setHasMore(false);
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, [artistId, offset, LIMIT, loadingMore, loading, hasMore]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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: TrackType) => {
|
|
|
|
const handleDownloadTrack = (track: TrackType) => {
|
|
|
|
if (!track.id) return;
|
|
|
|
if (!track.id) return;
|
|
|
|
toast.info(`Adding ${track.name} to queue...`);
|
|
|
|
toast.info(`Adding ${track.name} to queue...`);
|
|
|
|
@@ -91,23 +203,17 @@ export const Artist = () => {
|
|
|
|
const response = await apiClient.get(`/artist/download/${artistId}`);
|
|
|
|
const response = await apiClient.get(`/artist/download/${artistId}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (response.data.queued_albums?.length > 0) {
|
|
|
|
if (response.data.queued_albums?.length > 0) {
|
|
|
|
toast.success(
|
|
|
|
toast.success(`${artist.name} discography queued successfully!`, {
|
|
|
|
`${artist.name} discography queued successfully!`,
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
description: `${response.data.queued_albums.length} albums added to queue.`,
|
|
|
|
description: `${response.data.queued_albums.length} albums added to queue.`,
|
|
|
|
}
|
|
|
|
});
|
|
|
|
);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
toast.info("No new albums to download for this artist.");
|
|
|
|
toast.info("No new albums to download for this artist.");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error: any) {
|
|
|
|
} catch (error: any) {
|
|
|
|
console.error("Artist download failed:", error);
|
|
|
|
console.error("Artist download failed:", error);
|
|
|
|
toast.error(
|
|
|
|
toast.error("Failed to download artist", {
|
|
|
|
"Failed to download artist",
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
description: error.response?.data?.error || "An unexpected error occurred.",
|
|
|
|
description: error.response?.data?.error || "An unexpected error occurred.",
|
|
|
|
}
|
|
|
|
});
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@@ -132,18 +238,14 @@ export const Artist = () => {
|
|
|
|
return <div className="text-red-500">{error}</div>;
|
|
|
|
return <div className="text-red-500">{error}</div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!artist) {
|
|
|
|
if (loading && !artist) {
|
|
|
|
return <div>Loading...</div>;
|
|
|
|
return <div>Loading...</div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!artist.name) {
|
|
|
|
if (!artist) {
|
|
|
|
return <div>Artist data could not be fully loaded. Please try again later.</div>;
|
|
|
|
return <div>Artist data could not be fully loaded. Please try again later.</div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const applyFilters = (items: AlbumType[]) => {
|
|
|
|
|
|
|
|
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album"));
|
|
|
|
const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album"));
|
|
|
|
const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single"));
|
|
|
|
const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single"));
|
|
|
|
const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation"));
|
|
|
|
const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation"));
|
|
|
|
@@ -178,8 +280,7 @@ export const Artist = () => {
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
onClick={handleToggleWatch}
|
|
|
|
onClick={handleToggleWatch}
|
|
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${
|
|
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${isWatched
|
|
|
|
isWatched
|
|
|
|
|
|
|
|
? "bg-button-primary text-button-primary-text border-primary"
|
|
|
|
? "bg-button-primary text-button-primary-text border-primary"
|
|
|
|
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
|
|
|
|
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
|
|
|
|
}`}
|
|
|
|
}`}
|
|
|
|
@@ -208,7 +309,11 @@ export const Artist = () => {
|
|
|
|
key={track.id}
|
|
|
|
key={track.id}
|
|
|
|
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
|
|
|
|
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold text-content-primary dark:text-content-primary-dark">
|
|
|
|
<Link
|
|
|
|
|
|
|
|
to="/track/$trackId"
|
|
|
|
|
|
|
|
params={{ trackId: track.id }}
|
|
|
|
|
|
|
|
className="font-semibold text-content-primary dark:text-content-primary-dark"
|
|
|
|
|
|
|
|
>
|
|
|
|
{track.name}
|
|
|
|
{track.name}
|
|
|
|
</Link>
|
|
|
|
</Link>
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
@@ -223,6 +328,7 @@ export const Artist = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Albums */}
|
|
|
|
{artistAlbums.length > 0 && (
|
|
|
|
{artistAlbums.length > 0 && (
|
|
|
|
<div className="mb-12">
|
|
|
|
<div className="mb-12">
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2>
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Albums</h2>
|
|
|
|
@@ -234,6 +340,7 @@ export const Artist = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Singles */}
|
|
|
|
{artistSingles.length > 0 && (
|
|
|
|
{artistSingles.length > 0 && (
|
|
|
|
<div className="mb-12">
|
|
|
|
<div className="mb-12">
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2>
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Singles</h2>
|
|
|
|
@@ -245,6 +352,7 @@ export const Artist = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Compilations */}
|
|
|
|
{artistCompilations.length > 0 && (
|
|
|
|
{artistCompilations.length > 0 && (
|
|
|
|
<div className="mb-12">
|
|
|
|
<div className="mb-12">
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2>
|
|
|
|
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Compilations</h2>
|
|
|
|
@@ -255,6 +363,22 @@ export const Artist = () => {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* sentinel + loading */}
|
|
|
|
|
|
|
|
<div className="flex flex-col items-center gap-2">
|
|
|
|
|
|
|
|
{loadingMore && <div className="py-4">Loading more...</div>}
|
|
|
|
|
|
|
|
{!hasMore && !loading && <div className="py-4 text-sm text-content-secondary">End of discography</div>}
|
|
|
|
|
|
|
|
{/* fallback load more button for browsers that block IntersectionObserver or for manual control */}
|
|
|
|
|
|
|
|
{hasMore && !loadingMore && (
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
onClick={() => fetchMoreAlbums()}
|
|
|
|
|
|
|
|
className="px-4 py-2 mb-6 rounded bg-surface-muted hover:bg-surface-muted-dark"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
Load more
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
<div ref={sentinelRef} style={{ height: 1, width: "100%" }} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|