From 1da33de8923793a4db4f9db3338093efe92c921f Mon Sep 17 00:00:00 2001 From: codey Date: Sun, 25 Jan 2026 13:11:25 -0500 Subject: [PATCH] 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...