From 256d5d9c7f2dca89d523b06dec156854607299f0 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 24 Dec 2025 09:55:08 -0500 Subject: [PATCH] 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.");