From 256d5d9c7f2dca89d523b06dec156854607299f0 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 24 Dec 2025 09:55:08 -0500 Subject: [PATCH 1/5] Redirect user to requests page after successful media request submission js->ts --- src/assets/styles/player.css | 14 +- src/components/AppLayout.tsx | 111 +- src/components/Radio.tsx | 97 +- src/components/TRip/MediaRequestForm.jsx | 1349 ---------------------- src/components/TRip/MediaRequestForm.tsx | 4 + 5 files changed, 186 insertions(+), 1389 deletions(-) delete mode 100644 src/components/TRip/MediaRequestForm.jsx diff --git a/src/assets/styles/player.css b/src/assets/styles/player.css index 72ae265..aed13e3 100644 --- a/src/assets/styles/player.css +++ b/src/assets/styles/player.css @@ -6,10 +6,10 @@ } :root { - --lrc-text-color: #333; /* darker text */ + --lrc-text-color: #666; /* muted text for inactive lines */ --lrc-bg-color: rgba(0, 0, 0, 0.05); --lrc-active-color: #000; /* bold black for active */ - --lrc-active-shadow: none; /* no glow in light mode */ + --lrc-active-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); /* subtle shadow in light mode */ --lrc-hover-color: #005fcc; /* darker blue hover */ } @@ -259,7 +259,7 @@ body { opacity: 1; pointer-events: auto; transition: opacity 0.3s ease; - max-height: 125px; + max-height: 220px; max-width: 100%; overflow-y: auto; margin-top: 1rem; @@ -277,6 +277,9 @@ body { scrollbar-width: thin; scrollbar-color: #999 transparent; scroll-padding-bottom: 1rem; + overscroll-behavior: contain; + touch-action: pan-y; + scroll-behavior: smooth; } .lrc-text.empty { @@ -297,9 +300,10 @@ body { .lrc-line.active { color: var(--lrc-active-color); - font-weight: 600; - font-size: 0.8rem; + font-weight: 700; + font-size: 0.95rem; text-shadow: var(--lrc-active-shadow); + transform: scale(1.02); } .lrc-line:hover { diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 84efb88..269d7b2 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,27 +1,49 @@ import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react'; import type { ComponentType } from 'react'; -import Memes from './Memes.jsx'; -import Lighting from './Lighting.jsx'; +import Memes from './Memes.tsx'; +import Lighting from './Lighting.tsx'; import { toast } from 'react-toastify'; -import { JoyUIRootIsland } from './Components.jsx'; +import { JoyUIRootIsland } from './Components.tsx'; import { PrimeReactProvider } from "primereact/api"; -import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.jsx'; -import CustomToastContainer from '../components/ToastProvider.jsx'; +import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.tsx'; +import CustomToastContainer from '../components/ToastProvider.tsx'; import 'primereact/resources/themes/bootstrap4-light-blue/theme.css'; import 'primereact/resources/primereact.min.css'; import "primeicons/primeicons.css"; -const LoginPage = lazy(() => import('./Login.jsx')); +const LoginPage = lazy(() => import('./Login.tsx')); const LyricSearch = lazy(() => import('./LyricSearch')); -const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx')); -const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx')); -const DiscordLogs = lazy(() => import('./DiscordLogs.jsx')); +const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.tsx')); +const RequestManagement = lazy(() => import('./TRip/RequestManagement.tsx')); +const DiscordLogs = lazy(() => import('./DiscordLogs.tsx')); // NOTE: Player is intentionally NOT imported at module initialization. // We create the lazy import inside the component at render-time only when // we are on the main site and the Player island should be rendered. This // prevents bundling the player island into pages that are explicitly // identified as subsites. -const ReqForm = lazy(() => import('./req/ReqForm.jsx')); +const ReqForm = lazy(() => import('./req/ReqForm.tsx')); + +// Simple error boundary for lazy islands +class LazyBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(err) { + if (import.meta.env.DEV) { + console.error('[AppLayout] lazy island error', err); + } + } + render() { + if (this.state.hasError) { + return
Something went wrong loading this module.
; + } + return this.props.children; + } +} declare global { interface Window { @@ -55,15 +77,18 @@ export default function Root({ child, user = undefined, ...props }: RootProps): // don't need to pass guards. const isSubsite = typeof document !== 'undefined' && document.documentElement.getAttribute('data-subsite') === 'true'; // Log when the active child changes (DEV only) + const devLogsEnabled = import.meta.env.DEV && import.meta.env.VITE_DEV_LOGS === '1'; + useEffect(() => { + if (!devLogsEnabled) return; try { - if (typeof console !== 'undefined' && typeof document !== 'undefined' && import.meta.env.DEV) { + if (typeof console !== 'undefined' && typeof document !== 'undefined') { console.debug(`[AppLayout] child=${String(child)}, data-subsite=${document.documentElement.getAttribute('data-subsite')}`); } } catch (e) { // no-op } - }, [child]); + }, [child, devLogsEnabled]); // Only initialize the lazy player when this is NOT a subsite and the // active child is the Player island. Placing the lazy() call here @@ -114,23 +139,59 @@ export default function Root({ child, user = undefined, ...props }: RootProps): color="danger"> Work in progress... bugs are to be expected. */} - {child == "LoginPage" && ()} - {child == "LyricSearch" && ()} + {child == "LoginPage" && ( + + Loading...}> + + + + )} + {child == "LyricSearch" && ( + + Loading...}> + + + + )} {child == "Player" && !isSubsite && PlayerComp && ( - - - + + + + + + )} + {child == "Memes" && ( + + + )} - {child == "Memes" && } {child == "DiscordLogs" && ( - Loading...}> - - + + Loading...}> + + + + )} + {child == "qs2.MediaRequestForm" && ( + + + + )} + {child == "qs2.RequestManagement" && ( + + + + )} + {child == "ReqForm" && ( + + + + )} + {child == "Lighting" && ( + + + )} - {child == "qs2.MediaRequestForm" && } - {child == "qs2.RequestManagement" && } - {child == "ReqForm" && } - {child == "Lighting" && } ); diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index 6929de0..dc8200a 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -44,6 +44,11 @@ export default function Player({ user }: PlayerProps) { // Global CSS now contains the paginator / dialog datatable dark rules. const [isQueueVisible, setQueueVisible] = useState(false); + const lrcContainerRef = useRef(null); + const lrcWheelHandlerRef = useRef<((e: WheelEvent) => void) | null>(null); + const lrcScrollTimeout = useRef(null); + const lrcScrollRaf = useRef(null); + const lrcActiveRef = useRef(null); // Mouse wheel scroll fix for queue modal useEffect(() => { if (!isQueueVisible) return; @@ -249,16 +254,56 @@ export default function Player({ user }: PlayerProps) { // Scroll active lyric into view useEffect(() => { - setTimeout(() => { - const activeElement = document.querySelector('.lrc-line.active'); - const lyricsContainer = document.querySelector('.lrc-text'); - if (activeElement && lyricsContainer) { - (lyricsContainer as HTMLElement).style.maxHeight = '220px'; - (lyricsContainer as HTMLElement).style.overflowY = 'auto'; - activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const container = lrcContainerRef.current; + if (!container || lyrics.length === 0) return; + + const scheduleScroll = () => { + // Read ref/DOM inside the callback so we get the updated element after render + const activeElement = lrcActiveRef.current || (container.querySelector('.lrc-line.active') as HTMLElement | null); + if (!activeElement) return; + + // Use getBoundingClientRect for accurate positioning + const containerRect = container.getBoundingClientRect(); + const activeRect = activeElement.getBoundingClientRect(); + + // Calculate where the element is relative to the container's current scroll + const elementTopInContainer = activeRect.top - containerRect.top + container.scrollTop; + + // Center the active line in the container + const targetScrollTop = elementTopInContainer - (container.clientHeight / 2) + (activeElement.offsetHeight / 2); + + container.scrollTo({ top: Math.max(targetScrollTop, 0), behavior: 'smooth' }); + }; + + // Debounce a tick then align to paint frame so ref is updated after render + if (lrcScrollTimeout.current) window.clearTimeout(lrcScrollTimeout.current); + lrcScrollTimeout.current = window.setTimeout(() => { + if (lrcScrollRaf.current) cancelAnimationFrame(lrcScrollRaf.current); + lrcScrollRaf.current = requestAnimationFrame(scheduleScroll); + }, 16); + + const wheelHandler = (e: WheelEvent) => { + const atTop = container.scrollTop === 0; + const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + e.preventDefault(); + } else { + e.stopPropagation(); } - }, 0); - }, [currentLyricIndex, lyrics]); + }; + + if (lrcWheelHandlerRef.current) { + container.removeEventListener('wheel', lrcWheelHandlerRef.current as EventListener); + } + lrcWheelHandlerRef.current = wheelHandler; + container.addEventListener('wheel', wheelHandler, { passive: false }); + + return () => { + if (lrcScrollTimeout.current) window.clearTimeout(lrcScrollTimeout.current); + if (lrcScrollRaf.current) cancelAnimationFrame(lrcScrollRaf.current); + container.removeEventListener('wheel', wheelHandler as EventListener); + }; + }, [currentLyricIndex, lyrics.length]); // Handle station changes: reset and start new stream useEffect(() => { @@ -746,10 +791,42 @@ export default function Player({ user }: PlayerProps) { > -
+
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."); -- 2.47.3 From 1da33de8923793a4db4f9db3338093efe92c921f Mon Sep 17 00:00:00 2001 From: codey Date: Sun, 25 Jan 2026 13:11:25 -0500 Subject: [PATCH 2/5] Refactor components to TypeScript, enhance media request handling, and improve UI elements - Updated Radio component to TypeScript and added WebSocket connection checks. - Enhanced DiscordLogs component with thread detection and improved bot presence checks. - Modified Login component to include a client ID for authentication. - Refactored RadioBanner for better styling and accessibility. - Improved BreadcrumbNav with Astro reload attribute for better navigation. - Enhanced MediaRequestForm to prevent rapid clicks during track play/pause. - Updated RequestManagement to handle track lists and finalizing job status more effectively. - Improved CSS for RequestManagement to enhance progress bar and track list display. --- src/components/AppLayout.tsx | 2 +- src/components/DiscordLogs.tsx | 7 +- src/components/Login.tsx | 2 +- src/components/Radio.tsx | 184 +++++++++++++++---- src/components/RadioBanner.tsx | 24 +-- src/components/TRip/BreadcrumbNav.tsx | 1 + src/components/TRip/MediaRequestForm.tsx | 66 +++---- src/components/TRip/RequestManagement.css | 98 +++++++++- src/components/TRip/RequestManagement.tsx | 208 +++++++++++++++++++--- 9 files changed, 477 insertions(+), 115 deletions(-) diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 269d7b2..5ac813d 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -107,7 +107,7 @@ export default function Root({ child, user = undefined, ...props }: RootProps): let mounted = true; if (wantPlayer) { if (import.meta.env.DEV) { try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } } - import('./Radio.js') + import('./Radio.tsx') .then((mod) => { if (!mounted) return; // set the component factory diff --git a/src/components/DiscordLogs.tsx b/src/components/DiscordLogs.tsx index e7b308c..922c612 100644 --- a/src/components/DiscordLogs.tsx +++ b/src/components/DiscordLogs.tsx @@ -3284,9 +3284,14 @@ export default function DiscordLogs() { // Check if current channel is dds-archive const isArchiveChannel = selectedChannel?.name === 'dds-archive'; + // Check if current channel is a thread (types 10, 11, 12) + const isThread = selectedChannel?.type === 10 || selectedChannel?.type === 11 || selectedChannel?.type === 12; + // Check if Havoc bot is in the channel's member list const HAVOC_BOT_ID = '1219636064608583770'; const isHavocInChannel = useMemo(() => { + // Threads inherit access from parent channel - if we can see the thread, Havoc has access + if (isThread) return true; if (!members?.groups) return true; // Assume present if members not loaded for (const group of members.groups) { if (group.members?.some(m => m.id === HAVOC_BOT_ID)) { @@ -3294,7 +3299,7 @@ export default function DiscordLogs() { } } return false; - }, [members]); + }, [members, isThread]); // Group messages by author and time window (5 minutes) // Messages are in ASC order (oldest first, newest at bottom), so we group accordingly diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 1f934b0..8ee5041 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -53,7 +53,7 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ password, grant_type: "password", scope: "", - client_id: "", + client_id: "b8308cf47d424e66", client_secret: "", }); diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index dc8200a..3beefa9 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -156,6 +156,10 @@ export default function Player({ user }: PlayerProps) { const lastUpdateTimestamp = useRef(Date.now()); const activeStationRef = useRef(activeStation); const wsInstance = useRef(null); + const wsReconnectTimer = useRef | null>(null); + const wsConnectionCheckTimer = useRef | null>(null); + const wsLastMessageTime = useRef(Date.now()); + const [isStreamReady, setIsStreamReady] = useState(false); const formatTime = (seconds: number): string => { if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; @@ -171,6 +175,7 @@ export default function Player({ user }: PlayerProps) { // Initialize or switch HLS stream const initializeStream = (station) => { + setIsStreamReady(false); import('hls.js').then(({ default: Hls }) => { const audio = audioElement.current; if (!audio) return; @@ -188,11 +193,13 @@ export default function Player({ user }: PlayerProps) { // Handle audio load errors audio.onerror = () => { setIsPlaying(false); + setIsStreamReady(false); }; if (audio.canPlayType("application/vnd.apple.mpegurl")) { audio.src = streamUrl; audio.load(); + setIsStreamReady(true); audio.play().then(() => setIsPlaying(true)).catch(() => { setTrackTitle("Offline"); setIsPlaying(false); @@ -222,6 +229,7 @@ export default function Player({ user }: PlayerProps) { hls.attachMedia(audio); hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl)); hls.on(Hls.Events.MANIFEST_PARSED, () => { + setIsStreamReady(true); audio.play().then(() => setIsPlaying(true)).catch(() => { setIsPlaying(false); }); @@ -233,6 +241,7 @@ export default function Player({ user }: PlayerProps) { hlsInstance.current = null; setTrackTitle("Offline"); setIsPlaying(false); + setIsStreamReady(false); } }); }); @@ -351,11 +360,20 @@ export default function Player({ user }: PlayerProps) { const handleTrackData = useCallback((trackData) => { const requestStation = activeStationRef.current; + // Guard: if trackData is null/undefined or empty object, ignore + if (!trackData || (typeof trackData === 'object' && Object.keys(trackData).length === 0)) { + return; + } + // Only set "No track playing" if we have clear indication of no track - // and not just missing song field (to avoid flickering during transitions) + // AND we don't already have a valid track playing (to prevent race conditions) if ((!trackData.song || trackData.song === 'N/A') && (!trackData.artist || trackData.artist === 'N/A')) { - setTrackTitle('No track playing'); - setLyrics([]); + // Only clear if we don't have a current track UUID (meaning we never had valid data) + if (!currentTrackUuid.current) { + setTrackTitle('No track playing'); + setLyrics([]); + } + // Otherwise ignore this empty data - keep showing current track return; } @@ -383,27 +401,65 @@ export default function Player({ user }: PlayerProps) { }, []); const initializeWebSocket = useCallback((station) => { - // Clean up existing WebSocket + // Clean up existing WebSocket and timers + if (wsReconnectTimer.current) { + clearTimeout(wsReconnectTimer.current); + wsReconnectTimer.current = null; + } + if (wsConnectionCheckTimer.current) { + clearInterval(wsConnectionCheckTimer.current); + wsConnectionCheckTimer.current = null; + } if (wsInstance.current) { wsInstance.current.onclose = null; // Prevent triggering reconnection logic + wsInstance.current.onerror = null; wsInstance.current.close(); wsInstance.current = null; } - const connectWebSocket = (retryCount = 0) => { - const baseDelay = 1000; // 1 second - const maxDelay = 30000; // 30 seconds + let retryCount = 0; + const baseDelay = 1000; // 1 second + const maxDelay = 30000; // 30 seconds + let isIntentionallyClosed = false; + + const connectWebSocket = () => { + if (isIntentionallyClosed) return; const wsUrl = `${API_URL.replace(/^https?:/, 'wss:')}/radio/ws/${station}`; const ws = new WebSocket(wsUrl); ws.onopen = function () { // Reset retry count on successful connection + retryCount = 0; + wsLastMessageTime.current = Date.now(); + + // Start periodic connection check - if no messages received for 2 minutes, + // the connection is likely dead (server sends track updates regularly) + if (wsConnectionCheckTimer.current) { + clearInterval(wsConnectionCheckTimer.current); + } + wsConnectionCheckTimer.current = setInterval(() => { + const timeSinceLastMessage = Date.now() - wsLastMessageTime.current; + const isStale = timeSinceLastMessage > 120000; // 2 minutes without any message + + // Check if connection appears dead + if (ws.readyState !== WebSocket.OPEN || isStale) { + console.warn('WebSocket connection appears stale, reconnecting...'); + // Force close and let onclose handle reconnection + ws.close(); + } + }, 30000); // Check every 30 seconds }; ws.onmessage = function (event) { + // Track last message time for connection health monitoring + wsLastMessageTime.current = Date.now(); + try { const data = JSON.parse(event.data); + + // Ignore pong responses and other control messages + if (data.type === 'pong' || data.type === 'ping' || data.type === 'error' || data.type === 'status') return; if (data.type === 'track_change') { // Handle track change @@ -415,24 +471,35 @@ export default function Player({ user }: PlayerProps) { const parsedLyrics = parseLrcString(data.data); setLyrics(parsedLyrics); setCurrentLyricIndex(0); - } else { - // Handle initial now playing data + } else if (data.type === 'now_playing' || data.type === 'initial') { + // Explicit now playing message + handleTrackData(data.data || data); + } else if (!data.type && (data.song || data.artist || data.uuid)) { + // Untyped message with track data fields - treat as now playing handleTrackData(data); } + // Ignore any other message types that don't contain track data } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onclose = function (event) { - // Don't retry if it was a clean close (code 1000) - if (event.code === 1000) return; + // Clear connection check timer + if (wsConnectionCheckTimer.current) { + clearInterval(wsConnectionCheckTimer.current); + wsConnectionCheckTimer.current = null; + } - // Attempt reconnection with exponential backoff + // Always attempt reconnection unless intentionally closed + if (isIntentionallyClosed) return; + + // Exponential backoff for reconnection const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay); + retryCount++; - setTimeout(() => { - connectWebSocket(retryCount + 1); + wsReconnectTimer.current = setTimeout(() => { + connectWebSocket(); }, delay); }; @@ -445,6 +512,25 @@ export default function Player({ user }: PlayerProps) { }; connectWebSocket(); + + // Return cleanup function + return () => { + isIntentionallyClosed = true; + if (wsReconnectTimer.current) { + clearTimeout(wsReconnectTimer.current); + wsReconnectTimer.current = null; + } + if (wsConnectionCheckTimer.current) { + clearInterval(wsConnectionCheckTimer.current); + wsConnectionCheckTimer.current = null; + } + if (wsInstance.current) { + wsInstance.current.onclose = null; + wsInstance.current.onerror = null; + wsInstance.current.close(); + wsInstance.current = null; + } + }; }, [handleTrackData, parseLrcString]); const setTrackMetadata = useCallback((trackData, requestStation) => { @@ -467,34 +553,43 @@ export default function Player({ user }: PlayerProps) { // Ensure the ref points to the current activeStation for in-flight guards activeStationRef.current = activeStation; - // Clean up the existing WebSocket connection before initializing a new one - const cleanupWebSocket = async () => { - if (wsInstance.current) { - wsInstance.current.onclose = null; // Prevent triggering reconnection logic - wsInstance.current.close(); - wsInstance.current = null; + // Initialize WebSocket connection for metadata + const cleanupWs = initializeWebSocket(activeStation); + + // Handle visibility change - reconnect when page becomes visible + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Check if WebSocket is not connected or not open + if (!wsInstance.current || wsInstance.current.readyState !== WebSocket.OPEN) { + // Reinitialize WebSocket connection + if (cleanupWs) cleanupWs(); + initializeWebSocket(activeStation); + } } }; - cleanupWebSocket().then(() => { - // Initialize WebSocket connection for metadata - initializeWebSocket(activeStation); - }); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { - // Clean up WebSocket on station change or component unmount - if (wsInstance.current) { - wsInstance.current.onclose = null; // Prevent triggering reconnection logic - wsInstance.current.close(); - wsInstance.current = null; - } + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (cleanupWs) cleanupWs(); }; }, [activeStation, initializeWebSocket]); // Cleanup WebSocket on component unmount useEffect(() => { return () => { + if (wsReconnectTimer.current) { + clearTimeout(wsReconnectTimer.current); + wsReconnectTimer.current = null; + } + if (wsConnectionCheckTimer.current) { + clearInterval(wsConnectionCheckTimer.current); + wsConnectionCheckTimer.current = null; + } if (wsInstance.current) { + wsInstance.current.onclose = null; + wsInstance.current.onerror = null; wsInstance.current.close(); wsInstance.current = null; } @@ -666,7 +761,7 @@ export default function Player({ user }: PlayerProps) { const [queueSearch, setQueueSearch] = useState(""); const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => { const start = page * rows; - console.log("Fetching queue for station (ref):", activeStationRef.current); + // console.log("Fetching queue for station (ref):", activeStationRef.current); try { const response = await authFetch(`${API_URL}/radio/get_queue`, { method: "POST", @@ -692,13 +787,13 @@ export default function Player({ user }: PlayerProps) { useEffect(() => { if (isQueueVisible) { - console.log("Fetching queue for station:", activeStation); + // console.log("Fetching queue for station:", activeStation); fetchQueue(queuePage, queueRows, queueSearch); } }, [isQueueVisible, queuePage, queueRows, queueSearch, activeStation]); useEffect(() => { - console.log("Active station changed to:", activeStation); + // console.log("Active station changed to:", activeStation); if (isQueueVisible) { fetchQueue(queuePage, queueRows, queueSearch); } @@ -706,7 +801,7 @@ export default function Player({ user }: PlayerProps) { useEffect(() => { if (isQueueVisible) { - console.log("Track changed, refreshing queue for station:", activeStation); + // console.log("Track changed, refreshing queue for station:", activeStation); fetchQueue(queuePage, queueRows, queueSearch); } }, [currentTrackUuid]); @@ -907,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" @@ -962,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%' }} @@ -982,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 >({}); 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,11 +1005,14 @@ 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"; - } + 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."); @@ -1301,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..7a494e3 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,6 +31,7 @@ interface RequestJob { tarball?: string; created_at?: string; updated_at?: string; + track_list?: TrackInfo[]; [key: string]: unknown; } @@ -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...

-- 2.47.3 From 8abb12d369893a63eb9c7aa93299f3c25e98ae91 Mon Sep 17 00:00:00 2001 From: codey Date: Tue, 3 Feb 2026 12:48:10 -0500 Subject: [PATCH 3/5] Remove requester field from SubmitRequestBody and adjust related validation logic in POST request --- src/components/req/ReqForm.tsx | 15 +++++++------- src/pages/api/submit.ts | 38 +++++++++++++++++----------------- 2 files changed, 27 insertions(+), 26 deletions(-) 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" /> -
+
*/}