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.

This commit is contained in:
2025-09-22 11:15:24 -04:00
parent 3afc944a67
commit f177315231
8 changed files with 273 additions and 54 deletions

View File

@@ -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 }) {
))}
</div>
<div className="c-containter">
{user ? <span>Hello, {user.user}</span> : <span>Not logged in</span>}
{user && <span>Hello, {user.user}</span>}
< div className="music-container mt-8">
<section className="album-cover">
<div className="music-player__album" title="Album">

View File

@@ -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" }
);
}
}
};

View File

@@ -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(/<br\s*\/?>/gi, "\n");
setRandomMsg(formattedMsg);
}
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?>/gi, "\n"));
} catch (err) {
console.error("Failed to fetch random message:", err);
}
};
useEffect(() => {
getRandomMsg();
}, []);
useEffect(() => void getRandomMsg(), []);
return (
<div className="random-msg-container">
@@ -37,15 +29,43 @@ export default function RandomMsg() {
</small>
)}
</div>
<button
id="new-msg"
aria-label="New footer message"
type="button"
className="flex items-center justify-center px-2 py-1 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-opacity new-msg-button"
onClick={getRandomMsg}
>
<ReplayIcon fontSize="small" />
</button>
{randomMsg && (
<button
aria-label="New footer message"
type="button"
className="flex items-center justify-center px-2 py-1 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-opacity new-msg-button"
onClick={getRandomMsg}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
aria-hidden="true"
focusable="false"
className="inline-block"
>
<path
d="M21 12a9 9 0 1 1-2.64-6.36"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="21 3 21 9 15 9"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
);
}

View File

@@ -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 <div>{truncate(artist.artist, 58)}</div>;
const alts = artist.alternatives || artist.alternates || [];
return (
<div className="py-1">
<div className="font-medium flex items-baseline gap-2">
<span>{truncate(artist.artist || artist.name, 58)}</span>
<span className="text-xs text-neutral-400">ID: {artist.id}</span>
</div>
{alts.length > 0 && (
<div className="text-xs text-neutral-500 mt-1">
<div className="flex flex-wrap gap-2 items-center">
<span className="mr-1">Alternates:</span>
{alts.map((alt, idx) => (
<span key={alt.id || idx} className="inline-flex items-center max-w-[200px] truncate">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
selectArtist({ ...alt, artist: alt.artist || alt.name });
}}
className="ml-1 mr-1 underline hover:text-blue-600 bg-transparent border-none p-0 cursor-pointer"
>
{truncate(alt.artist || alt.name, 26)}
</button>
<span className="text-xs text-neutral-400">ID: {alt.id}</span>
{idx < alts.length - 1 ? <span className="mx-1">,</span> : null}
</span>
))}
</div>
</div>
)}
</div>
);
};
@@ -553,19 +633,25 @@ export default function MediaRequestForm() {
{type === "artist" && albums.length > 0 && (
<>
<div className="flex justify-end mb-2">
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault(); // prevent page jump
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
>
Check / Uncheck All Albums
</a>
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<strong className="mr-2">Albums:</strong> {totalAlbums}
<span className="mx-3">|</span>
<strong className="mr-2">Tracks:</strong> {totalTracks}
</div>
<div>
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault(); // prevent page jump
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
>
Check / Uncheck All Albums
</a>
</div>
</div>
<Accordion
multiple
@@ -588,7 +674,7 @@ export default function MediaRequestForm() {
<AccordionTab
key={id}
header={
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full">
<input
type="checkbox"
checked={allChecked}
@@ -605,6 +691,13 @@ export default function MediaRequestForm() {
{loadingAlbumId === id && <Spinner />}
</span>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
<span className="ml-auto text-xs text-neutral-500">
{typeof tracksByAlbum[id] === 'undefined' ? (
loadingAlbumId === id ? 'Loading...' : '...'
) : (
`${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}`
)}
</span>
</div>
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);