Merge pull request #337 from Phlogi/fix-slow-loading-of-watchlist
fix(ui): improve watchlist loading with batching and skeletons
This commit is contained in:
@@ -20,39 +20,77 @@ export const Watchlist = () => {
|
||||
const { settings, isLoading: settingsLoading } = useSettings();
|
||||
const [items, setItems] = useState<WatchedItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expectedCount, setExpectedCount] = useState<number | null>(null);
|
||||
|
||||
// Utility to batch fetch details
|
||||
async function batchFetch<T>(
|
||||
ids: string[],
|
||||
fetchFn: (id: string) => Promise<T>,
|
||||
batchSize: number,
|
||||
onBatch: (results: T[]) => void
|
||||
) {
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const batchIds = ids.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batchIds.map((id) => fetchFn(id).catch(() => null))
|
||||
);
|
||||
onBatch(batchResults.filter(Boolean) as T[]);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWatchlist = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setItems([]); // Clear previous items
|
||||
setExpectedCount(null);
|
||||
try {
|
||||
const [artistsRes, playlistsRes] = await Promise.all([
|
||||
apiClient.get<BaseWatched[]>("/artist/watch/list"),
|
||||
apiClient.get<BaseWatched[]>("/playlist/watch/list"),
|
||||
]);
|
||||
|
||||
const artistDetailsPromises = artistsRes.data.map((artist) =>
|
||||
apiClient.get<ArtistType>(`/artist/info?id=${artist.spotify_id}`),
|
||||
);
|
||||
const playlistDetailsPromises = playlistsRes.data.map((playlist) =>
|
||||
apiClient.get<PlaylistType>(`/playlist/info?id=${playlist.spotify_id}`),
|
||||
// Prepare lists of IDs
|
||||
const artistIds = artistsRes.data.map((artist) => artist.spotify_id);
|
||||
const playlistIds = playlistsRes.data.map((playlist) => playlist.spotify_id);
|
||||
setExpectedCount(artistIds.length + playlistIds.length);
|
||||
|
||||
// Allow UI to render grid and skeletons immediately
|
||||
setIsLoading(false);
|
||||
|
||||
// Helper to update state incrementally
|
||||
const appendItems = (newItems: WatchedItem[]) => {
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
};
|
||||
|
||||
// Fetch artist details in batches
|
||||
await batchFetch<ArtistType>(
|
||||
artistIds,
|
||||
(id) => apiClient.get<ArtistType>(`/artist/info?id=${id}`).then(res => res.data),
|
||||
5, // batch size
|
||||
(results) => {
|
||||
const items: WatchedArtist[] = results.map((data) => ({
|
||||
...data,
|
||||
itemType: "artist",
|
||||
}));
|
||||
appendItems(items);
|
||||
}
|
||||
);
|
||||
|
||||
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([
|
||||
Promise.all(artistDetailsPromises),
|
||||
Promise.all(playlistDetailsPromises),
|
||||
]);
|
||||
|
||||
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" }));
|
||||
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({
|
||||
...res.data,
|
||||
itemType: "playlist",
|
||||
spotify_id: res.data.id,
|
||||
}));
|
||||
|
||||
setItems([...artists, ...playlists]);
|
||||
// Fetch playlist details in batches
|
||||
await batchFetch<PlaylistType>(
|
||||
playlistIds,
|
||||
(id) => apiClient.get<PlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
|
||||
5, // batch size
|
||||
(results) => {
|
||||
const items: WatchedPlaylist[] = results.map((data) => ({
|
||||
...data,
|
||||
itemType: "playlist",
|
||||
spotify_id: data.id,
|
||||
}));
|
||||
appendItems(items);
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to load watchlist.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -110,7 +148,8 @@ export const Watchlist = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
// Show "empty" only if not loading and nothing expected
|
||||
if (!isLoading && items.length === 0 && (!expectedCount || expectedCount === 0)) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
|
||||
@@ -158,6 +197,25 @@ export const Watchlist = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Skeletons for loading items */}
|
||||
{isLoading && expectedCount && items.length < expectedCount &&
|
||||
Array.from({ length: expectedCount - items.length }).map((_, idx) => (
|
||||
<div
|
||||
key={`skeleton-${idx}`}
|
||||
className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col animate-pulse"
|
||||
>
|
||||
<div className="flex-grow">
|
||||
<div className="w-full aspect-square bg-gray-200 dark:bg-gray-700 rounded-md mb-2" />
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-1" />
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-1/2" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="w-full h-8 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="w-full h-8 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user