This commit is contained in:
Xoconoch
2025-07-26 19:44:23 -06:00
parent 2eb54a636b
commit 523eeed06b
12 changed files with 1110 additions and 250 deletions

View File

@@ -1,34 +1,46 @@
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } from "react";
import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner";
import type { PlaylistType, TrackType } from "../types/spotify";
import type { PlaylistType, TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa";
import { FaDownload } from "react-icons/fa6";
export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
const [playlist, setPlaylist] = useState<PlaylistType | null>(null);
const [playlistMetadata, setPlaylistMetadata] = useState<PlaylistMetadataType | null>(null);
const [tracks, setTracks] = useState<PlaylistItemType[]>([]);
const [isWatched, setIsWatched] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingTracks, setLoadingTracks] = useState(false);
const [hasMoreTracks, setHasMoreTracks] = useState(true);
const [tracksOffset, setTracksOffset] = useState(0);
const [totalTracks, setTotalTracks] = useState(0);
const context = useContext(QueueContext);
const { settings } = useSettings();
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
if (!context) {
throw new Error("useQueue must be used within a QueueProvider");
}
const { addItem } = context;
// Load playlist metadata first
useEffect(() => {
const fetchPlaylist = async () => {
const fetchPlaylistMetadata = async () => {
if (!playlistId) return;
try {
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`);
setPlaylist(response.data);
const response = await apiClient.get<PlaylistMetadataType>(`/playlist/metadata?id=${playlistId}`);
setPlaylistMetadata(response.data);
setTotalTracks(response.data.tracks.total);
} catch (err) {
setError("Failed to load playlist");
setError("Failed to load playlist metadata");
console.error(err);
}
};
@@ -45,10 +57,76 @@ export const Playlist = () => {
}
};
fetchPlaylist();
fetchPlaylistMetadata();
checkWatchStatus();
}, [playlistId]);
// Load tracks progressively
const loadMoreTracks = useCallback(async () => {
if (!playlistId || loadingTracks || !hasMoreTracks) return;
setLoadingTracks(true);
try {
const limit = 50; // Load 50 tracks at a time
const response = await apiClient.get<PlaylistTracksResponseType>(
`/playlist/tracks?id=${playlistId}&limit=${limit}&offset=${tracksOffset}`
);
const newTracks = response.data.items;
setTracks(prev => [...prev, ...newTracks]);
setTracksOffset(prev => prev + newTracks.length);
// Check if we've loaded all tracks
if (tracksOffset + newTracks.length >= totalTracks) {
setHasMoreTracks(false);
}
} catch (err) {
console.error("Failed to load tracks:", err);
toast.error("Failed to load more tracks");
} finally {
setLoadingTracks(false);
}
}, [playlistId, loadingTracks, hasMoreTracks, tracksOffset, totalTracks]);
// Intersection Observer for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreTracks && !loadingTracks) {
loadMoreTracks();
}
},
{ threshold: 0.1 }
);
if (loadingRef.current) {
observer.observe(loadingRef.current);
}
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadMoreTracks, hasMoreTracks, loadingTracks]);
// Load initial tracks when metadata is loaded
useEffect(() => {
if (playlistMetadata && tracks.length === 0 && totalTracks > 0) {
loadMoreTracks();
}
}, [playlistMetadata, tracks.length, totalTracks, loadMoreTracks]);
// Reset state when playlist ID changes
useEffect(() => {
setTracks([]);
setTracksOffset(0);
setHasMoreTracks(true);
setTotalTracks(0);
}, [playlistId]);
const handleDownloadTrack = (track: TrackType) => {
if (!track?.id) return;
addItem({ spotifyId: track.id, type: "track", name: track.name });
@@ -56,13 +134,13 @@ export const Playlist = () => {
};
const handleDownloadPlaylist = () => {
if (!playlist) return;
if (!playlistMetadata) return;
addItem({
spotifyId: playlist.id,
spotifyId: playlistMetadata.id,
type: "playlist",
name: playlist.name,
name: playlistMetadata.name,
});
toast.info(`Adding ${playlist.name} to queue...`);
toast.info(`Adding ${playlistMetadata.name} to queue...`);
};
const handleToggleWatch = async () => {
@@ -70,10 +148,10 @@ export const Playlist = () => {
try {
if (isWatched) {
await apiClient.delete(`/playlist/watch/${playlistId}`);
toast.success(`Removed ${playlist?.name} from watchlist.`);
toast.success(`Removed ${playlistMetadata?.name} from watchlist.`);
} else {
await apiClient.put(`/playlist/watch/${playlistId}`);
toast.success(`Added ${playlist?.name} to watchlist.`);
toast.success(`Added ${playlistMetadata?.name} to watchlist.`);
}
setIsWatched(!isWatched);
} catch (err) {
@@ -86,11 +164,11 @@ export const Playlist = () => {
return <div className="text-red-500 p-8 text-center">{error}</div>;
}
if (!playlist) {
return <div className="p-8 text-center">Loading...</div>;
if (!playlistMetadata) {
return <div className="p-8 text-center">Loading playlist...</div>;
}
const filteredTracks = playlist.tracks.items.filter(({ track }) => {
const filteredTracks = tracks.filter(({ track }) => {
if (!track) return false;
if (settings?.explicitFilter && track.explicit) return false;
return true;
@@ -107,19 +185,23 @@ export const Playlist = () => {
<span>Back to results</span>
</button>
</div>
{/* Playlist Header */}
<div className="flex flex-col md:flex-row items-start gap-6">
<img
src={playlist.images[0]?.url || "/placeholder.jpg"}
alt={playlist.name}
src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
alt={playlistMetadata.name}
className="w-48 h-48 object-cover rounded-lg shadow-lg"
/>
<div className="flex-grow space-y-2">
<h1 className="text-3xl font-bold">{playlist.name}</h1>
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>}
<h1 className="text-3xl font-bold">{playlistMetadata.name}</h1>
{playlistMetadata.description && (
<p className="text-gray-500 dark:text-gray-400">{playlistMetadata.description}</p>
)}
<div className="text-sm text-gray-400 dark:text-gray-500">
<p>
By {playlist.owner.display_name} {playlist.followers.total.toLocaleString()} followers {" "}
{playlist.tracks.total} songs
By {playlistMetadata.owner.display_name} {playlistMetadata.followers.total.toLocaleString()} followers {" "}
{totalTracks} songs
</p>
</div>
<div className="flex gap-2 pt-2">
@@ -149,8 +231,17 @@ export const Playlist = () => {
</div>
</div>
{/* Tracks Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Tracks</h2>
{tracks.length > 0 && (
<span className="text-sm text-gray-500">
Showing {tracks.length} of {totalTracks} tracks
</span>
)}
</div>
<div className="space-y-2">
{filteredTracks.map(({ track }, index) => {
if (!track) return null;
@@ -198,6 +289,25 @@ export const Playlist = () => {
</div>
);
})}
{/* Loading indicator */}
{loadingTracks && (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Intersection observer target */}
{hasMoreTracks && (
<div ref={loadingRef} className="h-4" />
)}
{/* End of tracks indicator */}
{!hasMoreTracks && tracks.length > 0 && (
<div className="text-center py-4 text-gray-500">
All tracks loaded
</div>
)}
</div>
</div>
</div>

View File

@@ -50,6 +50,7 @@ export interface PlaylistItemType {
added_at: string;
is_local: boolean;
track: TrackType | null;
is_locally_known?: boolean;
}
export interface PlaylistOwnerType {
@@ -57,6 +58,31 @@ export interface PlaylistOwnerType {
display_name: string;
}
// New interface for playlist metadata only (no tracks)
export interface PlaylistMetadataType {
id: string;
name: string;
description: string | null;
images: ImageType[];
tracks: {
total: number;
};
owner: PlaylistOwnerType;
followers: {
total: number;
};
_metadata_only: boolean;
_tracks_loaded: boolean;
}
// New interface for playlist tracks response
export interface PlaylistTracksResponseType {
items: PlaylistItemType[];
total: number;
limit: number;
offset: number;
}
export interface PlaylistType {
id: string;
name: string;