0 ? 0 : -1}
+ aria-label="Lyrics"
+ onKeyDown={(e) => {
+ if (!lrcContainerRef.current || lyrics.length === 0) return;
+ const container = lrcContainerRef.current;
+ const step = 24;
+ switch (e.key) {
+ case 'ArrowDown':
+ container.scrollBy({ top: step, behavior: 'smooth' });
+ e.preventDefault();
+ break;
+ case 'ArrowUp':
+ container.scrollBy({ top: -step, behavior: 'smooth' });
+ e.preventDefault();
+ break;
+ case 'Home':
+ container.scrollTo({ top: 0, behavior: 'smooth' });
+ e.preventDefault();
+ break;
+ case 'End':
+ container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
+ e.preventDefault();
+ break;
+ }
+ }}
+ >
+ {lyrics.length === 0 && (
+
No lyrics available.
+ )}
{lyrics.map((lyricObj, index) => (
) : undefined}
className={`lrc-line text-sm ${index === currentLyricIndex ? "active font-bold" : ""}`}
>
{lyricObj.line}
diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx
deleted file mode 100644
index d99e19a..0000000
--- a/src/components/TRip/MediaRequestForm.jsx
+++ /dev/null
@@ -1,1349 +0,0 @@
-import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react";
-import { toast } from "react-toastify";
-import { Button } from "@mui/joy";
-import { Accordion, AccordionTab } from "primereact/accordion";
-import { AutoComplete } from "primereact/autocomplete";
-import { authFetch } from "@/utils/authFetch";
-import BreadcrumbNav from "./BreadcrumbNav";
-import { API_URL, ENVIRONMENT } from "@/config";
-import "./RequestManagement.css";
-
-export default function MediaRequestForm() {
- const [type, setType] = useState("artist");
- const [selectedArtist, setSelectedArtist] = useState(null);
- const [artistInput, setArtistInput] = useState("");
- const [albumInput, setAlbumInput] = useState("");
- const [trackInput, setTrackInput] = useState("");
- const [quality, setQuality] = useState("FLAC"); // default FLAC
- const [selectedItem, setSelectedItem] = useState(null);
- const [albums, setAlbums] = useState([]);
- const [tracksByAlbum, setTracksByAlbum] = useState({});
- const [selectedTracks, setSelectedTracks] = useState({});
- const [artistSuggestions, setArtistSuggestions] = useState([]);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [isSearching, setIsSearching] = useState(false);
- const [loadingAlbumId, setLoadingAlbumId] = useState(null);
- const [expandedAlbums, setExpandedAlbums] = useState([]);
- const [isFetching, setIsFetching] = useState(false);
- const [currentTrackId, setCurrentTrackId] = useState(null);
- const [isAudioPlaying, setIsAudioPlaying] = useState(false);
- const [audioLoadingTrackId, setAudioLoadingTrackId] = useState(null);
- const [playbackQueue, setPlaybackQueue] = useState([]);
- const [queueIndex, setQueueIndex] = useState(null);
- const [queueAlbumId, setQueueAlbumId] = useState(null);
- const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
- const [shuffleAlbums, setShuffleAlbums] = useState({});
- const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
- const [diskSpace, setDiskSpace] = useState(null);
-
- const debounceTimeout = useRef(null);
- const autoCompleteRef = useRef(null);
- const metadataFetchToastId = useRef(null);
- const audioRef = useRef(null);
- const audioSourcesRef = useRef({});
- const pendingTrackFetchesRef = useRef({});
- const playbackQueueRef = useRef([]);
- const queueIndexRef = useRef(null);
- const queueAlbumIdRef = useRef(null);
- const albumHeaderRefs = useRef({});
- const suppressHashRef = useRef(false);
- const lastUrlRef = useRef("");
-
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
- const sanitizeFilename = (text) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
- const formatTime = (seconds) => {
- if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60)
- .toString()
- .padStart(2, "0");
- return `${mins}:${secs}`;
- };
-
- useEffect(() => {
- if (typeof window === "undefined") return;
- lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash;
- }, []);
-
- // Fetch disk space on mount
- useEffect(() => {
- const fetchDiskSpace = async () => {
- try {
- const res = await fetch('/api/disk-space');
- if (res.ok) {
- const data = await res.json();
- setDiskSpace(data);
- }
- } catch (err) {
- console.error('Failed to fetch disk space:', err);
- }
- };
- fetchDiskSpace();
- }, []);
-
- useEffect(() => {
- if (typeof window === "undefined") return undefined;
-
- const handleHashChange = () => {
- if (suppressHashRef.current) {
- const fallback =
- lastUrlRef.current || window.location.pathname + window.location.search;
- if (typeof window.history?.replaceState === "function") {
- window.history.replaceState(null, "", fallback);
- }
- lastUrlRef.current = fallback;
- suppressHashRef.current = false;
- } else {
- lastUrlRef.current =
- window.location.pathname + window.location.search + window.location.hash;
- }
- };
-
- window.addEventListener("hashchange", handleHashChange);
- return () => window.removeEventListener("hashchange", handleHashChange);
- }, []);
-
-
- const Spinner = () => (
-
- );
-
- const InlineSpinner = ({ sizeClass = "h-3 w-3" }) => (
-
- );
-
- const PlayIcon = () => (
-
- );
-
- const PauseIcon = () => (
-
- );
-
- const ShuffleIcon = ({ active }) => (
-
- );
-
- const shuffleArray = (arr) => {
- const clone = [...arr];
- for (let i = clone.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [clone[i], clone[j]] = [clone[j], clone[i]];
- }
- return clone;
- };
-
- const ensureAlbumExpanded = (albumIndex) => {
- if (typeof albumIndex !== "number") return;
- let albumAdded = false;
- setExpandedAlbums((prev) => {
- const current = Array.isArray(prev) ? [...prev] : [];
- if (current.includes(albumIndex)) return prev;
-
- if (typeof window !== "undefined") {
- lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash;
- }
-
- suppressHashRef.current = true;
- current.push(albumIndex);
- albumAdded = true;
- return current;
- });
-
- if (albumAdded) {
- setTimeout(() => {
- suppressHashRef.current = false;
- }, 400);
- }
- };
-
- const resetQueueState = () => {
- setPlaybackQueue([]);
- playbackQueueRef.current = [];
- setQueueIndex(null);
- queueIndexRef.current = null;
- setQueueAlbumId(null);
- queueAlbumIdRef.current = null;
- setAlbumPlaybackLoadingId(null);
- setAudioProgress({ current: 0, duration: 0 });
- };
-
- const getTrackSource = async (trackId) => {
- if (audioSourcesRef.current[trackId]) {
- return audioSourcesRef.current[trackId];
- }
-
- if (!pendingTrackFetchesRef.current[trackId]) {
- pendingTrackFetchesRef.current[trackId] = (async () => {
- const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`);
- if (!res.ok) throw new Error("Failed to fetch track URL");
- const data = await res.json();
-
- if (!data.stream_url) {
- throw new Error("No stream URL returned for this track.");
- }
-
- const fileResponse = await fetch(data.stream_url, {
- method: "GET",
- mode: "cors",
- credentials: "omit",
- });
-
- if (!fileResponse.ok) {
- throw new Error(`Failed to fetch track file: ${fileResponse.status}`);
- }
-
- const blob = await fileResponse.blob();
- const sourceUrl = URL.createObjectURL(blob);
-
- if (audioSourcesRef.current[trackId]) {
- URL.revokeObjectURL(audioSourcesRef.current[trackId]);
- }
-
- audioSourcesRef.current[trackId] = sourceUrl;
- return sourceUrl;
- })().finally(() => {
- delete pendingTrackFetchesRef.current[trackId];
- });
- }
-
- return pendingTrackFetchesRef.current[trackId];
- };
-
- const prefetchTrack = async (track) => {
- if (!track || audioSourcesRef.current[track.id] || pendingTrackFetchesRef.current[track.id]) {
- return;
- }
- try {
- await getTrackSource(track.id);
- } catch (error) {
- console.error("Prefetch failed", error);
- }
- };
-
- const playTrack = async (track, { fromQueue = false } = {}) => {
- const audio = audioRef.current;
- if (!audio || !track) return;
-
- if (!fromQueue) {
- resetQueueState();
- }
-
- setAudioLoadingTrackId(track.id);
- try {
- const sourceUrl = await getTrackSource(track.id);
- audio.pause();
- audio.currentTime = 0;
- audio.src = sourceUrl;
- setAudioProgress({ current: 0, duration: 0 });
- setCurrentTrackId(track.id);
- setIsAudioPlaying(true);
- await audio.play();
- } catch (error) {
- setIsAudioPlaying(false);
- console.error(error);
- toast.error("Failed to play track.");
- if (!fromQueue) {
- resetQueueState();
- }
- } finally {
- setAudioLoadingTrackId(null);
- }
- };
-
- // Fetch artist suggestions for autocomplete
- const searchArtists = (e) => {
- const query = e.query.trim();
- if (!query) {
- setArtistSuggestions([]);
- setSelectedArtist(null);
- return;
- }
-
- if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
-
- debounceTimeout.current = setTimeout(async () => {
- try {
- // Ensure at least 600ms between actual requests
- const now = Date.now();
- if (!searchArtists.lastCall) searchArtists.lastCall = 0;
- const elapsed = now - searchArtists.lastCall;
- const minDelay = 600; // ms
- if (elapsed < minDelay) await delay(minDelay - elapsed);
-
- searchArtists.lastCall = Date.now();
-
- const res = await authFetch(
- `${API_URL}/trip/get_artists_by_name?artist=${encodeURIComponent(query)}&group=true`
- );
- if (!res.ok) throw new Error("API error");
- const data = await res.json();
- setArtistSuggestions(data);
- } catch (err) {
- toast.error("Failed to fetch artist suggestions.");
- setArtistSuggestions([]);
- }
- }, 500); // debounce 500ms
- };
-
-
- const truncate = (text, maxLen) =>
- maxLen <= 3
- ? text.slice(0, maxLen)
- : text.length <= maxLen
- ? text
- : text.slice(0, maxLen - 3) + '...';
-
- const selectArtist = (artist) => {
- // artist may be a grouped item or an alternate object
- const value = artist.artist || artist.name || "";
- setSelectedArtist(artist);
- setArtistInput(value);
- setArtistSuggestions([]);
-
- // Hide autocomplete panel and blur input to prevent leftover white box.
- // Use a small timeout so PrimeReact internal handlers can finish first.
- try {
- const ac = autoCompleteRef.current;
- // Give PrimeReact a tick to finish its events, then call hide().
- setTimeout(() => {
- try {
- if (ac && typeof ac.hide === "function") {
- ac.hide();
- }
-
- // If panel still exists, hide it via style (safer than removing the node)
- setTimeout(() => {
- const panel = document.querySelector('.p-autocomplete-panel');
- if (panel) {
- panel.style.display = 'none';
- panel.style.opacity = '0';
- panel.style.visibility = 'hidden';
- panel.style.pointerEvents = 'none';
- }
- }, 10);
-
- // blur the input element if present
- const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input');
- if (inputEl && typeof inputEl.blur === 'function') inputEl.blur();
- } catch (innerErr) {
- // Ignore inner errors
- }
- }, 0);
- } catch (err) {
- // Ignore outer errors
- }
- };
-
- const totalAlbums = albums.length;
- const totalTracks = useMemo(() =>
- Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
- [tracksByAlbum]
- );
-
- const artistItemTemplate = (artist) => {
- if (!artist) return null;
-
- const alts = artist.alternatives || artist.alternates || [];
-
- return (
-
-
- {truncate(artist.artist || artist.name, 58)}
- ID: {artist.id}
-
- {alts.length > 0 && (
-
-
- Alternates:
- {alts.map((alt, idx) => (
-
-
- ID: {alt.id}
- {idx < alts.length - 1 ? , : null}
-
- ))}
-
-
- )}
-
- );
- };
-
-
- // Handle autocomplete input changes (typing/selecting)
- const handleArtistChange = (e) => {
- if (typeof e.value === "string") {
- setArtistInput(e.value);
- setSelectedArtist(null);
- } else if (e.value && typeof e.value === "object") {
- setSelectedArtist(e.value);
- setArtistInput(e.value.artist);
- } else {
- setArtistInput("");
- setSelectedArtist(null);
- }
- };
-
- // Search button click handler
- const handleSearch = async () => {
- toast.dismiss();
- setIsSearching(true);
- resetQueueState();
- setShuffleAlbums({});
- if (audioRef.current) {
- audioRef.current.pause();
- audioRef.current.removeAttribute("src");
- audioRef.current.load();
- }
- setIsAudioPlaying(false);
- setAudioLoadingTrackId(null);
- setCurrentTrackId(null);
- try {
- if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
- } catch (err) {
- }
- metadataFetchToastId.current = toast.info("Retrieving metadata...",
- {
- autoClose: false,
- progress: 0,
- closeOnClick: false,
- }
- );
- if (type === "artist") {
- if (!selectedArtist) {
- toast.error("Please select a valid artist from suggestions.");
- setIsSearching(false);
- return;
- }
-
- setSelectedItem(selectedArtist.artist);
-
- try {
- const res = await authFetch(
- `${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
- );
- if (!res.ok) throw new Error("API error");
- const data = await res.json();
-
- data.sort((a, b) =>
- (b.release_date || "").localeCompare(a.release_date || "")
- );
-
- setAlbums(data);
- setShuffleAlbums({});
- setTracksByAlbum({});
- setExpandedAlbums([]);
-
- // Set selectedTracks for all albums as null (means tracks loading/not loaded)
- setSelectedTracks(
- data.reduce((acc, album) => {
- acc[album.id] = null;
- return acc;
- }, {})
- );
- } catch (err) {
- toast.error("Failed to fetch albums for artist.");
- setAlbums([]);
- setTracksByAlbum({});
- setSelectedTracks({});
- }
- } else if (type === "album") {
- if (!artistInput.trim() || !albumInput.trim()) {
- toast.error("Artist and Album are required.");
- setIsSearching(false);
- return;
- }
- setSelectedItem(`${artistInput} - ${albumInput}`);
- setAlbums([]);
- setTracksByAlbum({});
- setSelectedTracks({});
- } else if (type === "track") {
- if (!artistInput.trim() || !trackInput.trim()) {
- toast.error("Artist and Track are required.");
- setIsSearching(false);
- return;
- }
- setSelectedItem(`${artistInput} - ${trackInput}`);
- setAlbums([]);
- setTracksByAlbum({});
- setSelectedTracks({});
- }
- setIsSearching(false);
- };
-
- const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => {
- const audio = audioRef.current;
- if (!audio) return;
-
- if (typeof albumIndex === "number") {
- ensureAlbumExpanded(albumIndex);
- }
-
- if (currentTrackId === track.id) {
- if (audio.paused) {
- setIsAudioPlaying(true);
- try {
- await audio.play();
- } catch (error) {
- setIsAudioPlaying(false);
- console.error(error);
- toast.error("Unable to resume playback.");
- }
- } else {
- setIsAudioPlaying(false);
- audio.pause();
- }
- return;
- }
-
- await playTrack(track, { fromQueue: false });
- };
-
- const toggleAlbumShuffle = (albumId) => {
- setShuffleAlbums((prev) => ({ ...prev, [albumId]: !prev[albumId] }));
- };
-
- const startAlbumPlayback = async (albumId, albumIndex) => {
- const tracks = tracksByAlbum[albumId];
- if (!Array.isArray(tracks) || tracks.length === 0) {
- toast.error("Tracks are still loading for this album.");
- return;
- }
-
- ensureAlbumExpanded(albumIndex);
-
- const shouldShuffle = !!shuffleAlbums[albumId];
- const queue = shouldShuffle ? shuffleArray(tracks) : [...tracks];
- setPlaybackQueue(queue);
- playbackQueueRef.current = queue;
- setQueueAlbumId(albumId);
- queueAlbumIdRef.current = albumId;
- setQueueIndex(0);
- queueIndexRef.current = 0;
- setAlbumPlaybackLoadingId(albumId);
-
- try {
- await playTrack(queue[0], { fromQueue: true });
- setAlbumPlaybackLoadingId(null);
- if (queue[1]) prefetchTrack(queue[1]);
- } catch (err) {
- setAlbumPlaybackLoadingId(null);
- }
- };
-
- const handleAlbumPlayPause = async (albumId, albumIndex) => {
- const audio = audioRef.current;
- if (!audio) return;
-
- ensureAlbumExpanded(albumIndex);
-
- if (queueAlbumId === albumId && playbackQueue.length > 0) {
- if (audio.paused) {
- setIsAudioPlaying(true);
- try {
- await audio.play();
- } catch (error) {
- setIsAudioPlaying(false);
- console.error(error);
- toast.error("Unable to resume album playback.");
- }
- } else {
- setIsAudioPlaying(false);
- audio.pause();
- }
- return;
- }
-
- await startAlbumPlayback(albumId, albumIndex);
- };
-
- const handleTrackDownload = async (track) => {
- try {
- const res = await authFetch(`${API_URL}/trip/get_track_by_id/${track.id}?quality=${quality}`);
- if (!res.ok) throw new Error("Failed to fetch track URL");
- const data = await res.json();
-
- if (!data.stream_url) {
- throw new Error("No stream URL returned for this track.");
- }
-
- const fileResponse = await fetch(data.stream_url, {
- method: "GET",
- mode: "cors",
- credentials: "omit",
- });
-
- if (!fileResponse.ok) {
- throw new Error(`Failed to fetch track file: ${fileResponse.status}`);
- }
-
- const blob = await fileResponse.blob();
- const url = URL.createObjectURL(blob);
- const artistName = track.artist || selectedArtist?.artist || "Unknown Artist";
- const urlPath = new URL(data.stream_url).pathname;
- const extension = urlPath.split(".").pop().split("?")[0] || "flac";
- const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`;
-
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- link.remove();
- URL.revokeObjectURL(url);
- } catch (error) {
- console.error(error);
- toast.error("Failed to download track.");
- }
- };
-
- useEffect(() => {
- if (typeof Audio === "undefined") {
- return undefined;
- }
-
- const audio = new Audio();
- audio.preload = "auto";
- audioRef.current = audio;
-
- const handleEnded = async () => {
- const queue = playbackQueueRef.current;
- const index = queueIndexRef.current;
-
- if (Array.isArray(queue) && queue.length > 0 && index !== null && index + 1 < queue.length) {
- const nextIndex = index + 1;
- setQueueIndex(nextIndex);
- queueIndexRef.current = nextIndex;
- setAlbumPlaybackLoadingId(queueAlbumIdRef.current);
- try {
- await playTrack(queue[nextIndex], { fromQueue: true });
- setAlbumPlaybackLoadingId(null);
- const upcoming = queue[nextIndex + 1];
- if (upcoming) prefetchTrack(upcoming);
- } catch (error) {
- console.error("Failed to advance queue", error);
- setAlbumPlaybackLoadingId(null);
- resetQueueState();
- }
- return;
- }
-
- setIsAudioPlaying(false);
- setCurrentTrackId(null);
- resetQueueState();
- };
-
- const updateProgress = () => {
- setAudioProgress({ current: audio.currentTime || 0, duration: audio.duration || 0 });
- };
-
- const handlePause = () => {
- setIsAudioPlaying(false);
- updateProgress();
- };
-
- const handlePlay = () => {
- setIsAudioPlaying(true);
- updateProgress();
- };
-
- const handleTimeUpdate = () => {
- updateProgress();
- const queue = playbackQueueRef.current;
- const index = queueIndexRef.current;
- if (!Array.isArray(queue) || queue.length === 0 || index === null) return;
- const nextTrack = queue[index + 1];
- if (!nextTrack) return;
-
- const duration = audio.duration || 0;
- const currentTime = audio.currentTime || 0;
- const progress = duration > 0 ? currentTime / duration : 0;
- if ((duration > 0 && progress >= 0.7) || (duration === 0 && currentTime >= 15)) {
- prefetchTrack(nextTrack);
- }
- };
-
- audio.addEventListener("ended", handleEnded);
- audio.addEventListener("pause", handlePause);
- audio.addEventListener("play", handlePlay);
- audio.addEventListener("timeupdate", handleTimeUpdate);
- audio.addEventListener("loadedmetadata", updateProgress);
- audio.addEventListener("seeking", updateProgress);
- audio.addEventListener("seeked", updateProgress);
-
- return () => {
- audio.pause();
- audio.removeAttribute("src");
- audio.load();
- audio.removeEventListener("ended", handleEnded);
- audio.removeEventListener("pause", handlePause);
- audio.removeEventListener("play", handlePlay);
- audio.removeEventListener("timeupdate", handleTimeUpdate);
- audio.removeEventListener("loadedmetadata", updateProgress);
- audio.removeEventListener("seeking", updateProgress);
- audio.removeEventListener("seeked", updateProgress);
- Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url));
- audioSourcesRef.current = {};
- };
- }, []);
-
-
- useEffect(() => {
- playbackQueueRef.current = playbackQueue;
- }, [playbackQueue]);
-
- useEffect(() => {
- queueIndexRef.current = queueIndex;
- }, [queueIndex]);
-
- useEffect(() => {
- queueAlbumIdRef.current = queueAlbumId;
- }, [queueAlbumId]);
-
- useEffect(() => {
- Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url));
- audioSourcesRef.current = {};
- pendingTrackFetchesRef.current = {};
- if (audioRef.current) {
- audioRef.current.pause();
- audioRef.current.removeAttribute("src");
- audioRef.current.load();
- }
- setIsAudioPlaying(false);
- setAudioLoadingTrackId(null);
- setCurrentTrackId(null);
- resetQueueState();
- }, [quality]);
-
- const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0);
-
- const handleToggleAllAlbums = () => {
- const allSelected = albums.every(({ id }) => {
- const allTracks = tracksByAlbum[id] || [];
- return selectedTracks[id]?.length === allTracks.length && allTracks.length > 0;
- });
-
- const newSelection = {};
- albums.forEach(({ id }) => {
- const allTracks = tracksByAlbum[id] || [];
- if (allSelected) {
- // Uncheck all
- newSelection[id] = [];
- } else {
- // Check all tracks in the album
- newSelection[id] = allTracks.map(track => String(track.id));
- }
- });
- setSelectedTracks(newSelection);
- };
-
-
{
- e.preventDefault();
- if (!allTracksLoaded) return; // prevent clicking before data ready
- handleToggleAllAlbums();
- }}
- className={`text-sm hover:underline cursor-pointer ${!allTracksLoaded ? "text-gray-400 dark:text-gray-500 pointer-events-none" : "text-blue-600"
- }`}
- >
- Check / Uncheck All Albums
-
-
-
-
- // Sequentially fetch tracks for albums not loaded yet
- useEffect(() => {
- if (type !== "artist" || albums.length === 0) return;
-
- let isCancelled = false;
- const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
- if (albumsToFetch.length === 0) return;
-
- const fetchTracksSequentially = async () => {
- const minDelay = 650; // ms between API requests
- setIsFetching(true);
-
- const totalAlbums = albumsToFetch.length;
-
- for (let index = 0; index < totalAlbums; index++) {
- const album = albumsToFetch[index];
-
- if (isCancelled) break;
-
- setLoadingAlbumId(album.id);
-
- try {
- const now = Date.now();
- if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0;
- const elapsed = now - fetchTracksSequentially.lastCall;
- if (elapsed < minDelay) await delay(minDelay - elapsed);
- fetchTracksSequentially.lastCall = Date.now();
-
- const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`);
- if (!res.ok) throw new Error("API error");
- const data = await res.json();
-
- if (isCancelled) break;
-
- setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
- setSelectedTracks((prev) => ({
- ...prev,
- [album.id]: data.map((t) => String(t.id)),
- }));
- } catch (err) {
- toast.error(`Failed to fetch tracks for album ${album.album}.`);
- setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] }));
- setSelectedTracks((prev) => ({ ...prev, [album.id]: [] }));
- }
-
- // Update progress toast
- toast.update(metadataFetchToastId.current, {
- progress: (index + 1) / totalAlbums,
- render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
- });
- }
-
- setLoadingAlbumId(null);
- setIsFetching(false);
-
- // Finish the toast
- toast.update(metadataFetchToastId.current, {
- render: "Metadata retrieved!",
- type: "success",
- progress: 1,
- autoClose: 1500,
- });
- };
-
- fetchTracksSequentially();
-
- return () => {
- isCancelled = true;
- };
- }, [albums, type]);
-
-
-
- // Toggle individual track checkbox
- const toggleTrack = (albumId, trackId) => {
- setSelectedTracks((prev) => {
- const current = new Set(prev[albumId] || []);
- if (current.has(String(trackId))) current.delete(String(trackId));
- else current.add(String(trackId));
- return { ...prev, [albumId]: Array.from(current) };
- });
- };
-
- // Toggle album checkbox (select/deselect all tracks in album)
- const toggleAlbum = (albumId) => {
- const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
- setSelectedTracks((prev) => {
- const current = prev[albumId] || [];
- const allSelected = current.length === allTracks.length;
- return {
- ...prev,
- [albumId]: allSelected ? [] : [...allTracks],
- };
- });
- };
-
- // Attach scroll fix for autocomplete panel
- const attachScrollFix = () => {
- setTimeout(() => {
- const panel = document.querySelector(".p-autocomplete-panel");
- const items = panel?.querySelector(".p-autocomplete-items");
- if (items) {
- items.style.maxHeight = "200px";
- items.style.overflowY = "auto";
- items.style.overscrollBehavior = "contain";
- const wheelHandler = (e) => {
- const delta = e.deltaY;
- const atTop = items.scrollTop === 0;
- const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
- if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
- e.preventDefault();
- } else {
- e.stopPropagation();
- }
- };
- items.removeEventListener("wheel", wheelHandler);
- items.addEventListener("wheel", wheelHandler, { passive: false });
- }
- }, 0);
- };
-
- // Submit request handler with progress indicator
- const handleSubmitRequest = async () => {
- if (isFetching) {
- // tracks are not done being fetched
- return toast.error("Still fetching track metadata, please wait a moment.");
- }
- setIsSubmitting(true);
- try {
- const allSelectedIds = Object.values(selectedTracks)
- .filter(arr => Array.isArray(arr)) // skip null entries
- .flat();
-
- const response = await authFetch(`${API_URL}/trip/bulk_fetch`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json; charset=utf-8",
- },
- body: JSON.stringify({
- track_ids: allSelectedIds,
- target: selectedArtist.artist,
- quality: quality,
- }),
- });
-
- if (!response.ok) {
- throw new Error(`Server error: ${response.status}`);
- }
-
- const data = await response.json();
- toast.success(`Request submitted! (${allSelectedIds.length} tracks)`);
- } catch (err) {
- console.error(err);
- toast.error("Failed to submit request.");
- } finally {
- setIsSubmitting(false);
- }
- };
-
-
- return (
-
-
-
-
- {/* Disk Space Indicator - always visible */}
- {diskSpace && (
-
-
-
- 90 ? 'text-red-500' :
- diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400'
- }`}>{diskSpace.usedFormatted} used ยท{' '}
- 90 ? 'text-red-500' :
- diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400'
- }`}>{diskSpace.availableFormatted} available
-
-
-
90 ? 'bg-red-500' :
- diskSpace.usedPercent > 75 ? 'bg-yellow-500' : 'bg-green-500'
- }`}
- style={{ width: `${diskSpace.usedPercent}%` }}
- />
-
-
({diskSpace.usedPercent}% used)
-
- )}
-
-
New Request
-
Search for an artist to browse and select tracks for download.
-
-
-
-
-
- {(type === "album" || type === "track") && (
-
- type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
- }
- placeholder={type === "album" ? "Album" : "Track"}
- />
- )}
-
-
-
-
-
-
-
-
- {type === "artist" && albums.length > 0 && (
- <>
-
-
setExpandedAlbums(e.index)}
- >
- {albums.map(({ album, id, release_date }, albumIndex) => {
- const allTracks = tracksByAlbum[id] || [];
- const selected = selectedTracks[id];
-
- // Album checkbox is checked if tracks not loaded (selected === null)
- // or all tracks loaded and all selected
- const allChecked =
- selected === null || (selected?.length === allTracks.length && allTracks.length > 0);
- const someChecked =
- selected !== null && selected.length > 0 && selected.length < allTracks.length;
-
- return (
-
- {
- if (el) el.indeterminate = someChecked;
- }}
- onChange={() => toggleAlbum(id)}
- onClick={(e) => e.stopPropagation()}
- className="trip-checkbox cursor-pointer"
- aria-label={`Select all tracks for album ${album}`}
- />
- e.stopPropagation()}>
-
-
-
-
- {truncate(album, 32)}
- {loadingAlbumId === id && }
-
- ({release_date})
-
- {typeof tracksByAlbum[id] === 'undefined' ? (
- loadingAlbumId === id ? 'Loading...' : '...'
- ) : (
- `${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}`
- )}
-
-
- }
-
- >
- {allTracks.length > 0 ? (
-
- {allTracks.map((track) => {
- const isCurrentTrack = currentTrackId === track.id;
- const showProgress = isCurrentTrack && audioProgress.duration > 0;
- const safeProgress = {
- current: Math.min(audioProgress.current, audioProgress.duration || 0),
- duration: audioProgress.duration || 0,
- };
-
- return (
- -
-
-
-
toggleTrack(id, track.id)}
- className="trip-checkbox cursor-pointer"
- aria-label={`Select track ${track.title} `}
- />
-
-
-
-
- {truncate(track.title, 80)}
-
- {track.version && (
-
- {track.version}
-
- )}
-
-
-
- {quality}
- {track.duration && (
- {track.duration}
- )}
-
-
- {showProgress && (
-
-
{
- if (!audioRef.current) return;
- const nextValue = Number(e.target.value);
- audioRef.current.currentTime = nextValue;
- setAudioProgress((prev) => ({ ...prev, current: nextValue }));
- }}
- className="w-full h-1 cursor-pointer accent-blue-600"
- aria-label={`Seek within ${track.title}`}
- />
-
- {formatTime(safeProgress.current)}
- {formatTime(safeProgress.duration)}
-
-
- )}
-
- );
- })}
-
- ) : (
-
- {tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
-
- )}
-
- );
- })}
-
-
-
-
- >
- )
- }
-
-
- );
-}
diff --git a/src/components/TRip/MediaRequestForm.tsx b/src/components/TRip/MediaRequestForm.tsx
index aac50a3..f3f51b2 100644
--- a/src/components/TRip/MediaRequestForm.tsx
+++ b/src/components/TRip/MediaRequestForm.tsx
@@ -994,6 +994,10 @@ export default function MediaRequestForm() {
const data = await response.json();
toast.success(`Request submitted! (${allSelectedIds.length} tracks)`);
+ // Send the user to the requests page to monitor progress
+ if (typeof window !== "undefined") {
+ window.location.href = "/TRip/requests";
+ }
} catch (err) {
console.error(err);
toast.error("Failed to submit request.");