Refactor AudioPlayer and LyricSearch components/ AudioPlayer: add DJ controls (porting from old site), and optimize theme handling.
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
|
||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
import Play from "@mui/icons-material/PlayArrow";
|
import Play from "@mui/icons-material/PlayArrow";
|
||||||
import Pause from "@mui/icons-material/Pause";
|
import Pause from "@mui/icons-material/Pause";
|
||||||
import "@styles/player.css";
|
import "@styles/player.css";
|
||||||
|
import { Dialog } from "primereact/dialog";
|
||||||
|
import { AutoComplete } from "primereact/autocomplete";
|
||||||
|
import { DataTable } from "primereact/datatable";
|
||||||
|
import { Column } from "primereact/column";
|
||||||
|
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
|
import { authFetch } from "@/utils/authFetch";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||||
|
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
|
||||||
|
import "@/components/TRip/RequestManagement.css";
|
||||||
|
|
||||||
const STATIONS = {
|
const STATIONS = {
|
||||||
main: { label: "Main" },
|
main: { label: "Main" },
|
||||||
@@ -17,17 +24,99 @@ const STATIONS = {
|
|||||||
|
|
||||||
|
|
||||||
export default function Player({ user }) {
|
export default function Player({ user }) {
|
||||||
|
const [isQueueVisible, setQueueVisible] = useState(false);
|
||||||
|
// Mouse wheel scroll fix for queue modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isQueueVisible) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
const modalContent = document.querySelector('.p-dialog .p-dialog-content > div');
|
||||||
|
if (modalContent) {
|
||||||
|
modalContent.style.overflowY = 'auto';
|
||||||
|
modalContent.style.overscrollBehavior = 'contain';
|
||||||
|
const wheelHandler = (e) => {
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const atTop = modalContent.scrollTop === 0;
|
||||||
|
const atBottom = modalContent.scrollTop + modalContent.clientHeight >= modalContent.scrollHeight;
|
||||||
|
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modalContent.removeEventListener('wheel', wheelHandler);
|
||||||
|
modalContent.addEventListener('wheel', wheelHandler, { passive: false });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [isQueueVisible]);
|
||||||
|
// Debugging output
|
||||||
|
// Autocomplete for requests
|
||||||
|
const fetchTypeahead = async (query) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setTypeaheadOptions([]);
|
||||||
|
console.debug('Typeahead: query too short', query);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.debug('Typeahead: fetching for query', query);
|
||||||
|
const response = await authFetch(`${API_URL}/radio/typeahead`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ station: activeStation, query }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.debug('Typeahead: response data', data);
|
||||||
|
setTypeaheadOptions(data.options || []);
|
||||||
|
console.debug('Typeahead: setTypeaheadOptions', data.options || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Typeahead: error', error);
|
||||||
|
setTypeaheadOptions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.debug('AudioPlayer user:', user);
|
||||||
|
if (user) {
|
||||||
|
console.debug('isAuthenticated:', user.isAuthenticated);
|
||||||
|
console.debug('roles:', user.roles);
|
||||||
|
console.debug('isDJ:', user.isAuthenticated && Array.isArray(user.roles) && user.roles.includes('dj'));
|
||||||
|
}
|
||||||
|
const theme = useHtmlThemeAttr();
|
||||||
|
|
||||||
const [activeStation, setActiveStation] = useState("main");
|
const [activeStation, setActiveStation] = useState("main");
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [trackTitle, setTrackTitle] = useState("");
|
const [trackTitle, setTrackTitle] = useState("");
|
||||||
const [trackArtist, setTrackArtist] = useState("");
|
const [trackArtist, setTrackArtist] = useState("");
|
||||||
|
// ...existing code...
|
||||||
const [trackGenre, setTrackGenre] = useState("");
|
const [trackGenre, setTrackGenre] = useState("");
|
||||||
const [trackAlbum, setTrackAlbum] = useState("");
|
const [trackAlbum, setTrackAlbum] = useState("");
|
||||||
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
||||||
const [lyrics, setLyrics] = useState([]);
|
const [lyrics, setLyrics] = useState([]);
|
||||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
// Update currentLyricIndex as song progresses
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lyrics || lyrics.length === 0) return;
|
||||||
|
// Find the last lyric whose timestamp is <= elapsedTime
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = 0; i < lyrics.length; i++) {
|
||||||
|
if (lyrics[i].timestamp <= elapsedTime) {
|
||||||
|
idx = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrentLyricIndex(idx);
|
||||||
|
}, [elapsedTime, lyrics]);
|
||||||
const [trackDuration, setTrackDuration] = useState(0);
|
const [trackDuration, setTrackDuration] = useState(0);
|
||||||
|
const [queueData, setQueueData] = useState([]);
|
||||||
|
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
||||||
|
const [queuePage, setQueuePage] = useState(0);
|
||||||
|
const [queueRows, setQueueRows] = useState(10);
|
||||||
|
|
||||||
|
// DJ controls state
|
||||||
|
const [requestInput, setRequestInput] = useState("");
|
||||||
|
const [typeaheadOptions, setTypeaheadOptions] = useState([]);
|
||||||
|
const [requestInputArtist, setRequestInputArtist] = useState("");
|
||||||
|
const [requestInputSong, setRequestInputSong] = useState("");
|
||||||
|
const [requestInputUuid, setRequestInputUuid] = useState("");
|
||||||
|
|
||||||
const audioElement = useRef(null);
|
const audioElement = useRef(null);
|
||||||
const hlsInstance = useRef(null);
|
const hlsInstance = useRef(null);
|
||||||
@@ -44,7 +133,7 @@ export default function Player({ user }) {
|
|||||||
|
|
||||||
// Set page title based on current track and station
|
// Set page title based on current track and station
|
||||||
const setPageTitle = (artist, song) => {
|
const setPageTitle = (artist, song) => {
|
||||||
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize or switch HLS stream
|
// Initialize or switch HLS stream
|
||||||
@@ -125,38 +214,33 @@ export default function Player({ user }) {
|
|||||||
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
||||||
setElapsedTime(liveElapsed);
|
setElapsedTime(liveElapsed);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [isPlaying, trackDuration]);
|
}, [isPlaying, trackDuration]);
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
// Use both base elapsed and audio time for precise lyric syncing
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPlaying || !lyrics.length) return;
|
|
||||||
|
|
||||||
const lyricsInterval = setInterval(() => {
|
|
||||||
let newIndex = 0;
|
|
||||||
for (let i = 0; i < lyrics.length; i++) {
|
|
||||||
if (elapsedTime >= lyrics[i].timestamp) newIndex = i;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
setCurrentLyricIndex(newIndex);
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
return () => clearInterval(lyricsInterval);
|
|
||||||
}, [isPlaying, lyrics, elapsedTime]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Scroll active lyric into view
|
// Scroll active lyric into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeElement = document.querySelector('.lrc-line.active');
|
setTimeout(() => {
|
||||||
if (activeElement) {
|
console.debug('Lyric scroll: currentLyricIndex', currentLyricIndex);
|
||||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
console.debug('Lyric scroll: lyrics', lyrics);
|
||||||
}
|
const activeElement = document.querySelector('.lrc-line.active');
|
||||||
}, [currentLyricIndex]);
|
const lyricsContainer = document.querySelector('.lrc-text');
|
||||||
|
console.debug('Lyric scroll: activeElement', activeElement);
|
||||||
|
console.debug('Lyric scroll: lyricsContainer', lyricsContainer);
|
||||||
|
if (activeElement && lyricsContainer) {
|
||||||
|
lyricsContainer.style.maxHeight = '220px';
|
||||||
|
lyricsContainer.style.overflowY = 'auto';
|
||||||
|
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
console.debug('Lyric scroll: scrolled into view');
|
||||||
|
} else {
|
||||||
|
console.debug('Lyric scroll: could not scroll, missing element(s)');
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [currentLyricIndex, lyrics]);
|
||||||
|
|
||||||
// Handle station changes: reset and start new stream
|
// Handle station changes: reset and start new stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,79 +261,83 @@ export default function Player({ user }) {
|
|||||||
lastUpdateTimestamp.current = Date.now();
|
lastUpdateTimestamp.current = Date.now();
|
||||||
|
|
||||||
initializeStream(activeStation);
|
initializeStream(activeStation);
|
||||||
|
// If no track is loaded, update page title to just station
|
||||||
|
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
// Fetch metadata and lyrics periodically
|
const fetchMetadataAndLyrics = useCallback(async () => {
|
||||||
useEffect(() => {
|
const requestStation = activeStationRef.current;
|
||||||
const fetchMetadataAndLyrics = async () => {
|
try {
|
||||||
// capture the station id at request time; ignore results if station changed
|
const response = await fetch(`${API_URL}/radio/np?station=${requestStation}`, { method: 'POST' });
|
||||||
const requestStation = activeStationRef.current;
|
const trackData = await response.json();
|
||||||
try {
|
|
||||||
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 station changed while request was in-flight, ignore the response
|
||||||
if (requestStation !== activeStationRef.current) return;
|
if (requestStation !== activeStationRef.current) return;
|
||||||
|
|
||||||
if (trackData.artist === 'N/A') {
|
if (trackData.artist === 'N/A') {
|
||||||
setTrackTitle('Offline');
|
|
||||||
setLyrics([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trackData.uuid !== currentTrackUuid.current) {
|
|
||||||
currentTrackUuid.current = trackData.uuid;
|
|
||||||
|
|
||||||
setTrackTitle(trackData.song);
|
|
||||||
setTrackArtist(trackData.artist);
|
|
||||||
setTrackGenre(trackData.genre || '');
|
|
||||||
setTrackAlbum(trackData.album);
|
|
||||||
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
|
|
||||||
|
|
||||||
baseTrackElapsed.current = trackData.elapsed;
|
|
||||||
lastUpdateTimestamp.current = Date.now();
|
|
||||||
setElapsedTime(trackData.elapsed);
|
|
||||||
|
|
||||||
setTrackDuration(trackData.duration);
|
|
||||||
|
|
||||||
setLyrics([]);
|
|
||||||
setCurrentLyricIndex(0);
|
|
||||||
setPageTitle(trackData.artist, trackData.song);
|
|
||||||
|
|
||||||
// Fetch lyrics as before, but guard against station switches
|
|
||||||
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
a: trackData.artist,
|
|
||||||
s: trackData.song,
|
|
||||||
excluded_sources: [],
|
|
||||||
extra: false,
|
|
||||||
lrc: true,
|
|
||||||
src: 'WEB-RADIO',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
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(':');
|
|
||||||
const secs = parseFloat(rest);
|
|
||||||
return { timestamp: Number(mins) * 60 + secs, line: words };
|
|
||||||
});
|
|
||||||
setLyrics(parsedLyrics);
|
|
||||||
setCurrentLyricIndex(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Same track - do not update elapsed or elapsed base here
|
|
||||||
setTrackDuration(trackData.duration);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching metadata:', error);
|
|
||||||
setTrackTitle('Offline');
|
setTrackTitle('Offline');
|
||||||
setLyrics([]);
|
setLyrics([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (trackData.uuid !== currentTrackUuid.current) {
|
||||||
|
currentTrackUuid.current = trackData.uuid;
|
||||||
|
|
||||||
|
setTrackMetadata(trackData, requestStation);
|
||||||
|
|
||||||
|
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
a: trackData.artist,
|
||||||
|
s: trackData.song,
|
||||||
|
excluded_sources: [],
|
||||||
|
extra: false,
|
||||||
|
lrc: true,
|
||||||
|
src: 'WEB-RADIO',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const lyricsData = await lyricsResponse.json();
|
||||||
|
|
||||||
|
// Guard against station or track changes during lyrics fetch
|
||||||
|
if (requestStation !== activeStationRef.current || trackData.uuid !== currentTrackUuid.current) return;
|
||||||
|
|
||||||
|
if (!lyricsData.err && Array.isArray(lyricsData.lrc)) {
|
||||||
|
const parsedLyrics = lyricsData.lrc.map(({ timeTag, words }) => {
|
||||||
|
const [mins, rest] = timeTag.split(':');
|
||||||
|
const secs = parseFloat(rest);
|
||||||
|
return { timestamp: Number(mins) * 60 + secs, line: words };
|
||||||
|
});
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
setCurrentLyricIndex(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Same track - do not update elapsed or elapsed base here
|
||||||
|
setTrackDuration(trackData.duration);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching metadata:', error);
|
||||||
|
setTrackTitle('Offline');
|
||||||
|
setLyrics([]);
|
||||||
|
}
|
||||||
|
}, [activeStation]);
|
||||||
|
|
||||||
|
const setTrackMetadata = useCallback((trackData, requestStation) => {
|
||||||
|
setTrackTitle(trackData.song);
|
||||||
|
setTrackArtist(trackData.artist);
|
||||||
|
setTrackGenre(trackData.genre || '');
|
||||||
|
setTrackAlbum(trackData.album);
|
||||||
|
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
|
||||||
|
|
||||||
|
baseTrackElapsed.current = trackData.elapsed;
|
||||||
|
lastUpdateTimestamp.current = Date.now();
|
||||||
|
setElapsedTime(trackData.elapsed);
|
||||||
|
|
||||||
|
setTrackDuration(trackData.duration);
|
||||||
|
setPageTitle(trackData.artist, trackData.song);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// ensure the ref points to the current activeStation for in-flight guards
|
// ensure the ref points to the current activeStation for in-flight guards
|
||||||
activeStationRef.current = activeStation;
|
activeStationRef.current = activeStation;
|
||||||
fetchMetadataAndLyrics();
|
fetchMetadataAndLyrics();
|
||||||
@@ -267,7 +355,146 @@ export default function Player({ user }) {
|
|||||||
? "bg-yellow-400 dark:bg-yellow-300"
|
? "bg-yellow-400 dark:bg-yellow-300"
|
||||||
: "bg-blue-500 dark:bg-blue-400";
|
: "bg-blue-500 dark:bg-blue-400";
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
|
||||||
|
const handleSkip = async (skipTo = null) => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/radio/skip`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ station: activeStation, skipTo }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success) {
|
||||||
|
console.error("Failed to skip track.", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error skipping track:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReshuffle = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/radio/reshuffle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ station: activeStation }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
console.error("Failed to reshuffle queue.", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reshuffling queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueueShift = async (uuid, playNow = 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 }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
console.error("Failed to shift queue.", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error shifting queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.result) {
|
||||||
|
console.error("Failed to request song.", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error requesting song:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [queueSearch, setQueueSearch] = useState("");
|
||||||
|
const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => {
|
||||||
|
const start = page * rows;
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
station: activeStation,
|
||||||
|
start,
|
||||||
|
length: rows,
|
||||||
|
search,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setQueueData(data.items || []);
|
||||||
|
setQueueTotalRecords(data.total || data.totalRecords || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isQueueVisible) {
|
||||||
|
fetchQueue(queuePage, queueRows, queueSearch);
|
||||||
|
}
|
||||||
|
}, [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 queueFooter = isDJ
|
||||||
|
? (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded bg-green-400 text-white hover:bg-green-500 text-xs font-bold"
|
||||||
|
onClick={() => handleReshuffle()}
|
||||||
|
>
|
||||||
|
Reshuffle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded bg-gray-400 text-white hover:bg-gray-500 text-xs font-bold"
|
||||||
|
onClick={() => setQueueVisible(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded bg-gray-400 text-white hover:bg-gray-500 text-xs font-bold"
|
||||||
|
onClick={() => setQueueVisible(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
|
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
|
||||||
@@ -276,8 +503,8 @@ export default function Player({ user }) {
|
|||||||
key={stationKey}
|
key={stationKey}
|
||||||
onClick={() => setActiveStation(stationKey)}
|
onClick={() => setActiveStation(stationKey)}
|
||||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
||||||
? 'bg-neutral-800 text-white dark:bg-white dark:text-black'
|
? "bg-neutral-800 text-white dark:bg-white dark:text-black"
|
||||||
: 'bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white'
|
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||||
}`}
|
}`}
|
||||||
aria-pressed={activeStation === stationKey}
|
aria-pressed={activeStation === stationKey}
|
||||||
>
|
>
|
||||||
@@ -285,25 +512,29 @@ export default function Player({ user }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="c-containter">
|
|
||||||
{user && <span>Hello, {user.user}</span>}
|
<div className="c-container">
|
||||||
< div className="music-container mt-8">
|
{user && <span className="text-lg font-semibold">Hello, {user.user}</span>}
|
||||||
|
|
||||||
|
<div className="music-container mt-8">
|
||||||
<section className="album-cover">
|
<section className="album-cover">
|
||||||
<div className="music-player__album" title="Album">
|
<div className="music-player__album" title="Album">
|
||||||
{trackAlbum}
|
{trackAlbum}
|
||||||
</div>
|
</div>
|
||||||
<img src={coverArt} className="cover" alt="Cover Art" />
|
<img src={coverArt} className="cover rounded-lg shadow-md" alt="Cover Art" />
|
||||||
</section>
|
</section>
|
||||||
<section className="music-player">
|
|
||||||
<h1 className="music-player__header">serious.FM</h1>
|
|
||||||
<h1 className="music-player__title">{trackTitle}</h1>
|
|
||||||
<h2 className="music-player__author">{trackArtist}</h2>
|
|
||||||
{trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
|
|
||||||
|
|
||||||
<div className="music-time">
|
<section className="music-player">
|
||||||
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
<h1 className="music-player__header text-2xl font-bold">serious.FM</h1>
|
||||||
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
<h1 className="music-player__title text-xl font-semibold">{trackTitle}</h1>
|
||||||
|
<h2 className="music-player__author text-lg font-medium">{trackArtist}</h2>
|
||||||
|
{trackGenre && <h2 className="music-player__genre text-md italic">{trackGenre}</h2>}
|
||||||
|
|
||||||
|
<div className="music-time flex justify-between items-center mt-4">
|
||||||
|
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
||||||
|
<p className="music-time__last text-sm">{formatTime(trackDuration - elapsedTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
||||||
@@ -311,18 +542,85 @@ export default function Player({ user }) {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
|
<div className={`lrc-text mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800 ${lyrics.length === 0 ? "empty" : ""}`}>
|
||||||
{lyrics.map((lyricObj, index) => (
|
{lyrics.map((lyricObj, index) => (
|
||||||
<p
|
<p
|
||||||
key={index}
|
key={index}
|
||||||
className={`lrc-line ${index === currentLyricIndex ? 'active' : ''}`}
|
className={`lrc-line text-sm ${index === currentLyricIndex ? "active font-bold" : ""}`}
|
||||||
>
|
>
|
||||||
{lyricObj.line}
|
{lyricObj.line}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="music-control">
|
{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>
|
||||||
|
)}
|
||||||
|
{/* Always show play/pause button */}
|
||||||
|
<div className="music-control mt-4 flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="music-control__play"
|
className="music-control__play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -344,8 +642,119 @@ export default function Player({ user }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio ref={audioElement} preload="none" />
|
<audio ref={audioElement} preload="none" />
|
||||||
</div >
|
|
||||||
|
<Dialog
|
||||||
|
header="Queue"
|
||||||
|
visible={isQueueVisible}
|
||||||
|
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
|
||||||
|
footer={queueFooter}
|
||||||
|
onHide={() => setQueueVisible(false)}
|
||||||
|
className={theme === "dark" ? "dark-theme" : "light-theme"}
|
||||||
|
>
|
||||||
|
<div style={{ maxHeight: "calc(90vh - 100px)", overflow: "visible" }}>
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={queueSearch}
|
||||||
|
onChange={e => setQueueSearch(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-700 text-sm"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
value={queueData}
|
||||||
|
paginator
|
||||||
|
rows={queueRows}
|
||||||
|
first={queuePage * queueRows}
|
||||||
|
totalRecords={queueTotalRecords}
|
||||||
|
onPage={(e) => {
|
||||||
|
setQueuePage(e.page);
|
||||||
|
setQueueRows(e.rows);
|
||||||
|
fetchQueue(e.page, e.rows, queueSearch);
|
||||||
|
}}
|
||||||
|
className="p-datatable-gridlines rounded-lg shadow-md border-t border-neutral-300 dark:border-neutral-700"
|
||||||
|
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
|
||||||
|
lazy
|
||||||
|
>
|
||||||
|
<Column
|
||||||
|
field="pos"
|
||||||
|
header="Position"
|
||||||
|
body={(rowData) => rowData.pos + 1}
|
||||||
|
sortable
|
||||||
|
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
||||||
|
style={{ width: '50px', textAlign: 'center' }}
|
||||||
|
></Column>
|
||||||
|
<Column
|
||||||
|
field="artistsong"
|
||||||
|
header="Track"
|
||||||
|
sortable
|
||||||
|
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
||||||
|
style={{ width: '300px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
|
||||||
|
></Column>
|
||||||
|
<Column
|
||||||
|
field="album"
|
||||||
|
header="Album"
|
||||||
|
sortable
|
||||||
|
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
||||||
|
style={{ width: '220px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
|
||||||
|
></Column>
|
||||||
|
<Column
|
||||||
|
field="genre"
|
||||||
|
header="Genre"
|
||||||
|
sortable
|
||||||
|
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
||||||
|
style={{ width: '120px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
|
||||||
|
></Column>
|
||||||
|
{isDJ && (
|
||||||
|
<Column
|
||||||
|
field="actions"
|
||||||
|
header="Actions"
|
||||||
|
body={(rowData) => (
|
||||||
|
<div className="flex gap-1 flex-nowrap" style={{ minWidth: '220px', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 rounded bg-blue-400 text-neutral-900 hover:bg-blue-500 text-xs shadow-md cursor-pointer font-bold font-sans"
|
||||||
|
onClick={() => handleSkip(rowData.uuid)}
|
||||||
|
title="Skip To"
|
||||||
|
>
|
||||||
|
Skip To
|
||||||
|
</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)}
|
||||||
|
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)}
|
||||||
|
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)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
||||||
|
style={{ width: '220px', textAlign: 'center' }}
|
||||||
|
></Column>
|
||||||
|
)}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Alert from '@mui/joy/Alert';
|
import Alert from '@mui/joy/Alert';
|
||||||
@@ -65,7 +66,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
|
|
||||||
|
|
||||||
// Typeahead: fetch suggestions
|
// Typeahead: fetch suggestions
|
||||||
const fetchSuggestions = async (event) => {
|
const fetchSuggestions = useCallback(async (event) => {
|
||||||
const query = event.query;
|
const query = event.query;
|
||||||
const res = await fetch(`${API_URL}/typeahead/lyrics`, {
|
const res = await fetch(`${API_URL}/typeahead/lyrics`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -74,7 +75,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setSuggestions(json);
|
setSuggestions(json);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Toggle exclusion state for checkboxes
|
// Toggle exclusion state for checkboxes
|
||||||
const toggleExclusion = (source) => {
|
const toggleExclusion = (source) => {
|
||||||
|
|||||||
45005
src/components/old.js
Normal file
45005
src/components/old.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,6 @@
|
|||||||
export function useHtmlThemeAttr () {
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useHtmlThemeAttr() {
|
||||||
const [theme, setTheme] = useState(() =>
|
const [theme, setTheme] = useState(() =>
|
||||||
document.documentElement.getAttribute("data-theme") || "light"
|
document.documentElement.getAttribute("data-theme") || "light"
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user