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}
@@ -830,14 +1002,27 @@ export default function Player({ user }: PlayerProps) {
{
+ onClick={async () => {
const audio = audioElement.current;
if (!audio) return;
+
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
- audio.play().then(() => setIsPlaying(true));
+ // If stream is not ready, reinitialize it
+ if (!isStreamReady || !audio.src || audio.error) {
+ initializeStream(activeStation);
+ return;
+ }
+ try {
+ await audio.play();
+ setIsPlaying(true);
+ } catch (err) {
+ console.warn('Playback failed, reinitializing stream:', err);
+ // Reinitialize stream on playback failure
+ initializeStream(activeStation);
+ }
}
}}
role="button"
@@ -885,7 +1070,7 @@ export default function Player({ user }: PlayerProps) {
fetchQueue(e.page ?? 0, queueRows, queueSearch);
}}
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
- currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"
+ currentPageReportTemplate="Showing {first} to {last} of {totalRecords} tracks"
paginatorClassName="queue-paginator !bg-neutral-50 dark:!bg-neutral-800 !text-neutral-900 dark:!text-neutral-100 !border-neutral-200 dark:!border-neutral-700"
className="p-datatable-gridlines rounded-lg shadow-md border-t border-neutral-300 dark:border-neutral-700"
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
@@ -905,7 +1090,7 @@ export default function Player({ user }: PlayerProps) {
sortable
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
style={{ width: '300px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
- body={(rowData) =>
{rowData.artistsong}}
+ body={(rowData) =>
{rowData.artist} - {rowData.song}}
>
- Radio
- }
- >
- Maintenance in progress. Please check back soon!
-
+
+
Radio
+
+
+
+
+ Maintenance in progress. Please check back soon!
+
+
+
);
}
diff --git a/src/components/TRip/BreadcrumbNav.tsx b/src/components/TRip/BreadcrumbNav.tsx
index e2778ea..4731b3e 100644
--- a/src/components/TRip/BreadcrumbNav.tsx
+++ b/src/components/TRip/BreadcrumbNav.tsx
@@ -24,6 +24,7 @@ export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): Reac
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..366f910 100644
--- a/src/components/TRip/MediaRequestForm.tsx
+++ b/src/components/TRip/MediaRequestForm.tsx
@@ -100,6 +100,7 @@ export default function MediaRequestForm() {
const albumHeaderRefs = useRef>({});
const suppressHashRef = useRef(false);
const lastUrlRef = useRef("");
+ const playPauseClickRef = useRef(false);
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const sanitizeFilename = (text: string) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
@@ -558,31 +559,42 @@ export default function MediaRequestForm() {
};
const handleTrackPlayPause = async (track: Track, albumId: string | number | null = null, albumIndex: number | null = null) => {
+ // Prevent double-clicks / rapid clicks
+ if (playPauseClickRef.current) return;
+ playPauseClickRef.current = true;
+
const audio = audioRef.current;
- if (!audio) return;
+ if (!audio) {
+ playPauseClickRef.current = false;
+ return;
+ }
if (typeof albumIndex === "number") {
ensureAlbumExpanded(albumIndex);
}
- if (currentTrackId === track.id) {
- if (audio.paused) {
- setIsAudioPlaying(true);
- try {
+ try {
+ if (currentTrackId === track.id) {
+ if (audio.paused) {
+ setIsAudioPlaying(true);
await audio.play();
- } catch (error) {
+ } else {
setIsAudioPlaying(false);
- console.error(error);
- toast.error("Unable to resume playback.");
+ audio.pause();
}
} else {
- setIsAudioPlaying(false);
- audio.pause();
+ await playTrack(track, { fromQueue: false });
}
- return;
+ } catch (error) {
+ setIsAudioPlaying(false);
+ console.error(error);
+ toast.error("Unable to play/pause track.");
+ } finally {
+ // Small delay before allowing next click to prevent rapid clicks
+ setTimeout(() => {
+ playPauseClickRef.current = false;
+ }, 150);
}
-
- await playTrack(track, { fromQueue: false });
};
const toggleAlbumShuffle = (albumId) => {
@@ -993,7 +1005,14 @@ export default function MediaRequestForm() {
}
const data = await response.json();
- toast.success(`Request submitted! (${allSelectedIds.length} tracks)`);
+ const toastId = 'trip-request-submitted';
+ toast.success(`Request submitted! (${allSelectedIds.length} tracks)`, {
+ toastId,
+ autoClose: 3000,
+ onClose: () => {
+ if (typeof window !== 'undefined') window.location.href = '/TRip/requests';
+ }
+ });
} catch (err) {
console.error(err);
toast.error("Failed to submit request.");
@@ -1297,22 +1316,13 @@ export default function MediaRequestForm() {
e.stopPropagation();
handleTrackPlayPause(track, id, albumIndex);
}}
- onPointerDown={(e) => {
- try {
- if (e?.pointerType === "touch" || e.type === "touchstart") {
- e.preventDefault();
- }
- } catch (err) {
- // ignore
- }
- }}
- style={{ touchAction: "manipulation" }}
- 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
+ style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
+ className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed select-none ${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}
+ disabled={audioLoadingTrackId === track.id || playPauseClickRef.current}
>
{audioLoadingTrackId === track.id ? (
diff --git a/src/components/TRip/RequestManagement.css b/src/components/TRip/RequestManagement.css
index de15976..ed1f2cf 100644
--- a/src/components/TRip/RequestManagement.css
+++ b/src/components/TRip/RequestManagement.css
@@ -279,6 +279,7 @@
display: flex;
align-items: center;
width: 100%;
+ gap: 0.5rem; /* space between track and percent */
}
@@ -292,6 +293,7 @@
overflow: hidden; /* must clip when scaled */
margin: 0 !important;
padding: 0 !important;
+ margin-right: 0; /* ensure neighbor percent isn't pushed inside */
}
.rm-progress-track-lg {
@@ -308,13 +310,41 @@
transform: scaleX(var(--rm-progress, 0)); /* use custom property (0-1 range) */
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
- transition: transform 0.24s cubic-bezier(0.4,0,0.2,1), border-radius 0.24s;
+ transition: transform .5s cubic-bezier(.25,.8,.25,1), background-color .28s ease, border-radius .28s;
margin: 0 !important;
padding: 0 !important;
right: 0;
min-width: 0;
- will-change: transform;
+ will-change: transform, background-color;
box-sizing: border-box;
+ z-index: 1; /* ensure fill sits beneath the percent text */
+}
+
+/* Ensure percent label appears above the fill even when inside the track */
+.rm-progress-text {
+ position: relative;
+ z-index: 2;
+ flex: none; /* don't stretch */
+ margin-left: 0.5rem;
+ white-space: nowrap;
+ overflow: visible;
+}
+
+/* Finalizing pulse for near-100% jobs */
+.rm-finalizing {
+ animation: rm-finalize-pulse 1.6s ease-in-out infinite;
+}
+
+@keyframes rm-finalize-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
+ }
+ 50% {
+ box-shadow: 0 0 12px 4px rgba(255, 193, 7, 0.10);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
+ }
}
/* Fix for native audio progress bar (range input) */
@@ -404,16 +434,47 @@
.rm-progress-text {
font-size: 0.75rem;
font-weight: 600;
+ color: inherit;
+}
/* Ensure progress styles apply when rendered within a PrimeReact Dialog (portal) */
-.p-dialog .rm-progress-container{display:flex;align-items:center;width:100%}
-.p-dialog .rm-progress-track{position:relative;flex:1 1 0%;min-width:0;height:6px;background-color:#80808033;border-radius:999px;overflow:hidden;margin:0!important;padding:0!important}
-.p-dialog .rm-progress-track-lg{height:10px}
-.p-dialog .rm-progress-fill{position:absolute;left:0;top:0;height:100%;width:100%!important;transform-origin:left center;transform:scaleX(var(--rm-progress, 0));border-top-left-radius:999px;border-bottom-left-radius:999px;transition:transform .24s cubic-bezier(.4,0,.2,1),border-radius .24s;margin:0!important;padding:0!important;right:0;min-width:0;will-change:transform;box-sizing:border-box}
-.p-dialog .rm-progress-text{font-size:.75rem;font-weight:600;min-width:2.5rem;text-align:right}
- min-width: 2.5rem;
- text-align: right;
+.p-dialog .rm-progress-container {
+ display: flex;
+ align-items: center;
+ width: 100%;
}
+.p-dialog .rm-progress-track {
+ position: relative;
+ flex: 1 1 0%;
+ min-width: 0;
+ height: 6px;
+ background-color: #80808033;
+ border-radius: 999px;
+ overflow: hidden;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+.p-dialog .rm-progress-track-lg { height: 10px; }
+.p-dialog .rm-progress-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: 100% !important;
+ transform-origin: left center;
+ transform: scaleX(var(--rm-progress, 0));
+ border-top-left-radius: 999px;
+ border-bottom-left-radius: 999px;
+ transition: transform .5s cubic-bezier(.25,.8,.25,1), background-color .28s ease, border-radius .28s;
+ margin: 0 !important;
+ padding: 0 !important;
+ right: 0;
+ min-width: 0;
+ will-change: transform, background-color;
+ box-sizing: border-box;
+}
+.p-dialog .rm-progress-text { font-size: .75rem; font-weight: 600; color: #e5e7eb !important; margin-left: 0.5rem; white-space: nowrap; }
+
/* Container Styles */
.trip-management-container {
@@ -531,6 +592,25 @@
padding: 0.5rem !important;
font-size: 0.85rem !important;
}
+
+/* Track list scrollbar and status pill adjustments */
+.rm-track-list {
+ /* Give room for overlay scrollbars so status pills don't overlap */
+ padding-inline-end: 1.25rem; /* ~20px */
+}
+
+.rm-track-status {
+ /* Ensure the status pill has extra right padding and sits visually clear of the scrollbar */
+ padding-right: 0.75rem !important;
+ margin-right: 0.25rem !important;
+ border-radius: 999px !important;
+}
+
+/* Slightly reduce spacing on very small screens */
+@media (max-width: 480px) {
+ .rm-track-list { padding-inline-end: 0.75rem; }
+ .rm-track-status { padding-right: 0.5rem !important; }
+}
/* Album header info stacks */
.album-header-info {
diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx
index 1bd946b..9534617 100644
--- a/src/components/TRip/RequestManagement.tsx
+++ b/src/components/TRip/RequestManagement.tsx
@@ -11,6 +11,15 @@ import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
import "./RequestManagement.css";
+interface TrackInfo {
+ title?: string;
+ artist?: string;
+ status?: string;
+ error?: string;
+ filename?: string;
+ [key: string]: unknown;
+}
+
interface RequestJob {
id: string | number;
target: string;
@@ -22,11 +31,12 @@ interface RequestJob {
tarball?: string;
created_at?: string;
updated_at?: string;
+ track_list?: TrackInfo[];
[key: string]: unknown;
}
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
-const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
+const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix
export default function RequestManagement() {
const [requests, setRequests] = useState([]);
@@ -38,6 +48,8 @@ export default function RequestManagement() {
const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef | null>(null);
const pollingDetailRef = useRef | null>(null);
+ // Track finalizing job polls to actively refresh job status when progress hits 100% but status hasn't updated yet
+ const finalizingPollsRef = useRef | null>>({});
const resolveTarballPath = (job: RequestJob) => job.tarball;
@@ -47,6 +59,13 @@ export default function RequestManagement() {
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
// If the backend already stores a fully qualified URL, return as-is
if (/^https?:\/\//i.test(absPath)) return absPath;
+
+ // Check if path is /storage/music/TRIP
+ if (absPath.includes("/storage/music/TRIP/")) {
+ return `https://music.boatson.boats/TRIP/${filename}`;
+ }
+
+ // Otherwise, assume /storage/music2/completed/{quality} format
return `${TAR_BASE_URL}/${quality}/${filename}`;
};
@@ -182,30 +201,105 @@ export default function RequestManagement() {
const computePct = (p: unknown) => {
if (p === null || p === undefined || p === "") return 0;
+ // Handle "X / Y" format (e.g., "9 / 545") - note spaces around slash from backend
+ if (typeof p === 'string' && p.includes('/')) {
+ const parts = p.split('/').map(s => s.trim());
+ const current = parseFloat(parts[0]);
+ const total = parseFloat(parts[1]);
+ if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
+ return Math.min(100, Math.max(0, Math.round((current / total) * 100)));
+ }
+ return 0;
+ }
const num = Number(p);
if (!Number.isFinite(num)) return 0;
- const normalized = num > 1 ? num : num * 100;
- return Math.min(100, Math.max(0, Math.round(normalized)));
+ // Backend sends progress as 0-100 directly, so just clamp it
+ return Math.min(100, Math.max(0, Math.round(num)));
};
+ // Visual pct used for display/fill. Prevent briefly showing 100% unless status is Finished
+ const displayPct = (p: unknown, status?: string) => {
+ const pct = computePct(p);
+ const statusNorm = String(status || "").trim();
+ // If the backend reports 100% but the job hasn't reached 'Finished', show 99 to avoid flash
+ if (pct >= 100 && statusNorm.toLowerCase() !== 'finished') return 99;
+ return pct;
+ };
+ const isFinalizingJob = (job: RequestJob | { progress?: unknown; status?: string }) => {
+ const pct = computePct(job.progress);
+ const statusNorm = String(job.status || "").trim().toLowerCase();
+ // Only treat as finalizing when status is explicitly "Compressing"
+ // This is set by the backend only when progress == 100 and tarball isn't ready yet
+ return statusNorm === 'compressing';
+ };
+
+ const startFinalizingPoll = (jobId: string | number) => {
+ if (finalizingPollsRef.current[jobId]) return; // already polling
+
+ let attempts = 0;
+ const iv = setInterval(async () => {
+ attempts += 1;
+ try {
+ const updated = await fetchJobDetail(jobId);
+ if (updated) {
+ // Merge the updated job into requests list so UI refreshes
+ setRequests((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
+ // If it's no longer finalizing, stop this poll
+ if (!isFinalizingJob(updated)) {
+ if (finalizingPollsRef.current[jobId]) {
+ clearInterval(finalizingPollsRef.current[jobId] as ReturnType);
+ finalizingPollsRef.current[jobId] = null;
+ }
+ }
+ }
+ } catch (err) {
+ // ignore individual errors; we'll retry a few times
+ }
+ // safety cap: stop after ~20 attempts (~30s)
+ if (attempts >= 20) {
+ if (finalizingPollsRef.current[jobId]) {
+ clearInterval(finalizingPollsRef.current[jobId] as ReturnType);
+ finalizingPollsRef.current[jobId] = null;
+ }
+ }
+ }, 1500);
+
+ finalizingPollsRef.current[jobId] = iv;
+ };
+
+ // stop all finalizing polls on unmount
+ useEffect(() => {
+ return () => {
+ Object.values(finalizingPollsRef.current).forEach((iv) => {
+ if (iv) clearInterval(iv);
+ });
+ };
+ }, []);
+
const progressBarTemplate = (rowData: RequestJob) => {
const p = rowData.progress;
if (p === null || p === undefined || p === "") return "—";
- const pct = computePct(p);
+ const pctRaw = computePct(p);
+ const isFinalizing = isFinalizingJob(rowData);
+ const pct = isFinalizing ? 99 : pctRaw;
const getProgressColor = () => {
if (rowData.status === "Failed") return "bg-red-500";
if (rowData.status === "Finished") return "bg-green-500";
- if (pct < 30) return "bg-blue-400";
- if (pct < 70) return "bg-blue-500";
+ if (isFinalizing) return "bg-yellow-500"; // finalizing indicator
+ if (pctRaw < 30) return "bg-blue-400";
+ if (pctRaw < 70) return "bg-blue-500";
return "bg-blue-600";
};
+ // If this job appears to be finalizing, ensure a poll is active to get the real status
+ if (isFinalizing) startFinalizingPoll(rowData.id);
+
return (
-
{pct}%
+
{pct}%{isFinalizing ? ' (finalizing...)' : ''}
);
};
@@ -411,26 +505,34 @@ export default function RequestManagement() {
{(() => {
- const pctDialog = computePct(selectedRequest.progress);
+ const pctRawDialog = computePct(selectedRequest.progress);
+ const isFinalizingDialog = isFinalizingJob(selectedRequest);
+ const pctDialog = isFinalizingDialog ? 99 : pctRawDialog;
const status = selectedRequest.status;
- const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : "bg-blue-500";
+ const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : isFinalizingDialog ? "bg-yellow-500" : "bg-blue-500";
+
+ // Ensure we poll for finalizing jobs to get the real status update
+ if (isFinalizingDialog) startFinalizingPoll(selectedRequest.id);
+
return (
-
= 100 ? '999px' : 0,
- borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
- }}
- data-pct={pctDialog}
- aria-valuenow={pctDialog}
- aria-valuemin={0}
- aria-valuemax={100}
- />
+ <>
+
= 100 ? '999px' : 0,
+ borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
+ }}
+ data-pct={pctDialog}
+ aria-valuenow={pctRawDialog}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ />
+ >
);
})()}
-
{formatProgress(selectedRequest.progress)}
+
{formatProgress(selectedRequest.progress)}{isFinalizingJob(selectedRequest) ? ' — finalizing' : ''}
)}
@@ -462,6 +564,66 @@ export default function RequestManagement() {
)
}
+ {/* --- Track List Card --- */}
+ {selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
+
+
Tracks ({selectedRequest.track_list.length}):
+
+ {selectedRequest.track_list.map((track, idx) => {
+ const rawStatus = String(track.status || "pending");
+ const statusNorm = rawStatus.trim().toLowerCase();
+
+ const isError = statusNorm === "failed" || statusNorm === "error" || !!track.error;
+ const isSuccess = ["done", "success", "completed", "finished"].includes(statusNorm);
+ const isPending = ["pending", "queued"].includes(statusNorm);
+ const isDownloading = ["downloading", "in_progress", "started"].includes(statusNorm);
+
+ const statusBadgeClass = isError
+ ? "bg-red-600 text-white"
+ : isSuccess
+ ? "bg-green-600 text-white"
+ : isDownloading
+ ? "bg-blue-600 text-white"
+ : isPending
+ ? "bg-yellow-600 text-white"
+ : "bg-gray-500 text-white";
+
+ const trackTitle = track.title || track.filename || `Track ${idx + 1}`;
+ const trackArtist = track.artist;
+
+ return (
+
+
+
+
+ {trackTitle}
+
+ {trackArtist && (
+
+ {trackArtist}
+
+ )}
+
+
+ {rawStatus}
+
+
+ {track.error && (
+
+
+ {track.error}
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
) : (
Loading...
diff --git a/src/components/req/ReqForm.tsx b/src/components/req/ReqForm.tsx
index 2641546..261d372 100644
--- a/src/components/req/ReqForm.tsx
+++ b/src/components/req/ReqForm.tsx
@@ -296,7 +296,7 @@ export default function ReqForm() {
/>
{selectedItem && selectedTypeLabel && (
- Selected type: {selectedTypeLabel}
+ Type: {selectedTypeLabel}
)}
@@ -334,18 +334,19 @@ export default function ReqForm() {
setYear(e.target.value)}
- placeholder="e.g. 2023"
- className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
+ placeholder=""
+ readOnly
+ disabled
+ className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 cursor-not-allowed transition-all outline-none"
/>
-
+ {/*
@@ -356,7 +357,7 @@ export default function ReqForm() {
placeholder="Who is requesting this?"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
/>
-
+
*/}