178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { api, type SongsResponse } from '../services/api';
|
|
import type { Song } from '../types/interfaces';
|
|
|
|
interface UsePaginatedSongsOptions {
|
|
pageSize?: number;
|
|
initialSearch?: string;
|
|
playlistName?: string;
|
|
}
|
|
|
|
export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|
const { pageSize = 50, initialSearch = '', playlistName } = options;
|
|
|
|
const [songs, setSongs] = useState<Song[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
|
const [totalSongs, setTotalSongs] = useState(0);
|
|
const [totalDuration, setTotalDuration] = useState<number | undefined>(undefined);
|
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
|
|
|
const loadingRef = useRef(false);
|
|
const currentPlaylistRef = useRef(playlistName);
|
|
const currentSearchQueryRef = useRef(searchQuery);
|
|
const previousPlaylistRef = useRef(playlistName);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Cleanup function to prevent memory leaks
|
|
const cleanup = useCallback(() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
abortControllerRef.current = null;
|
|
}
|
|
loadingRef.current = false;
|
|
}, []);
|
|
|
|
// Load songs for a specific page
|
|
const loadPage = useCallback(async (page: number, search?: string, targetPlaylist?: string) => {
|
|
if (loadingRef.current) return;
|
|
|
|
const searchToUse = search ?? currentSearchQueryRef.current;
|
|
const playlistToUse = targetPlaylist ?? currentPlaylistRef.current;
|
|
|
|
// Cleanup previous request
|
|
cleanup();
|
|
|
|
// Create new abort controller for this request
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
loadingRef.current = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
let response: SongsResponse;
|
|
|
|
if (playlistToUse && playlistToUse !== 'All Songs') {
|
|
// Load songs for specific playlist
|
|
response = await api.getPlaylistSongsPaginated(playlistToUse, page, pageSize, searchToUse);
|
|
} else {
|
|
// Load all songs
|
|
response = await api.getSongsPaginated(page, pageSize, searchToUse);
|
|
}
|
|
|
|
// Check if request was aborted
|
|
if (abortControllerRef.current?.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
if (page === 1) {
|
|
// First page - replace all songs
|
|
setSongs(response.songs);
|
|
} else {
|
|
// Subsequent pages - append songs
|
|
setSongs(prev => [...prev, ...response.songs]);
|
|
}
|
|
|
|
setHasMore(response.pagination.hasNextPage);
|
|
setTotalSongs(response.pagination.totalSongs);
|
|
setTotalDuration(response.totalDuration);
|
|
setCurrentPage(page);
|
|
} catch (err) {
|
|
// Don't set error if request was aborted
|
|
if (!abortControllerRef.current?.signal.aborted) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load songs');
|
|
}
|
|
} finally {
|
|
if (!abortControllerRef.current?.signal.aborted) {
|
|
setLoading(false);
|
|
loadingRef.current = false;
|
|
setIsInitialLoad(false);
|
|
}
|
|
}
|
|
}, [pageSize, cleanup]); // Remove searchQuery and playlistName from dependencies
|
|
|
|
// Load next page (for infinite scroll)
|
|
const loadNextPage = useCallback(() => {
|
|
if (!loading && hasMore && !loadingRef.current) {
|
|
loadPage(currentPage + 1);
|
|
}
|
|
}, [loading, hasMore, currentPage, loadPage]);
|
|
|
|
// Search songs with debouncing
|
|
const searchSongs = useCallback((query: string) => {
|
|
setSearchQuery(query);
|
|
// Clear songs for new search to replace them
|
|
setSongs([]);
|
|
setHasMore(true);
|
|
setCurrentPage(1);
|
|
setError(null);
|
|
loadPage(1, query);
|
|
}, [loadPage]);
|
|
|
|
// Reset to initial state
|
|
const reset = useCallback(() => {
|
|
cleanup();
|
|
setSongs([]);
|
|
setLoading(false);
|
|
setError(null);
|
|
setHasMore(true);
|
|
setCurrentPage(1);
|
|
setSearchQuery(initialSearch);
|
|
setIsInitialLoad(true);
|
|
}, [initialSearch, cleanup]);
|
|
|
|
// Initial load - only run once when the hook is first created
|
|
useEffect(() => {
|
|
// Only load if we haven't loaded anything yet
|
|
if (songs.length === 0 && !loading) {
|
|
loadPage(1);
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
return () => {
|
|
cleanup();
|
|
};
|
|
}, []);
|
|
|
|
// Handle playlist changes - optimized for immediate response
|
|
useEffect(() => {
|
|
if (previousPlaylistRef.current !== playlistName) {
|
|
// Update refs immediately
|
|
currentPlaylistRef.current = playlistName;
|
|
currentSearchQueryRef.current = searchQuery;
|
|
previousPlaylistRef.current = playlistName;
|
|
|
|
// Batch all state updates together to reduce re-renders
|
|
React.startTransition(() => {
|
|
setSongs([]);
|
|
setHasMore(true);
|
|
setCurrentPage(1);
|
|
setSearchQuery(initialSearch);
|
|
setError(null);
|
|
});
|
|
|
|
// Load immediately
|
|
loadPage(1, initialSearch, playlistName);
|
|
}
|
|
}, [playlistName, initialSearch, loadPage]);
|
|
|
|
return {
|
|
songs,
|
|
loading,
|
|
error,
|
|
hasMore,
|
|
totalSongs,
|
|
totalDuration,
|
|
currentPage,
|
|
searchQuery,
|
|
isInitialLoad,
|
|
loadNextPage,
|
|
searchSongs,
|
|
reset,
|
|
refresh: () => loadPage(1)
|
|
};
|
|
};
|