Enhance AudioPlayer functionality with improved error handling, toast notifications, and refined queue management. Update typeahead options handling, adjust track metadata defaults, and optimize DJ controls layout. Introduce new methods for handling song requests and queue operations.

This commit is contained in:
2025-09-24 22:40:48 -04:00
parent 0e46db70eb
commit 6cdddc774a

View File

@@ -7,7 +7,8 @@ import { Dialog } from "primereact/dialog";
import { AutoComplete } from "primereact/autocomplete";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import { toast } from "react-toastify";
import { ENVIRONMENT } from "../config";
import { API_URL } from "@/config";
import { authFetch } from "@/utils/authFetch";
import { requireAuthHook } from "@/hooks/requireAuthHook";
@@ -65,8 +66,9 @@ export default function Player({ user }) {
});
const data = await response.json();
console.debug('Typeahead: response data', data);
setTypeaheadOptions(data.options || []);
console.debug('Typeahead: setTypeaheadOptions', data.options || []);
// Accept bare array or { options: [...] }
setTypeaheadOptions(Array.isArray(data) ? data : data.options || []);
console.debug('Typeahead: setTypeaheadOptions', Array.isArray(data) ? data : data.options || []);
} catch (error) {
console.error('Typeahead: error', error);
setTypeaheadOptions([]);
@@ -74,9 +76,9 @@ export default function Player({ user }) {
};
console.debug('AudioPlayer user:', user);
if (user) {
console.debug('isAuthenticated:', user.isAuthenticated);
console.debug('isAuthenticated:', user);
console.debug('roles:', user.roles);
console.debug('isDJ:', user.isAuthenticated && Array.isArray(user.roles) && user.roles.includes('dj'));
console.debug('isDJ:', user && Array.isArray(user.roles) && user.roles.includes('dj'));
}
const theme = useHtmlThemeAttr();
@@ -219,6 +221,7 @@ export default function Player({ user }) {
// ...existing code...
// ...existing code...
@@ -274,8 +277,8 @@ export default function Player({ user }) {
// If station changed while request was in-flight, ignore the response
if (requestStation !== activeStationRef.current) return;
if (trackData.artist === 'N/A') {
setTrackTitle('Offline');
if (!trackData.song || trackData.song === 'N/A') {
setTrackTitle('No track playing');
setLyrics([]);
return;
}
@@ -285,6 +288,9 @@ export default function Player({ user }) {
setTrackMetadata(trackData, requestStation);
// Refresh queue when track changes
fetchQueue();
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -317,16 +323,16 @@ export default function Player({ user }) {
}
} catch (error) {
console.error('Error fetching metadata:', error);
setTrackTitle('Offline');
setTrackTitle('Error fetching track');
setLyrics([]);
}
}, [activeStation]);
const setTrackMetadata = useCallback((trackData, requestStation) => {
setTrackTitle(trackData.song);
setTrackArtist(trackData.artist);
setTrackTitle(trackData.song || 'Unknown Title');
setTrackArtist(trackData.artist || 'Unknown Artist');
setTrackGenre(trackData.genre || '');
setTrackAlbum(trackData.album);
setTrackAlbum(trackData.album || 'Unknown Album');
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
baseTrackElapsed.current = trackData.elapsed;
@@ -358,19 +364,26 @@ export default function Player({ user }) {
// ...existing code...
const handleSkip = async (skipTo = null) => {
const handleSkip = async (uuid = null) => {
try {
const response = await authFetch(`${API_URL}/radio/skip`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ station: activeStation, skipTo }),
body: JSON.stringify({
skipTo: uuid,
station: activeStation,
}),
});
const data = await response.json();
if (!data.success) {
console.error("Failed to skip track.", data);
if (data.success) {
toast.success("OK!");
fetchQueue();
} else {
toast.error("Skip failed.");
}
} catch (error) {
console.error("Error skipping track:", error);
toast.error("Skip failed.");
}
};
@@ -379,62 +392,127 @@ export default function Player({ user }) {
const response = await authFetch(`${API_URL}/radio/reshuffle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ station: activeStation }),
body: JSON.stringify({
station: activeStation,
}),
});
const data = await response.json();
if (!data.ok) {
console.error("Failed to reshuffle queue.", data);
if (data.ok) {
toast.success("OK!");
fetchQueue();
} else {
toast.error("Reshuffle failed.");
}
} catch (error) {
console.error("Error reshuffling queue:", error);
toast.error("Reshuffle failed.");
}
};
const handleQueueShift = async (uuid, playNow = false) => {
const handleQueueShift = async (uuid, next = false) => {
try {
const response = await authFetch(`${API_URL}/radio/queue_shift`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ station: activeStation, uuid, playNow }),
body: JSON.stringify({
uuid,
next,
station: activeStation,
}),
});
const data = await response.json();
if (!data.ok) {
console.error("Failed to shift queue.", data);
if (!data.err) {
toast.success("OK!");
fetchQueue();
} else {
toast.error("Queue shift failed.");
}
} catch (error) {
console.error("Error shifting queue:", error);
toast.error("Queue shift failed.");
}
};
const handleQueueRemove = async (uuid) => {
try {
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ station: activeStation, uuid }),
});
const data = await response.json();
if (!data.ok) {
console.error("Failed to remove from queue.", data);
}
} catch (error) {
console.error("Error removing from queue:", error);
const handlePlayNow = async (artistSong, next = false) => {
const toastId = "playNowToast"; // Unique ID for this toast
if (!toast.isActive(toastId)) {
toast.info("Trying...", { toastId });
}
};
const handleSongRequest = async (artist, song) => {
try {
const response = await authFetch(`${API_URL}/radio/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ station: activeStation, artist, song }),
body: JSON.stringify({
artistsong: artistSong,
alsoSkip: !next,
station: activeStation,
}),
});
const data = await response.json();
if (!data.result) {
console.error("Failed to request song.", data);
if (data.result) {
setRequestInput(""); // Clear the input immediately
toast.update(toastId, { render: "OK!", type: "success", autoClose: 2000 });
fetchQueue();
} else {
toast.update(toastId, { render: "Play Now failed.", type: "error", autoClose: 3000 });
}
} catch (error) {
console.error("Error playing song immediately:", error);
toast.update(toastId, { render: "Play Now failed.", type: "error", autoClose: 3000 });
}
};
const handleSongRequest = async (artistSong) => {
const toastId = "songRequestToast"; // Unique ID for this toast
if (!toast.isActive(toastId)) {
toast.info("Trying...", { toastId });
}
try {
const response = await authFetch(`${API_URL}/radio/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
artistsong: artistSong,
alsoSkip: false, // Ensure alsoSkip is false for requests
station: activeStation,
}),
});
const data = await response.json();
if (data.result) {
setRequestInput(""); // Clear the input immediately
toast.update(toastId, { render: "OK!", type: "success", autoClose: 2000 });
fetchQueue();
} else {
toast.update(toastId, { render: "Song request failed.", type: "error", autoClose: 3000 });
}
} catch (error) {
console.error("Error requesting song:", error);
toast.update(toastId, { render: "Song request failed.", type: "error", autoClose: 3000 });
}
};
const handleRemoveFromQueue = async (uuid) => {
console.debug("handleRemoveFromQueue called with uuid:", uuid); // Debugging log
try {
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
uuid,
station: activeStation,
}),
});
const data = await response.json();
console.debug("handleRemoveFromQueue response:", data); // Debugging log
if (!data.err) {
toast.success("OK!");
fetchQueue();
} else {
toast.error("Remove from queue failed.");
}
} catch (error) {
console.error("Error removing from queue:", error);
toast.error("Remove from queue failed.");
}
};
@@ -454,7 +532,12 @@ export default function Player({ user }) {
});
const data = await response.json();
setQueueData(data.items || []);
setQueueTotalRecords(data.total || data.totalRecords || 0);
// Use recordsFiltered for search, otherwise recordsTotal
setQueueTotalRecords(
typeof search === 'string' && search.length > 0
? data.recordsFiltered ?? data.recordsTotal ?? 0
: data.recordsTotal ?? 0
);
} catch (error) {
console.error("Error fetching queue:", error);
}
@@ -467,7 +550,7 @@ export default function Player({ user }) {
}, [isQueueVisible, queuePage, queueRows, queueSearch]);
// Always define queueFooter, fallback to Close button if user is not available
const isDJ = user && Array.isArray(user.roles) && user.roles.includes('dj');
const isDJ = (user && user.roles.includes('dj')) || ENVIRONMENT === "Dev";
const queueFooter = isDJ
? (
<div className="flex gap-2 justify-end">
@@ -514,7 +597,7 @@ export default function Player({ user }) {
</div>
<div className="c-container">
{user && <span className="text-lg font-semibold">Hello, {user.user}</span>}
{/* {user && <span className="text-lg font-semibold">Hello, {user.user}</span>} */}
<div className="music-container mt-8">
<section className="album-cover">
@@ -554,69 +637,74 @@ export default function Player({ user }) {
</div>
{isDJ && (
<div className="dj-controls mt-6 flex gap-1 justify-center items-center flex-wrap">
<AutoComplete
value={requestInput}
suggestions={typeaheadOptions}
completeMethod={(e) => {
console.debug('AutoComplete: completeMethod called', e);
fetchTypeahead(e.query);
}}
onChange={e => {
console.debug('AutoComplete: onChange', e);
setRequestInput(e.target.value);
}}
onShow={() => {
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const items = panel?.querySelector('.p-autocomplete-items');
console.debug('AutoComplete: onShow panel', panel);
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);
}}
placeholder="Request a song..."
inputStyle={{
width: '14rem',
padding: '0.25rem 0.5rem',
borderRadius: '9999px',
border: '1px solid #d1d5db',
fontSize: '0.85rem',
fontWeight: 'bold',
}}
className="typeahead-input"
forceSelection={false}
/>
<button className="px-3 py-1 rounded-full bg-blue-400 text-white hover:bg-blue-500 text-xs font-bold"
onClick={() => handleSongRequest(requestInputArtist, requestInputSong)}>
Request
</button>
<button className="px-3 py-1 rounded-full bg-green-400 text-white hover:bg-green-500 text-xs font-bold"
onClick={() => handleQueueShift(requestInputUuid, true)}>
Play Now
</button>
<button className="px-3 py-1 rounded-full bg-red-400 text-white hover:bg-red-500 text-xs font-bold" onClick={() => handleSkip()}>Skip</button>
<button
className="px-3 py-1 rounded-full bg-gray-400 text-white hover:bg-gray-500 text-xs font-bold"
onClick={() => setQueueVisible(true)}
>
Queue
</button>
<div className="dj-controls mt-6 flex flex-wrap justify-center items-center gap-2" style={{ minWidth: '24rem', maxWidth: '100%', alignItems: 'flex-start' }}>
<div className="flex flex-col items-center gap-2 w-full" style={{ minWidth: '24rem' }}>
<AutoComplete
value={requestInput}
suggestions={typeaheadOptions}
completeMethod={(e) => {
console.debug('AutoComplete: completeMethod called', e);
fetchTypeahead(e.query);
}}
onChange={e => {
console.debug('AutoComplete: onChange', e);
setRequestInput(e.target.value);
}}
onShow={() => {
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const items = panel?.querySelector('.p-autocomplete-items');
console.debug('AutoComplete: onShow panel', panel);
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);
}}
placeholder="Request a song..."
inputStyle={{
width: '24rem',
minWidth: '24rem',
padding: '0.35rem 0.75rem',
borderRadius: '9999px',
border: '1px solid #d1d5db',
fontSize: '1rem',
fontWeight: 'bold',
}}
className="typeahead-input"
forceSelection={false}
/>
<div className="flex flex-row flex-wrap justify-center gap-2 items-center" style={{ minWidth: '24rem' }}>
<button className="px-3 py-1 rounded-full bg-blue-400 text-white hover:bg-blue-500 text-xs font-bold"
onClick={() => handleSongRequest(requestInput)}>
Request
</button>
<button className="px-3 py-1 rounded-full bg-green-400 text-white hover:bg-green-500 text-xs font-bold"
onClick={() => handlePlayNow(requestInput)}>
Play Now
</button>
<button className="px-3 py-1 rounded-full bg-red-400 text-white hover:bg-red-500 text-xs font-bold" onClick={() => handleSkip()}>Skip</button>
<button
className="px-3 py-1 rounded-full bg-gray-400 text-white hover:bg-gray-500 text-xs font-bold"
onClick={() => setQueueVisible(true)}
>
Queue
</button>
</div>
</div>
</div>
)}
{/* Always show play/pause button */}
@@ -646,12 +734,13 @@ export default function Player({ user }) {
<audio ref={audioElement} preload="none" />
<Dialog
header="Queue"
header={`Queue - ${activeStation}`}
visible={isQueueVisible}
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
footer={queueFooter}
onHide={() => setQueueVisible(false)}
className={theme === "dark" ? "dark-theme" : "light-theme"}
dismissableMask={true}
>
<div style={{ maxHeight: "calc(90vh - 100px)", overflow: "visible" }}>
<div className="mb-2 flex justify-end">
@@ -726,21 +815,24 @@ export default function Player({ user }) {
</button>
<button
className="px-2 py-1 rounded bg-yellow-400 text-neutral-900 hover:bg-yellow-500 text-xs shadow-md cursor-pointer font-bold font-sans"
onClick={() => handleQueueShift(rowData.uuid, true)}
onClick={() => handleQueueShift(rowData.uuid, true, false)}
title="Play"
>
Play
</button>
<button
className="px-2 py-1 rounded bg-green-400 text-neutral-900 hover:bg-green-500 text-xs shadow-md cursor-pointer font-bold font-sans"
onClick={() => handleQueueShift(rowData.uuid)}
onClick={() => handleQueueShift(rowData.uuid, false, true)}
title="Play Next"
>
Play Next
</button>
<button
className="px-2 py-1 rounded bg-red-400 text-neutral-900 hover:bg-red-500 text-xs shadow-md cursor-pointer font-bold font-sans"
onClick={() => handleQueueRemove(rowData.uuid)}
onClick={() => {
console.debug("Remove button clicked for uuid:", rowData.uuid); // Debugging log
handleRemoveFromQueue(rowData.uuid);
}}
title="Remove"
>
Remove