- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
1282 lines
58 KiB
JavaScript
1282 lines
58 KiB
JavaScript
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 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;
|
|
}, []);
|
|
|
|
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 = () => (
|
|
<span
|
|
className="inline-block ml-2 h-4 w-4 border-2 border-t-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"
|
|
aria-label="Loading"
|
|
/>
|
|
);
|
|
|
|
const InlineSpinner = ({ sizeClass = "h-3 w-3" }) => (
|
|
<span
|
|
className={`inline-block ${sizeClass} border-2 border-t-2 border-gray-300 border-t-blue-600 rounded-full animate-spin`}
|
|
aria-label="Loading audio"
|
|
/>
|
|
);
|
|
|
|
const PlayIcon = () => (
|
|
<svg viewBox="0 0 24 24" role="presentation" className="w-4 h-4" aria-hidden="true">
|
|
<path fill="currentColor" d="M8 5v14l11-7z" />
|
|
</svg>
|
|
);
|
|
|
|
const PauseIcon = () => (
|
|
<svg viewBox="0 0 24 24" role="presentation" className="w-4 h-4" aria-hidden="true">
|
|
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" />
|
|
</svg>
|
|
);
|
|
|
|
const ShuffleIcon = ({ active }) => (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
role="presentation"
|
|
className={`w-4 h-4 ${active ? "text-blue-600" : "text-neutral-500"}`}
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M17 3h4v4h-2V6.41l-4.29 4.3-1.42-1.42L17.59 5H17V3zm0 11h-1.59l-2.7-2.7 1.42-1.42 2.99 3h1.88v-1.59L21 14l-3.99 3V15zm-7.12-1.71L4.41 5H3V3h2.59l6.12 6.12-1.71 1.17zM3 19v-2h1.41l4.7-4.7 1.42 1.41L6.41 19H3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
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);
|
|
await audio.play();
|
|
} catch (error) {
|
|
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 (
|
|
<div className="py-1">
|
|
<div className="font-medium flex items-baseline gap-2">
|
|
<span>{truncate(artist.artist || artist.name, 58)}</span>
|
|
<span className="text-xs text-neutral-400">ID: {artist.id}</span>
|
|
</div>
|
|
{alts.length > 0 && (
|
|
<div className="text-xs text-neutral-500 mt-1">
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|
<span className="mr-1">Alternates:</span>
|
|
{alts.map((alt, idx) => (
|
|
<span key={alt.id || idx} className="inline-flex items-center max-w-[200px] truncate">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
selectArtist({ ...alt, artist: alt.artist || alt.name });
|
|
}}
|
|
className="ml-1 mr-1 underline hover:text-blue-600 bg-transparent border-none p-0 cursor-pointer"
|
|
>
|
|
{truncate(alt.artist || alt.name, 26)}
|
|
</button>
|
|
<span className="text-xs text-neutral-400">ID: {alt.id}</span>
|
|
{idx < alts.length - 1 ? <span className="mx-1">,</span> : null}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
// 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) {
|
|
try {
|
|
await audio.play();
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Unable to resume playback.");
|
|
}
|
|
} else {
|
|
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) {
|
|
try {
|
|
await audio.play();
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Unable to resume album playback.");
|
|
}
|
|
} else {
|
|
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);
|
|
};
|
|
|
|
<a
|
|
href="#"
|
|
role="button"
|
|
onClick={(e) => {
|
|
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
|
|
</a>
|
|
|
|
|
|
|
|
// 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 (
|
|
<div className="trip-request-form mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
|
<style>{`
|
|
/* Accordion tab backgrounds & text */
|
|
.p-accordion-tab {
|
|
background-color: #ffffff;
|
|
color: #000000;
|
|
}
|
|
[data-theme="dark"] .p-accordion-tab {
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Accordion header link */
|
|
.p-accordion-header .p-accordion-header-link {
|
|
background-color: #f9f9f9;
|
|
color: #000000;
|
|
}
|
|
[data-theme="dark"] .p-accordion-header .p-accordion-header-link {
|
|
background-color: #1e1e1e !important;
|
|
color: #ffffff !important;
|
|
}
|
|
|
|
/* Accordion content panel */
|
|
.p-accordion-content {
|
|
background-color: #fafafa;
|
|
color: #000000;
|
|
}
|
|
[data-theme="dark"] .p-accordion-content {
|
|
background-color: #2a2a2a;
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Track list UL/LI styling */
|
|
.p-accordion-content ul {
|
|
padding-left: 0;
|
|
margin: 0;
|
|
list-style: none;
|
|
}
|
|
[data-theme="dark"] .p-accordion-content ul {
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Track items */
|
|
.p-accordion-content li {
|
|
background-color: #fff;
|
|
border-bottom: 1px solid #e5e5e5;
|
|
}
|
|
[data-theme="dark"] .p-accordion-content li {
|
|
background-color: #2a2a2a;
|
|
border-bottom: 1px solid #444;
|
|
}
|
|
|
|
/* Checkboxes inside track list */
|
|
.p-accordion-content input[type="checkbox"] {
|
|
accent-color: #1d4ed8; /* optional for consistent dark mode styling */
|
|
}
|
|
|
|
/* Loading spinner (optional darker style) */
|
|
[data-theme="dark"] .animate-spin {
|
|
border-color: #555;
|
|
border-top-color: #1d4ed8;
|
|
}
|
|
|
|
/* Small text like audio quality, duration, version */
|
|
.p-accordion-content span {
|
|
color: #555;
|
|
}
|
|
[data-theme="dark"] .p-accordion-content span {
|
|
color: #aaa;
|
|
}
|
|
`}</style>
|
|
<BreadcrumbNav currentPage="request" />
|
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-col gap-4">
|
|
<label htmlFor="artistInput">Artist: </label>
|
|
<AutoComplete
|
|
id={artistInput}
|
|
ref={autoCompleteRef}
|
|
value={selectedArtist || artistInput}
|
|
suggestions={artistSuggestions}
|
|
field="artist"
|
|
completeMethod={searchArtists}
|
|
onChange={handleArtistChange}
|
|
minLength={3}
|
|
placeholder="Artist"
|
|
dropdown
|
|
className="w-full"
|
|
inputClassName="w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 text-black dark:text-white dark:bg-neutral-800"
|
|
onShow={attachScrollFix}
|
|
itemTemplate={artistItemTemplate}
|
|
/>
|
|
|
|
{(type === "album" || type === "track") && (
|
|
<input
|
|
type="text"
|
|
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
|
|
value={type === "album" ? albumInput : trackInput}
|
|
onChange={(e) =>
|
|
type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
|
|
}
|
|
placeholder={type === "album" ? "Album" : "Track"}
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-4 justify-end mt-2">
|
|
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
|
Quality:
|
|
</label>
|
|
<select
|
|
id="qualitySelect"
|
|
value={quality}
|
|
onChange={(e) => setQuality(e.target.value)}
|
|
className="border border-neutral-300 dark:border-neutral-600 rounded px-2 py-1 bg-white dark:bg-neutral-800 text-black dark:text-white"
|
|
>
|
|
<option value="FLAC">FLAC</option>
|
|
<option value="Lossy">Lossy</option>
|
|
</select>
|
|
</div>
|
|
|
|
<Button onClick={handleSearch} disabled={isSearching}>
|
|
{isSearching ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
|
Searching...
|
|
</span>
|
|
) : (
|
|
"Search"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{type === "artist" && albums.length > 0 && (
|
|
<>
|
|
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
|
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
|
<span className="mx-3 sm:inline">|</span>
|
|
<strong className="mr-2">Tracks:</strong> {totalTracks}
|
|
</div>
|
|
<a
|
|
href="#"
|
|
role="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleToggleAllAlbums();
|
|
}}
|
|
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
|
|
>
|
|
Check / Uncheck All Albums
|
|
</a>
|
|
</div>
|
|
<Accordion
|
|
multiple
|
|
className="mt-4"
|
|
activeIndex={expandedAlbums}
|
|
onTabChange={(e) => 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 (
|
|
<AccordionTab
|
|
key={id}
|
|
header={
|
|
<div className="flex flex-wrap items-center gap-3 w-full text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={allChecked}
|
|
ref={(el) => {
|
|
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}`}
|
|
/>
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleAlbumPlayPause(id, albumIndex);
|
|
}}
|
|
className={`w-7 h-7 flex items-center justify-center rounded-full border transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${queueAlbumId === id && isAudioPlaying
|
|
? "border-green-500 text-green-500"
|
|
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
|
aria-label={`${queueAlbumId === id && isAudioPlaying ? "Pause" : "Play"} album ${album}`}
|
|
aria-pressed={queueAlbumId === id && isAudioPlaying}
|
|
disabled={albumPlaybackLoadingId === id}
|
|
>
|
|
{albumPlaybackLoadingId === id ? (
|
|
<InlineSpinner sizeClass="h-3 w-3" />
|
|
) : queueAlbumId === id && isAudioPlaying ? (
|
|
<PauseIcon />
|
|
) : (
|
|
<PlayIcon />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleAlbumShuffle(id);
|
|
}}
|
|
className={`w-7 h-7 flex items-center justify-center rounded-full border transition-colors ${shuffleAlbums[id]
|
|
? "border-blue-600 text-blue-600"
|
|
: "border-neutral-400 text-neutral-500 hover:text-blue-600 hover:border-blue-600"}`}
|
|
aria-label={`Toggle shuffle for album ${album}`}
|
|
aria-pressed={!!shuffleAlbums[id]}
|
|
>
|
|
<ShuffleIcon active={!!shuffleAlbums[id]} />
|
|
</button>
|
|
</div>
|
|
<span className="flex items-center" title={album}>
|
|
{truncate(album, 32)}
|
|
{loadingAlbumId === id && <Spinner />}
|
|
</span>
|
|
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
|
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
|
|
{typeof tracksByAlbum[id] === 'undefined' ? (
|
|
loadingAlbumId === id ? 'Loading...' : '...'
|
|
) : (
|
|
`${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}`
|
|
)}
|
|
</span>
|
|
</div>
|
|
}
|
|
|
|
>
|
|
{allTracks.length > 0 ? (
|
|
<ul className="text-sm">
|
|
{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 (
|
|
<li key={track.id} className="py-2">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected?.includes(String(track.id))}
|
|
onChange={() => toggleTrack(id, track.id)}
|
|
className="trip-checkbox cursor-pointer"
|
|
aria-label={`Select track ${track.title} `}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
|
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
|
? "border-green-600 text-green-600"
|
|
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
|
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
|
aria-pressed={isCurrentTrack && isAudioPlaying}
|
|
disabled={audioLoadingTrackId === track.id}
|
|
>
|
|
{audioLoadingTrackId === track.id ? (
|
|
<InlineSpinner sizeClass="h-4 w-4" />
|
|
) : isCurrentTrack && isAudioPlaying ? (
|
|
<PauseIcon />
|
|
) : (
|
|
<PlayIcon />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 min-w-0 text-left">
|
|
<span className="block truncate" title={track.title}>
|
|
{truncate(track.title, 80)}
|
|
</span>
|
|
{track.version && (
|
|
<span className="block text-[0.7rem] text-neutral-500 truncate" title={track.version}>
|
|
{track.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-neutral-500 w-full justify-between sm:w-auto sm:justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTrackDownload(track)}
|
|
className="text-neutral-500 hover:text-blue-600 underline whitespace-nowrap cursor-pointer"
|
|
aria-label={`Download ${track.title}`}
|
|
>
|
|
Download
|
|
</button>
|
|
<span>{quality}</span>
|
|
{track.duration && (
|
|
<span className="tabular-nums">{track.duration}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{showProgress && (
|
|
<div className="mt-2 pr-2 pl-4 sm:pr-6 sm:pl-16">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={safeProgress.duration}
|
|
value={safeProgress.current}
|
|
step="0.25"
|
|
onChange={(e) => {
|
|
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}`}
|
|
/>
|
|
<div className="flex justify-between text-[0.65rem] text-neutral-500 mt-1">
|
|
<span>{formatTime(safeProgress.current)}</span>
|
|
<span>{formatTime(safeProgress.duration)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
|
|
{tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
|
|
</div>
|
|
)}
|
|
</AccordionTab>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
<div className="flex justify-end">
|
|
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
|
{isSubmitting ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
|
Submitting...
|
|
</span>
|
|
) : (
|
|
"Submit Request"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
</div >
|
|
</div >
|
|
);
|
|
}
|