From f17731523169d82964445ffda1762019e63d7011 Mon Sep 17 00:00:00 2001 From: codey Date: Mon, 22 Sep 2025 11:15:24 -0400 Subject: [PATCH] Enhance authentication flow with improved error handling and logging in requireAuthHook. Refine HLS stream initialization and metadata fetching in AudioPlayer to handle station changes gracefully. Improve toast notifications and autocomplete behavior in LyricSearch. Simplify RandomMsg logic and remove unused imports. Add track and album count display in MediaRequestForm and enhance artist selection. Introduce dark mode styles for tables and dialogs in RequestManagement.css. Adjust imports and ensure proper usage of requireAuthHook in index.astro and requests.astro. --- src/components/AudioPlayer.jsx | 28 +++-- src/components/LyricSearch.jsx | 17 ++- src/components/RandomMsg.jsx | 62 +++++++---- src/components/TRip/MediaRequestForm.jsx | 127 +++++++++++++++++++--- src/components/TRip/RequestManagement.css | 81 ++++++++++++++ src/hooks/requireAuthHook.js | 8 +- src/pages/TRip/index.astro | 1 - src/pages/TRip/requests.astro | 3 - 8 files changed, 273 insertions(+), 54 deletions(-) create mode 100644 src/components/TRip/RequestManagement.css diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx index 81c48c3..ad3d3c9 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.jsx @@ -34,6 +34,7 @@ export default function Player({ user }) { const currentTrackUuid = useRef(null); const baseTrackElapsed = useRef(0); const lastUpdateTimestamp = useRef(Date.now()); + const activeStationRef = useRef(activeStation); const formatTime = (seconds) => { const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); @@ -85,9 +86,14 @@ export default function Player({ user }) { const hls = new Hls({ lowLatencyMode: true, abrEnabled: false, - liveSyncDuration: 1.0, // seconds behind live edge target - liveMaxLatencyDuration: 2.0, // max allowed latency before catchup - liveCatchUpPlaybackRate: 1.05, // playback speed when catching up + liveSyncDuration: 0.5, // seconds behind live edge target + liveMaxLatencyDuration: 3.0, // max allowed latency before catchup + liveCatchUpPlaybackRate: 1.02, + maxBufferLength: 30, + maxMaxBufferLength: 60, + maxBufferHole: 0.5, + manifestLoadingTimeOut: 4000, + fragLoadingTimeOut: 10000, // playback speed when catching up }); hlsInstance.current = hls; @@ -176,10 +182,15 @@ export default function Player({ user }) { // Fetch metadata and lyrics periodically useEffect(() => { const fetchMetadataAndLyrics = async () => { + // capture the station id at request time; ignore results if station changed + const requestStation = activeStationRef.current; try { - const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' }); + const response = await fetch(`${API_URL}/radio/np?station=${requestStation}`, { method: 'POST' }); const trackData = await response.json(); + // If station changed while request was in-flight, ignore the response + if (requestStation !== activeStationRef.current) return; + if (trackData.artist === 'N/A') { setTrackTitle('Offline'); setLyrics([]); @@ -193,7 +204,7 @@ export default function Player({ user }) { setTrackArtist(trackData.artist); setTrackGenre(trackData.genre || ''); setTrackAlbum(trackData.album); - setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`); + setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`); baseTrackElapsed.current = trackData.elapsed; lastUpdateTimestamp.current = Date.now(); @@ -205,7 +216,7 @@ export default function Player({ user }) { setCurrentLyricIndex(0); setPageTitle(trackData.artist, trackData.song); - // Fetch lyrics as before + // Fetch lyrics as before, but guard against station switches const lyricsResponse = await fetch(`${API_URL}/lyric/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -219,6 +230,7 @@ export default function Player({ user }) { }), }); const lyricsData = await lyricsResponse.json(); + if (requestStation !== activeStationRef.current) return; if (!lyricsData.err && Array.isArray(lyricsData.lrc)) { const parsedLyrics = lyricsData.lrc.map(({ timeTag, words }) => { const [mins, rest] = timeTag.split(':'); @@ -238,6 +250,8 @@ export default function Player({ user }) { setLyrics([]); } }; + // ensure the ref points to the current activeStation for in-flight guards + activeStationRef.current = activeStation; fetchMetadataAndLyrics(); const metadataInterval = setInterval(fetchMetadataAndLyrics, 700); return () => clearInterval(metadataInterval); @@ -272,7 +286,7 @@ export default function Player({ user }) { ))}
- {user ? Hello, {user.user} : Not logged in} + {user && Hello, {user.user}} < div className="music-container mt-8">
diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.jsx index e03be9a..ccad666 100644 --- a/src/components/LyricSearch.jsx +++ b/src/components/LyricSearch.jsx @@ -47,6 +47,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) { const [isLoading, setIsLoading] = useState(false); const [excludedSources, setExcludedSources] = useState([]); const [lyricsResult, setLyricsResult] = useState(null); + const searchToastRef = useRef(null); const autoCompleteRef = useRef(null); const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light"); @@ -137,7 +138,11 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) { setLyricsResult(null); setShowLyrics(false); - const toastId = toast.info("Searching..."); + if (!toast.isActive('lyrics-searching-toast')) { + searchToastRef.current = toast.info("Searching...", { + toastId: "lyrics-searching-toast" + }); + } const startTime = Date.now(); @@ -163,14 +168,14 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) { setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics }); setShowLyrics(true); - toast.update(toastId, { + toast.update(searchToastRef.current, { type: "success", render: `Found! (Took ${duration}s)`, className: "Toastify__toast--success", autoClose: 2500, }); } catch (error) { - toast.update(toastId, { + toast.update(searchToastRef.current, { type: "error", render: `😕 ${error.message}`, autoClose: 5000, @@ -262,7 +267,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) { if (checkedCount === 3) { checkboxes.forEach(cb => cb.click()); - toast.error("All sources were excluded; exclusions have been reset."); + if (!toast.isActive("lyrics-exclusion-reset-toast")) { + toast.error("All sources were excluded; exclusions have been reset.", + { toastId: "lyrics-exclusion-reset-toast" } + ); + } } }; diff --git a/src/components/RandomMsg.jsx b/src/components/RandomMsg.jsx index c5d2b64..9f2c241 100644 --- a/src/components/RandomMsg.jsx +++ b/src/components/RandomMsg.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { API_URL } from "../config"; -import ReplayIcon from "@mui/icons-material/Replay"; export default function RandomMsg() { const [randomMsg, setRandomMsg] = useState(""); @@ -9,24 +8,17 @@ export default function RandomMsg() { try { const response = await fetch(`${API_URL}/randmsg`, { method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, + headers: { "Content-Type": "application/json; charset=utf-8" }, }); - + if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); - if (data?.msg) { - const formattedMsg = data.msg.replace(//gi, "\n"); - setRandomMsg(formattedMsg); - } + if (data?.msg) setRandomMsg(data.msg.replace(//gi, "\n")); } catch (err) { console.error("Failed to fetch random message:", err); } }; - useEffect(() => { - getRandomMsg(); - }, []); + useEffect(() => void getRandomMsg(), []); return (
@@ -37,15 +29,43 @@ export default function RandomMsg() { )}
- + {randomMsg && ( + + )} +
); } diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx index 24a252e..3d91f2f 100644 --- a/src/components/TRip/MediaRequestForm.jsx +++ b/src/components/TRip/MediaRequestForm.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, Suspense, lazy } from "react"; +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"; @@ -62,7 +62,7 @@ export default function MediaRequestForm() { searchArtists.lastCall = Date.now(); const res = await authFetch( - `${API_URL}/trip/get_artists_by_name?artist=${encodeURIComponent(query)}` + `${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(); @@ -82,9 +82,89 @@ export default function MediaRequestForm() { ? 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) { + console.debug('selectArtist: overlay hide fallback failed', innerErr); + } + }, 0); + } catch (err) { + console.debug('selectArtist: unable to schedule overlay hide', err); + } + }; + + 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; - return
{truncate(artist.artist, 58)}
; + + 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} + + ))} +
+
+ )} +
+ ); }; @@ -553,19 +633,25 @@ export default function MediaRequestForm() { {type === "artist" && albums.length > 0 && ( <> -
- { - e.preventDefault(); // prevent page jump - handleToggleAllAlbums(); - }} - className="text-sm text-blue-600 hover:underline cursor-pointer" - > - Check / Uncheck All Albums - - +
+
+ Albums: {totalAlbums} + | + Tracks: {totalTracks} +
+
+
} ({release_date}) + + {typeof tracksByAlbum[id] === 'undefined' ? ( + loadingAlbumId === id ? 'Loading...' : '...' + ) : ( + `${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}` + )} +
} diff --git a/src/components/TRip/RequestManagement.css b/src/components/TRip/RequestManagement.css new file mode 100644 index 0000000..39e3dc2 --- /dev/null +++ b/src/components/TRip/RequestManagement.css @@ -0,0 +1,81 @@ +/* Table and Dark Overrides */ +.p-datatable { + table-layout: fixed !important; +} +.p-datatable td span.truncate { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Dark Mode for Table */ +[data-theme="dark"] .p-datatable { + background-color: #121212 !important; + color: #e5e7eb !important; +} +[data-theme="dark"] .p-datatable-thead > tr > th { + background-color: #1f1f1f !important; + color: #e5e7eb !important; + border-bottom: 1px solid #374151; +} +[data-theme="dark"] .p-datatable-tbody > tr { + background-color: #1a1a1a !important; + border-bottom: 1px solid #374151; + color: #e5e7eb !important; +} +[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) { + background-color: #222 !important; +} +[data-theme="dark"] .p-datatable-tbody > tr:hover { + background-color: #333 !important; + color: #fff !important; +} + +/* Paginator Dark Mode */ +[data-theme="dark"] .p-paginator { + background-color: #121212 !important; + color: #e5e7eb !important; + border-top: 1px solid #374151 !important; +} +[data-theme="dark"] .p-paginator .p-paginator-page, +[data-theme="dark"] .p-paginator .p-paginator-next, +[data-theme="dark"] .p-paginator .p-paginator-prev, +[data-theme="dark"] .p-paginator .p-paginator-first, +[data-theme="dark"] .p-paginator .p-paginator-last { + color: #e5e7eb !important; + background: transparent !important; + border: none !important; +} +[data-theme="dark"] .p-paginator .p-paginator-page:hover, +[data-theme="dark"] .p-paginator .p-paginator-next:hover, +[data-theme="dark"] .p-paginator .p-paginator-prev:hover { + background-color: #374151 !important; + color: #fff !important; + border-radius: 0.25rem; +} +[data-theme="dark"] .p-paginator .p-highlight { + background-color: #6b7280 !important; + color: #fff !important; + border-radius: 0.25rem !important; +} + +/* Dark Mode for PrimeReact Dialog */ +[data-theme="dark"] .p-dialog { + background-color: #1a1a1a !important; + color: #e5e7eb !important; + border-color: #374151 !important; +} +[data-theme="dark"] .p-dialog .p-dialog-header { + background-color: #121212 !important; + color: #e5e7eb !important; + border-bottom: 1px solid #374151 !important; +} +[data-theme="dark"] .p-dialog .p-dialog-content { + background-color: #1a1a1a !important; + color: #e5e7eb !important; +} +[data-theme="dark"] .p-dialog .p-dialog-footer { + background-color: #121212 !important; + border-top: 1px solid #374151 !important; +} diff --git a/src/hooks/requireAuthHook.js b/src/hooks/requireAuthHook.js index 1c844e2..44e8daa 100644 --- a/src/hooks/requireAuthHook.js +++ b/src/hooks/requireAuthHook.js @@ -17,6 +17,7 @@ export const requireAuthHook = async (Astro) => { }); if (!refreshRes.ok) { + console.error("Token refresh failed", refreshRes.status); return null; } @@ -24,10 +25,13 @@ export const requireAuthHook = async (Astro) => { let newCookieHeader = cookieHeader; if (setCookieHeader) { - const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/); + const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/); cookiesArray.forEach((c) => Astro.response.headers.append("set-cookie", c)); newCookieHeader = cookiesArray.map(c => c.split(";")[0]).join("; "); + } else { + console.error("No set-cookie header found in refresh response"); + return null; } res = await fetch(`${API_URL}/auth/id`, { @@ -37,8 +41,10 @@ export const requireAuthHook = async (Astro) => { } if (!res.ok) { + console.error("Failed to fetch user ID after token refresh", res.status); return null; } + const user = await res.json(); return user; diff --git a/src/pages/TRip/index.astro b/src/pages/TRip/index.astro index 6b822db..2edf82d 100644 --- a/src/pages/TRip/index.astro +++ b/src/pages/TRip/index.astro @@ -1,5 +1,4 @@ --- -import MediaRequestForm from "@/components/TRip/MediaRequestForm" import Base from "@/layouts/Base.astro"; import Root from "@/components/AppLayout.jsx"; import { requireAuthHook } from "@/hooks/requireAuthHook"; diff --git a/src/pages/TRip/requests.astro b/src/pages/TRip/requests.astro index efdc0cd..d3dc836 100644 --- a/src/pages/TRip/requests.astro +++ b/src/pages/TRip/requests.astro @@ -1,10 +1,7 @@ --- -import MediaRequestForm from "@/components/TRip/MediaRequestForm" import Base from "@/layouts/Base.astro"; import Root from "@/components/AppLayout.jsx"; -import { verifyToken } from "@/utils/jwt"; import { requireAuthHook } from "@/hooks/requireAuthHook"; -import { ENVIRONMENT } from "@/config"; const user = await requireAuthHook(Astro);