Files
spotizerr-dev/spotizerr-ui/src/routes/playlist.tsx

371 lines
15 KiB
TypeScript

import { Link, useParams } from "@tanstack/react-router";
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 { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
import { QueueContext, getStatus } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa";
export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
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, items } = context;
// Playlist queue status
const playlistQueueItem = playlistMetadata
? items.find(item => item.downloadType === "playlist" && item.spotifyId === playlistMetadata.id)
: undefined;
const playlistStatus = playlistQueueItem ? getStatus(playlistQueueItem) : null;
useEffect(() => {
if (playlistStatus === "queued") {
toast.success(`${playlistMetadata?.name} queued.`);
} else if (playlistStatus === "error") {
toast.error(`Failed to queue ${playlistMetadata?.name}`);
}
}, [playlistStatus]);
// Load playlist metadata first
useEffect(() => {
const fetchPlaylistMetadata = async () => {
if (!playlistId) return;
try {
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 metadata");
console.error(err);
}
};
const checkWatchStatus = async () => {
if (!playlistId) return;
try {
const response = await apiClient.get(`/playlist/watch/${playlistId}/status`);
if (response.data.is_watched) {
setIsWatched(true);
}
} catch {
console.log("Could not get watch status");
}
};
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 });
toast.info(`Adding ${track.name} to queue...`);
};
const handleDownloadPlaylist = () => {
if (!playlistMetadata) return;
addItem({
spotifyId: playlistMetadata.id,
type: "playlist",
name: playlistMetadata.name,
});
toast.info(`Adding ${playlistMetadata.name} to queue...`);
};
const handleToggleWatch = async () => {
if (!playlistId) return;
try {
if (isWatched) {
await apiClient.delete(`/playlist/watch/${playlistId}`);
toast.success(`Removed ${playlistMetadata?.name} from watchlist.`);
} else {
await apiClient.put(`/playlist/watch/${playlistId}`);
toast.success(`Added ${playlistMetadata?.name} to watchlist.`);
}
setIsWatched(!isWatched);
} catch (err) {
toast.error("Failed to update watchlist.");
console.error(err);
}
};
if (error) {
return <div className="text-red-500 p-8 text-center">{error}</div>;
}
if (!playlistMetadata) {
return <div className="p-8 text-center">Loading playlist...</div>;
}
// Map track download statuses
const trackStatuses = tracks.reduce((acc, { track }) => {
if (!track) return acc;
const qi = items.find(item => item.downloadType === "track" && item.spotifyId === track.id);
acc[track.id] = qi ? getStatus(qi) : null;
return acc;
}, {} as Record<string, string | null>);
const filteredTracks = tracks.filter(({ track }) => {
if (!track) return false;
if (settings?.explicitFilter && track.explicit) return false;
return true;
});
return (
<div className="space-y-4 md:space-y-6">
{/* Back Button */}
<div className="mb-4 md:mb-6">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
>
<FaArrowLeft className="icon-secondary hover:icon-primary" />
<span>Back to results</span>
</button>
</div>
{/* Playlist Header - Mobile Optimized */}
<div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
<div className="flex flex-col items-center gap-4 md:gap-6">
<img
src={playlistMetadata.images?.at(0)?.url || "/placeholder.jpg"}
alt={playlistMetadata.name}
className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-cover rounded-lg shadow-lg mx-auto"
/>
<div className="flex-grow space-y-2 text-center">
<h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{playlistMetadata.name}</h1>
{playlistMetadata.description && (
<p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
)}
<p className="text-sm text-content-muted dark:text-content-muted-dark">
By {playlistMetadata.owner.display_name} {playlistMetadata.followers.total.toLocaleString()} followers {totalTracks} songs
</p>
</div>
</div>
{/* Action Buttons - Full Width on Mobile */}
<div className="mt-4 md:mt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={handleDownloadPlaylist}
disabled={!!playlistQueueItem && playlistStatus !== "error"}
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{playlistStatus
? playlistStatus === "queued"
? "Queued."
: playlistStatus === "error"
? "Download All"
: <img src="/spinner.svg" alt="Loading" className="w-5 h-5 animate-spin inline-block" />
: "Download All"}
</button>
{settings?.watch?.enabled && (
<button
onClick={handleToggleWatch}
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
isWatched
? "bg-error hover:bg-error-hover text-button-primary-text"
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
}`}
>
<img
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
alt="Watch status"
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
/>
{isWatched ? "Unwatch" : "Watch"}
</button>
)}
</div>
</div>
{/* Tracks Section */}
<div className="space-y-3 md:space-y-4">
<div className="flex items-center justify_between px-1">
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
{tracks.length > 0 && (
<span className="text-sm text-content-muted dark:text-content-muted-dark">
Showing {tracks.length} of {totalTracks} tracks
</span>
)}
</div>
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
<div className="space-y-1 md:space-y-2">
{filteredTracks.map(({ track }, index) => {
if (!track) return null;
return (
<div
key={track.id}
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
>
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
<Link to="/album/$albumId" params={{ albumId: track.album.id }}>
<img
src={track.album.images?.at(-1)?.url || "/placeholder.jpg "}
alt={track.album.name}
className="w-10 h-10 md:w-12 md:h-12 object-cover rounded hover:scale-105 transition-transform duration-300"
/>
</Link>
<div className="min-w-0 flex-1">
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate">
{track.name}
</Link>
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:underline"
>
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-2 md:gap-4 shrink-0">
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(track)}
disabled={!!trackStatuses[track.id] && trackStatuses[track.id] !== "error"}
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title={
trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? "Download"
: "Downloading..."
: "Download"
}
>
{trackStatuses[track.id]
? trackStatuses[track.id] === "queued"
? "Queued."
: trackStatuses[track.id] === "error"
? <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
: <img src="/spinner.svg" alt="Loading" className="w-4 h-4 animate-spin" />
: <img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
}
</button>
</div>
</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-primary"></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-content-muted dark:text-content-muted-dark">
All tracks loaded
</div>
)}
</div>
</div>
</div>
</div>
);
};