Enhance AudioPlayer with custom paginator styles, improved scroll handling, and WebSocket integration for real-time track updates. Refactor typeahead and queue management functionalities for better performance and user experience.
This commit is contained in:
@@ -15,6 +15,29 @@ import { requireAuthHook } from "@/hooks/requireAuthHook";
|
|||||||
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
|
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
|
||||||
import "@/components/TRip/RequestManagement.css";
|
import "@/components/TRip/RequestManagement.css";
|
||||||
|
|
||||||
|
// Custom styles for paginator
|
||||||
|
const paginatorStyles = `
|
||||||
|
.queue-paginator .p-paginator-current {
|
||||||
|
background: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .queue-paginator .p-paginator-current {
|
||||||
|
color: rgb(212 212 212) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-paginator .p-paginator-bottom {
|
||||||
|
padding: 16px !important;
|
||||||
|
border-top: 1px solid rgb(229 229 229) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .queue-paginator .p-paginator-bottom {
|
||||||
|
border-top-color: rgb(82 82 82) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const STATIONS = {
|
const STATIONS = {
|
||||||
main: { label: "Main" },
|
main: { label: "Main" },
|
||||||
rock: { label: "Rock" },
|
rock: { label: "Rock" },
|
||||||
@@ -25,61 +48,79 @@ const STATIONS = {
|
|||||||
|
|
||||||
|
|
||||||
export default function Player({ user }) {
|
export default function Player({ user }) {
|
||||||
|
// Inject custom paginator styles
|
||||||
|
useEffect(() => {
|
||||||
|
const styleId = 'queue-paginator-styles';
|
||||||
|
if (!document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = paginatorStyles;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isQueueVisible, setQueueVisible] = useState(false);
|
const [isQueueVisible, setQueueVisible] = useState(false);
|
||||||
// Mouse wheel scroll fix for queue modal
|
// Mouse wheel scroll fix for queue modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQueueVisible) return;
|
if (!isQueueVisible) return;
|
||||||
setTimeout(() => {
|
|
||||||
const modalContent = document.querySelector('.p-dialog .p-dialog-content > div');
|
const setupScrollHandlers = () => {
|
||||||
if (modalContent) {
|
// Target multiple possible scroll containers
|
||||||
modalContent.style.overflowY = 'auto';
|
const selectors = [
|
||||||
modalContent.style.overscrollBehavior = 'contain';
|
'.p-dialog .p-dialog-content',
|
||||||
|
'.p-dialog .p-datatable-wrapper',
|
||||||
|
'.p-dialog .p-datatable-scrollable-body',
|
||||||
|
'.p-dialog .p-datatable-tbody'
|
||||||
|
];
|
||||||
|
|
||||||
|
const elements = selectors
|
||||||
|
.map(selector => document.querySelector(selector))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Also allow scrolling on the entire dialog
|
||||||
|
const dialog = document.querySelector('.p-dialog');
|
||||||
|
if (dialog) elements.push(dialog);
|
||||||
|
|
||||||
|
elements.forEach(element => {
|
||||||
|
element.style.overflowY = 'auto';
|
||||||
|
element.style.overscrollBehavior = 'contain';
|
||||||
|
|
||||||
const wheelHandler = (e) => {
|
const wheelHandler = (e) => {
|
||||||
const delta = e.deltaY;
|
e.stopPropagation();
|
||||||
const atTop = modalContent.scrollTop === 0;
|
// Allow normal scrolling within the modal
|
||||||
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 });
|
element.removeEventListener('wheel', wheelHandler);
|
||||||
}
|
element.addEventListener('wheel', wheelHandler, { passive: true });
|
||||||
}, 0);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use multiple timeouts to ensure elements are rendered
|
||||||
|
setTimeout(setupScrollHandlers, 0);
|
||||||
|
setTimeout(setupScrollHandlers, 100);
|
||||||
|
setTimeout(setupScrollHandlers, 300);
|
||||||
}, [isQueueVisible]);
|
}, [isQueueVisible]);
|
||||||
// Debugging output
|
// Debugging output
|
||||||
// Autocomplete for requests
|
// Autocomplete for requests
|
||||||
const fetchTypeahead = async (query) => {
|
const fetchTypeahead = async (query) => {
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
setTypeaheadOptions([]);
|
setTypeaheadOptions([]);
|
||||||
console.debug('Typeahead: query too short', query);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
console.debug('Typeahead: fetching for query', query);
|
|
||||||
const response = await authFetch(`${API_URL}/radio/typeahead`, {
|
const response = await authFetch(`${API_URL}/radio/typeahead`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ station: activeStation, query }),
|
body: JSON.stringify({ station: activeStation, query }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.debug('Typeahead: response data', data);
|
|
||||||
// Accept bare array or { options: [...] }
|
// Accept bare array or { options: [...] }
|
||||||
setTypeaheadOptions(Array.isArray(data) ? data : data.options || []);
|
setTypeaheadOptions(Array.isArray(data) ? data : data.options || []);
|
||||||
console.debug('Typeahead: setTypeaheadOptions', Array.isArray(data) ? data : data.options || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Typeahead: error', error);
|
console.error('Typeahead: error', error);
|
||||||
setTypeaheadOptions([]);
|
setTypeaheadOptions([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.debug('AudioPlayer user:', user);
|
|
||||||
if (user) {
|
|
||||||
console.debug('isAuthenticated:', user);
|
|
||||||
console.debug('roles:', user.roles);
|
|
||||||
console.debug('isDJ:', user && Array.isArray(user.roles) && user.roles.includes('dj'));
|
|
||||||
}
|
|
||||||
const theme = useHtmlThemeAttr();
|
const theme = useHtmlThemeAttr();
|
||||||
|
|
||||||
const [activeStation, setActiveStation] = useState("main");
|
const [activeStation, setActiveStation] = useState("main");
|
||||||
@@ -110,7 +151,7 @@ export default function Player({ user }) {
|
|||||||
const [queueData, setQueueData] = useState([]);
|
const [queueData, setQueueData] = useState([]);
|
||||||
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
||||||
const [queuePage, setQueuePage] = useState(0);
|
const [queuePage, setQueuePage] = useState(0);
|
||||||
const [queueRows, setQueueRows] = useState(10);
|
const [queueRows, setQueueRows] = useState(20);
|
||||||
|
|
||||||
// DJ controls state
|
// DJ controls state
|
||||||
const [requestInput, setRequestInput] = useState("");
|
const [requestInput, setRequestInput] = useState("");
|
||||||
@@ -125,8 +166,10 @@ export default function Player({ user }) {
|
|||||||
const baseTrackElapsed = useRef(0);
|
const baseTrackElapsed = useRef(0);
|
||||||
const lastUpdateTimestamp = useRef(Date.now());
|
const lastUpdateTimestamp = useRef(Date.now());
|
||||||
const activeStationRef = useRef(activeStation);
|
const activeStationRef = useRef(activeStation);
|
||||||
|
const wsInstance = useRef(null);
|
||||||
|
|
||||||
const formatTime = (seconds) => {
|
const formatTime = (seconds) => {
|
||||||
|
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
|
||||||
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||||
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
||||||
return `${mins}:${secs}`;
|
return `${mins}:${secs}`;
|
||||||
@@ -211,7 +254,7 @@ export default function Player({ user }) {
|
|||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
||||||
let liveElapsed = baseTrackElapsed.current + deltaSec;
|
let liveElapsed = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec;
|
||||||
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
||||||
setElapsedTime(liveElapsed);
|
setElapsedTime(liveElapsed);
|
||||||
}, 200);
|
}, 200);
|
||||||
@@ -223,19 +266,12 @@ export default function Player({ user }) {
|
|||||||
// Scroll active lyric into view
|
// Scroll active lyric into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.debug('Lyric scroll: currentLyricIndex', currentLyricIndex);
|
|
||||||
console.debug('Lyric scroll: lyrics', lyrics);
|
|
||||||
const activeElement = document.querySelector('.lrc-line.active');
|
const activeElement = document.querySelector('.lrc-line.active');
|
||||||
const lyricsContainer = document.querySelector('.lrc-text');
|
const lyricsContainer = document.querySelector('.lrc-text');
|
||||||
console.debug('Lyric scroll: activeElement', activeElement);
|
|
||||||
console.debug('Lyric scroll: lyricsContainer', lyricsContainer);
|
|
||||||
if (activeElement && lyricsContainer) {
|
if (activeElement && lyricsContainer) {
|
||||||
lyricsContainer.style.maxHeight = '220px';
|
lyricsContainer.style.maxHeight = '220px';
|
||||||
lyricsContainer.style.overflowY = 'auto';
|
lyricsContainer.style.overflowY = 'auto';
|
||||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
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);
|
}, 0);
|
||||||
}, [currentLyricIndex, lyrics]);
|
}, [currentLyricIndex, lyrics]);
|
||||||
@@ -263,65 +299,170 @@ export default function Player({ user }) {
|
|||||||
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
const fetchMetadataAndLyrics = useCallback(async () => {
|
const parseLrcString = useCallback((lrcString) => {
|
||||||
const requestStation = activeStationRef.current;
|
if (!lrcString || typeof lrcString !== 'string') return [];
|
||||||
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
|
const lines = lrcString.split('\n').filter(line => line.trim());
|
||||||
if (requestStation !== activeStationRef.current) return;
|
const parsedLyrics = [];
|
||||||
|
|
||||||
if (!trackData.song || trackData.song === 'N/A') {
|
for (const line of lines) {
|
||||||
setTrackTitle('No track playing');
|
const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/);
|
||||||
setLyrics([]);
|
if (match) {
|
||||||
return;
|
const [, mins, secs, text] = match;
|
||||||
}
|
const timestamp = Number(mins) * 60 + parseFloat(secs);
|
||||||
|
if (!isNaN(timestamp) && text.trim()) {
|
||||||
if (trackData.uuid !== currentTrackUuid.current) {
|
parsedLyrics.push({ timestamp, line: text.trim() });
|
||||||
currentTrackUuid.current = trackData.uuid;
|
|
||||||
|
|
||||||
setTrackMetadata(trackData, requestStation);
|
|
||||||
|
|
||||||
// Refresh queue when track changes
|
|
||||||
fetchQueue();
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
return parsedLyrics.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLyrics = useCallback(async (trackData, requestStation) => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching lyrics:', error);
|
||||||
setTrackTitle('Error fetching track');
|
|
||||||
setLyrics([]);
|
|
||||||
}
|
}
|
||||||
}, [activeStation]);
|
}, []);
|
||||||
|
|
||||||
|
const handleTrackData = useCallback((trackData) => {
|
||||||
|
console.debug('handleTrackData called with:', trackData);
|
||||||
|
const requestStation = activeStationRef.current;
|
||||||
|
|
||||||
|
// Only set "No track playing" if we have clear indication of no track
|
||||||
|
// and not just missing song field (to avoid flickering during transitions)
|
||||||
|
if ((!trackData.song || trackData.song === 'N/A') && (!trackData.artist || trackData.artist === 'N/A')) {
|
||||||
|
console.debug('Setting no track playing state');
|
||||||
|
setTrackTitle('No track playing');
|
||||||
|
setLyrics([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackData.uuid !== currentTrackUuid.current) {
|
||||||
|
currentTrackUuid.current = trackData.uuid;
|
||||||
|
|
||||||
|
setTrackMetadata(trackData, requestStation);
|
||||||
|
|
||||||
|
// Clear lyrics immediately when new track is detected
|
||||||
|
setLyrics([]);
|
||||||
|
setCurrentLyricIndex(0);
|
||||||
|
|
||||||
|
// Refresh queue when track changes
|
||||||
|
fetchQueue();
|
||||||
|
|
||||||
|
// Fetch lyrics for the new track
|
||||||
|
fetchLyrics(trackData, requestStation);
|
||||||
|
} else {
|
||||||
|
// Same track - update duration and elapsed time
|
||||||
|
setTrackDuration(trackData.duration || 0);
|
||||||
|
if (trackData.elapsed !== undefined && typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed)) {
|
||||||
|
baseTrackElapsed.current = trackData.elapsed;
|
||||||
|
lastUpdateTimestamp.current = Date.now();
|
||||||
|
setElapsedTime(trackData.elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetchLyrics]);
|
||||||
|
|
||||||
|
const initializeWebSocket = useCallback((station) => {
|
||||||
|
// Clean up existing WebSocket
|
||||||
|
if (wsInstance.current) {
|
||||||
|
wsInstance.current.close();
|
||||||
|
wsInstance.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectWebSocket = (retryCount = 0) => {
|
||||||
|
const maxRetries = 5;
|
||||||
|
const baseDelay = 1000; // 1 second
|
||||||
|
const maxDelay = 30000; // 30 seconds
|
||||||
|
|
||||||
|
const wsUrl = `${API_URL.replace(/^https?:/, 'wss:')}/radio/ws/${station}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = function () {
|
||||||
|
console.log('Connected to radio WebSocket for station:', station);
|
||||||
|
// Reset retry count on successful connection
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'track_change') {
|
||||||
|
// Handle track change
|
||||||
|
handleTrackData(data.data);
|
||||||
|
} else if (data.type === 'lrc') {
|
||||||
|
// Handle LRC data
|
||||||
|
const parsedLyrics = parseLrcString(data.data);
|
||||||
|
setLyrics(parsedLyrics);
|
||||||
|
setCurrentLyricIndex(0);
|
||||||
|
} else {
|
||||||
|
// Handle initial now playing data
|
||||||
|
handleTrackData(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function (event) {
|
||||||
|
console.log('Radio WebSocket connection closed, code:', event.code, 'reason:', event.reason);
|
||||||
|
|
||||||
|
// Don't retry if it was a clean close (code 1000)
|
||||||
|
if (event.code === 1000) return;
|
||||||
|
|
||||||
|
// Attempt reconnection with exponential backoff
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
||||||
|
console.log(`Attempting WebSocket reconnection in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
connectWebSocket(retryCount + 1);
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.error('Max WebSocket reconnection attempts reached');
|
||||||
|
setTrackTitle('Connection lost');
|
||||||
|
setLyrics([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function (error) {
|
||||||
|
console.error('Radio WebSocket error:', error);
|
||||||
|
// Don't set error state here - let onclose handle reconnection
|
||||||
|
};
|
||||||
|
|
||||||
|
wsInstance.current = ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
connectWebSocket();
|
||||||
|
}, [handleTrackData, parseLrcString]);
|
||||||
|
|
||||||
const setTrackMetadata = useCallback((trackData, requestStation) => {
|
const setTrackMetadata = useCallback((trackData, requestStation) => {
|
||||||
setTrackTitle(trackData.song || 'Unknown Title');
|
setTrackTitle(trackData.song || 'Unknown Title');
|
||||||
@@ -330,23 +471,42 @@ export default function Player({ user }) {
|
|||||||
setTrackAlbum(trackData.album || 'Unknown Album');
|
setTrackAlbum(trackData.album || 'Unknown Album');
|
||||||
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
|
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
|
||||||
|
|
||||||
baseTrackElapsed.current = trackData.elapsed;
|
const elapsed = typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0;
|
||||||
|
baseTrackElapsed.current = elapsed;
|
||||||
lastUpdateTimestamp.current = Date.now();
|
lastUpdateTimestamp.current = Date.now();
|
||||||
setElapsedTime(trackData.elapsed);
|
setElapsedTime(elapsed);
|
||||||
|
|
||||||
setTrackDuration(trackData.duration);
|
setTrackDuration(trackData.duration || 0);
|
||||||
setPageTitle(trackData.artist, trackData.song);
|
setPageTitle(trackData.artist, trackData.song);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
|
||||||
const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
|
|
||||||
return () => clearInterval(metadataInterval);
|
|
||||||
}, [activeStation]);
|
|
||||||
|
|
||||||
const progress = (elapsedTime / trackDuration) * 100;
|
// Initialize WebSocket connection for metadata
|
||||||
|
initializeWebSocket(activeStation);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clean up WebSocket on station change or component unmount
|
||||||
|
if (wsInstance.current) {
|
||||||
|
wsInstance.current.close();
|
||||||
|
wsInstance.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeStation, initializeWebSocket]);
|
||||||
|
|
||||||
|
// Cleanup WebSocket on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wsInstance.current) {
|
||||||
|
wsInstance.current.close();
|
||||||
|
wsInstance.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progress = trackDuration > 0 ? (elapsedTime / trackDuration) * 100 : 0;
|
||||||
const remaining = trackDuration - elapsedTime;
|
const remaining = trackDuration - elapsedTime;
|
||||||
|
|
||||||
const progressColorClass =
|
const progressColorClass =
|
||||||
@@ -486,18 +646,16 @@ export default function Player({ user }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromQueue = async (uuid) => {
|
const handleRemoveFromQueue = async (uuid) => {
|
||||||
console.debug("handleRemoveFromQueue called with uuid:", uuid); // Debugging log
|
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
|
const response = await authFetch(`${API_URL}/radio/remove_from_queue`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
uuid,
|
|
||||||
station: activeStation,
|
station: activeStation,
|
||||||
|
uuid,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.debug("handleRemoveFromQueue response:", data); // Debugging log
|
|
||||||
if (!data.err) {
|
if (!data.err) {
|
||||||
toast.success("OK!");
|
toast.success("OK!");
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
@@ -637,18 +795,15 @@ export default function Player({ user }) {
|
|||||||
value={requestInput}
|
value={requestInput}
|
||||||
suggestions={typeaheadOptions}
|
suggestions={typeaheadOptions}
|
||||||
completeMethod={(e) => {
|
completeMethod={(e) => {
|
||||||
console.debug('AutoComplete: completeMethod called', e);
|
|
||||||
fetchTypeahead(e.query);
|
fetchTypeahead(e.query);
|
||||||
}}
|
}}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
console.debug('AutoComplete: onChange', e);
|
|
||||||
setRequestInput(e.target.value);
|
setRequestInput(e.target.value);
|
||||||
}}
|
}}
|
||||||
onShow={() => {
|
onShow={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const panel = document.querySelector('.p-autocomplete-panel');
|
const panel = document.querySelector('.p-autocomplete-panel');
|
||||||
const items = panel?.querySelector('.p-autocomplete-items');
|
const items = panel?.querySelector('.p-autocomplete-items');
|
||||||
console.debug('AutoComplete: onShow panel', panel);
|
|
||||||
if (items) {
|
if (items) {
|
||||||
items.style.maxHeight = '200px';
|
items.style.maxHeight = '200px';
|
||||||
items.style.overflowY = 'auto';
|
items.style.overflowY = 'auto';
|
||||||
@@ -750,14 +905,17 @@ export default function Player({ user }) {
|
|||||||
<DataTable
|
<DataTable
|
||||||
value={queueData}
|
value={queueData}
|
||||||
paginator
|
paginator
|
||||||
|
alwaysShowPaginator={true}
|
||||||
rows={queueRows}
|
rows={queueRows}
|
||||||
first={queuePage * queueRows}
|
first={queuePage * queueRows}
|
||||||
totalRecords={queueTotalRecords}
|
totalRecords={queueTotalRecords}
|
||||||
onPage={(e) => {
|
onPage={(e) => {
|
||||||
setQueuePage(e.page);
|
setQueuePage(e.page);
|
||||||
setQueueRows(e.rows);
|
fetchQueue(e.page, queueRows, queueSearch);
|
||||||
fetchQueue(e.page, e.rows, queueSearch);
|
|
||||||
}}
|
}}
|
||||||
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
|
||||||
|
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"
|
||||||
|
paginatorClassName="queue-paginator !bg-neutral-50 dark:!bg-neutral-800 !text-neutral-900 dark:!text-neutral-100 !border-neutral-200 dark:!border-neutral-700"
|
||||||
className="p-datatable-gridlines rounded-lg shadow-md border-t border-neutral-300 dark:border-neutral-700"
|
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%' }}
|
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
|
||||||
lazy
|
lazy
|
||||||
@@ -776,7 +934,7 @@ export default function Player({ user }) {
|
|||||||
sortable
|
sortable
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
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' }}
|
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>}
|
body={(rowData) => <span title={rowData.artistsong} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
|
||||||
></Column>
|
></Column>
|
||||||
<Column
|
<Column
|
||||||
field="album"
|
field="album"
|
||||||
@@ -784,7 +942,7 @@ export default function Player({ user }) {
|
|||||||
sortable
|
sortable
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
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' }}
|
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>}
|
body={(rowData) => <span title={rowData.album} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
|
||||||
></Column>
|
></Column>
|
||||||
<Column
|
<Column
|
||||||
field="genre"
|
field="genre"
|
||||||
@@ -792,7 +950,7 @@ export default function Player({ user }) {
|
|||||||
sortable
|
sortable
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
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' }}
|
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>}
|
body={(rowData) => <span title={rowData.genre} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
|
||||||
></Column>
|
></Column>
|
||||||
{isDJ && (
|
{isDJ && (
|
||||||
<Column
|
<Column
|
||||||
@@ -824,7 +982,6 @@ export default function Player({ user }) {
|
|||||||
<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"
|
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={() => {
|
onClick={() => {
|
||||||
console.debug("Remove button clicked for uuid:", rowData.uuid); // Debugging log
|
|
||||||
handleRemoveFromQueue(rowData.uuid);
|
handleRemoveFromQueue(rowData.uuid);
|
||||||
}}
|
}}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
|
|||||||
Reference in New Issue
Block a user