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:
@@ -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">
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
{randomMsg && (
|
||||
<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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +633,13 @@ export default function MediaRequestForm() {
|
||||
|
||||
{type === "artist" && albums.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<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"
|
||||
@@ -565,7 +651,7 @@ export default function MediaRequestForm() {
|
||||
>
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
81
src/components/TRip/RequestManagement.css
Normal file
81
src/components/TRip/RequestManagement.css
Normal 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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const requireAuthHook = async (Astro) => {
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
console.error("Token refresh failed", refreshRes.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,9 @@ export const requireAuthHook = async (Astro) => {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user