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

388 lines
16 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 { LibrespotTrackType, LibrespotPlaylistType, LibrespotPlaylistItemType, LibrespotPlaylistTrackStubType } from "@/types/librespot";
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<LibrespotPlaylistType | null>(null);
const [items, setItems] = useState<LibrespotPlaylistItemType[]>([]);
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: queueItems } = context;
// Playlist queue status
const playlistQueueItem = playlistMetadata
? queueItems.find(item => item.downloadType === "playlist" && item.spotifyId === (playlistId ?? ""))
: 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 (no expanded items)
useEffect(() => {
const fetchPlaylist = async () => {
if (!playlistId) return;
try {
const response = await apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${playlistId}`);
const data = response.data;
setPlaylistMetadata(data);
setTotalTracks(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");
}
};
setItems([]);
setTracksOffset(0);
setHasMoreTracks(true);
setTotalTracks(0);
setError(null);
fetchPlaylist();
checkWatchStatus();
}, [playlistId]);
const BATCH_SIZE = 6;
// Load items progressively by expanding track stubs when needed
const loadMoreTracks = useCallback(async () => {
if (!playlistId || loadingTracks || !hasMoreTracks || !playlistMetadata) return;
setLoadingTracks(true);
try {
// Fetch full playlist snapshot (stub items)
const response = await apiClient.get<LibrespotPlaylistType>(`/playlist/info?id=${playlistId}`);
const allItems = response.data.tracks.items;
const slice = allItems.slice(tracksOffset, tracksOffset + BATCH_SIZE);
// Expand any stubbed track entries by fetching full track info
const expandedSlice: LibrespotPlaylistItemType[] = await Promise.all(
slice.map(async (it) => {
const t = it.track as LibrespotPlaylistTrackStubType | LibrespotTrackType;
// If track has only stub fields (no duration_ms), fetch full
if (t && (t as any).id && !("duration_ms" in (t as any))) {
try {
const full = await apiClient.get<LibrespotTrackType>(`/track/info?id=${(t as LibrespotPlaylistTrackStubType).id}`).then(r => r.data);
return { ...it, track: full } as LibrespotPlaylistItemType;
} catch {
return it; // fallback to stub if fetch fails
}
}
return it;
})
);
setItems((prev) => [...prev, ...expandedSlice]);
const loaded = tracksOffset + expandedSlice.length;
setTracksOffset(loaded);
if (loaded >= 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, playlistMetadata]);
// 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]);
// Kick off initial batch
useEffect(() => {
if (playlistMetadata && items.length === 0 && totalTracks > 0) {
loadMoreTracks();
}
}, [playlistMetadata, items.length, totalTracks, loadMoreTracks]);
const handleDownloadTrack = (track: LibrespotTrackType) => {
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: playlistId!,
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 = items.reduce((acc, { track }) => {
if (!track || (track as any).id === undefined) return acc;
const t = track as LibrespotTrackType;
const qi = queueItems.find(item => item.downloadType === "track" && item.spotifyId === t.id);
acc[t.id] = qi ? getStatus(qi) : null;
return acc;
}, {} as Record<string, string | null>);
const filteredItems = items.filter(({ track }) => {
const t = track as LibrespotTrackType | LibrespotPlaylistTrackStubType | null;
if (!t || (t as any).id === undefined) return false;
const full = t as LibrespotTrackType;
if (settings?.explicitFilter && full.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} {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>
{items.length > 0 && (
<span className="text-sm text-content-muted dark:text-content-muted-dark">
Showing {items.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">
{filteredItems.map(({ track }, index) => {
const t = track as LibrespotTrackType;
if (!t || !t.id) return null;
return (
<div
key={`${t.id}-${index}`}
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: t.album.id }}>
<img
src={t.album.images?.at(-1)?.url || "/placeholder.jpg "}
alt={t.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: t.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate">
{t.name}
</Link>
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
{t.artists.map((artist, index) => (
<span key={artist.id}>
<Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:underline"
>
{artist.name}
</Link>
{index < t.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(t.duration_ms / 60000)}:
{((t.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(t)}
disabled={!!trackStatuses[t.id] && trackStatuses[t.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[t.id]
? trackStatuses[t.id] === "queued"
? "Queued."
: trackStatuses[t.id] === "error"
? "Download"
: "Downloading..."
: "Download"
}
>
{trackStatuses[t.id]
? trackStatuses[t.id] === "queued"
? "Queued."
: trackStatuses[t.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 && items.length > 0 && (
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
All tracks loaded
</div>
)}
</div>
</div>
</div>
</div>
);
};