2025-09-24 16:30:13 -04:00
|
|
|
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
|
2025-11-28 09:40:13 -05:00
|
|
|
import "@styles/player.css";
|
2025-07-24 10:06:36 -04:00
|
|
|
import { metaData } from "../config";
|
2025-07-17 06:55:01 -04:00
|
|
|
import Play from "@mui/icons-material/PlayArrow";
|
|
|
|
|
import Pause from "@mui/icons-material/Pause";
|
2025-11-28 09:40:13 -05:00
|
|
|
import "@styles/player.css";
|
2025-09-24 16:30:13 -04:00
|
|
|
import { Dialog } from "primereact/dialog";
|
|
|
|
|
import { AutoComplete } from "primereact/autocomplete";
|
|
|
|
|
import { DataTable } from "primereact/datatable";
|
|
|
|
|
import { Column } from "primereact/column";
|
2025-09-24 22:40:48 -04:00
|
|
|
import { toast } from "react-toastify";
|
|
|
|
|
import { ENVIRONMENT } from "../config";
|
2025-08-09 07:10:04 -04:00
|
|
|
import { API_URL } from "@/config";
|
2025-09-24 16:30:13 -04:00
|
|
|
import { authFetch } from "@/utils/authFetch";
|
2025-09-12 22:39:35 -04:00
|
|
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
2025-09-24 16:30:13 -04:00
|
|
|
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
|
|
|
|
|
import "@/components/TRip/RequestManagement.css";
|
2025-08-09 07:10:04 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
const STATIONS = {
|
2025-07-30 07:58:44 -04:00
|
|
|
main: { label: "Main" },
|
|
|
|
|
rock: { label: "Rock" },
|
|
|
|
|
rap: { label: "Rap" },
|
|
|
|
|
electronic: { label: "Electronic" },
|
|
|
|
|
pop: { label: "Pop" },
|
2025-07-17 06:55:01 -04:00
|
|
|
};
|
|
|
|
|
|
2025-09-12 22:39:35 -04:00
|
|
|
|
|
|
|
|
export default function Player({ user }) {
|
2025-09-26 11:36:33 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-24 16:30:13 -04:00
|
|
|
const [isQueueVisible, setQueueVisible] = useState(false);
|
|
|
|
|
// Mouse wheel scroll fix for queue modal
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isQueueVisible) return;
|
2025-09-26 11:36:33 -04:00
|
|
|
|
|
|
|
|
const setupScrollHandlers = () => {
|
|
|
|
|
// Target multiple possible scroll containers
|
|
|
|
|
const selectors = [
|
|
|
|
|
'.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';
|
|
|
|
|
|
2025-09-24 16:30:13 -04:00
|
|
|
const wheelHandler = (e) => {
|
2025-09-26 11:36:33 -04:00
|
|
|
e.stopPropagation();
|
|
|
|
|
// Allow normal scrolling within the modal
|
2025-09-24 16:30:13 -04:00
|
|
|
};
|
2025-09-26 11:36:33 -04:00
|
|
|
|
|
|
|
|
element.removeEventListener('wheel', wheelHandler);
|
|
|
|
|
element.addEventListener('wheel', wheelHandler, { passive: true });
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Use multiple timeouts to ensure elements are rendered
|
|
|
|
|
setTimeout(setupScrollHandlers, 0);
|
|
|
|
|
setTimeout(setupScrollHandlers, 100);
|
|
|
|
|
setTimeout(setupScrollHandlers, 300);
|
2025-09-24 16:30:13 -04:00
|
|
|
}, [isQueueVisible]);
|
|
|
|
|
// Debugging output
|
|
|
|
|
// Autocomplete for requests
|
|
|
|
|
const fetchTypeahead = async (query) => {
|
|
|
|
|
if (!query || query.length < 2) {
|
|
|
|
|
setTypeaheadOptions([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
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();
|
2025-09-24 22:40:48 -04:00
|
|
|
// Accept bare array or { options: [...] }
|
|
|
|
|
setTypeaheadOptions(Array.isArray(data) ? data : data.options || []);
|
2025-09-24 16:30:13 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Typeahead: error', error);
|
|
|
|
|
setTypeaheadOptions([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const theme = useHtmlThemeAttr();
|
|
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
const [activeStation, setActiveStation] = useState("main");
|
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
|
const [trackTitle, setTrackTitle] = useState("");
|
|
|
|
|
const [trackArtist, setTrackArtist] = useState("");
|
|
|
|
|
const [trackGenre, setTrackGenre] = useState("");
|
2025-07-21 16:00:49 -04:00
|
|
|
const [trackAlbum, setTrackAlbum] = useState("");
|
2025-07-17 06:55:01 -04:00
|
|
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
2025-08-06 15:37:07 -04:00
|
|
|
const [lyrics, setLyrics] = useState([]);
|
|
|
|
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
|
|
|
|
const [elapsedTime, setElapsedTime] = useState(0);
|
2025-09-24 16:30:13 -04:00
|
|
|
// 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]);
|
2025-08-06 15:37:07 -04:00
|
|
|
const [trackDuration, setTrackDuration] = useState(0);
|
2025-09-24 16:30:13 -04:00
|
|
|
const [queueData, setQueueData] = useState([]);
|
|
|
|
|
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
|
|
|
|
const [queuePage, setQueuePage] = useState(0);
|
2025-09-26 11:36:33 -04:00
|
|
|
const [queueRows, setQueueRows] = useState(20);
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
// DJ controls state
|
|
|
|
|
const [requestInput, setRequestInput] = useState("");
|
|
|
|
|
const [typeaheadOptions, setTypeaheadOptions] = useState([]);
|
|
|
|
|
const [requestInputArtist, setRequestInputArtist] = useState("");
|
|
|
|
|
const [requestInputSong, setRequestInputSong] = useState("");
|
|
|
|
|
const [requestInputUuid, setRequestInputUuid] = useState("");
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
const audioElement = useRef(null);
|
|
|
|
|
const hlsInstance = useRef(null);
|
|
|
|
|
const currentTrackUuid = useRef(null);
|
|
|
|
|
const baseTrackElapsed = useRef(0);
|
|
|
|
|
const lastUpdateTimestamp = useRef(Date.now());
|
2025-09-22 11:15:24 -04:00
|
|
|
const activeStationRef = useRef(activeStation);
|
2025-09-26 11:36:33 -04:00
|
|
|
const wsInstance = useRef(null);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
|
|
|
|
const formatTime = (seconds) => {
|
2025-09-26 11:36:33 -04:00
|
|
|
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
|
2025-08-06 15:37:07 -04:00
|
|
|
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
|
|
|
|
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
|
|
|
|
return `${mins}:${secs}`;
|
2025-07-17 06:55:01 -04:00
|
|
|
};
|
|
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
// Set page title based on current track and station
|
|
|
|
|
const setPageTitle = (artist, song) => {
|
2025-09-24 16:30:13 -04:00
|
|
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`;
|
2025-08-09 07:10:04 -04:00
|
|
|
};
|
|
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Initialize or switch HLS stream
|
|
|
|
|
const initializeStream = (station) => {
|
2025-08-09 07:10:04 -04:00
|
|
|
import('hls.js').then(({ default: Hls }) => {
|
|
|
|
|
const audio = audioElement.current;
|
|
|
|
|
if (!audio) return;
|
|
|
|
|
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
|
|
|
|
|
|
|
|
|
// Clean up previous HLS
|
|
|
|
|
if (hlsInstance.current) {
|
|
|
|
|
hlsInstance.current.destroy();
|
|
|
|
|
hlsInstance.current = null;
|
|
|
|
|
}
|
|
|
|
|
audio.pause();
|
|
|
|
|
audio.removeAttribute("src");
|
2025-07-30 08:58:18 -04:00
|
|
|
audio.load();
|
2025-08-09 07:10:04 -04:00
|
|
|
|
|
|
|
|
// Handle audio load errors
|
|
|
|
|
audio.onerror = () => {
|
2025-08-06 15:37:07 -04:00
|
|
|
setIsPlaying(false);
|
2025-08-09 07:10:04 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
|
|
|
|
audio.src = streamUrl;
|
|
|
|
|
audio.load();
|
|
|
|
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
|
|
|
|
setTrackTitle("Offline");
|
|
|
|
|
setIsPlaying(false);
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-07-30 08:58:18 -04:00
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
if (!Hls.isSupported()) {
|
|
|
|
|
console.error("HLS not supported");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-07-30 08:06:24 -04:00
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
const hls = new Hls({
|
2025-11-25 13:05:37 -05:00
|
|
|
lowLatencyMode: false,
|
2025-08-09 07:10:04 -04:00
|
|
|
abrEnabled: false,
|
2025-11-25 13:05:37 -05:00
|
|
|
liveSyncDuration: 0.6, // seconds behind live edge target
|
2025-09-22 11:15:24 -04:00
|
|
|
liveMaxLatencyDuration: 3.0, // max allowed latency before catchup
|
|
|
|
|
liveCatchUpPlaybackRate: 1.02,
|
2025-11-25 13:05:37 -05:00
|
|
|
// maxBufferLength: 30,
|
|
|
|
|
// maxMaxBufferLength: 60,
|
|
|
|
|
// maxBufferHole: 2.0,
|
|
|
|
|
// manifestLoadingTimeOut: 5000,
|
|
|
|
|
// fragLoadingTimeOut: 10000, // playback speed when catching up
|
2025-08-09 07:10:04 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hlsInstance.current = hls;
|
|
|
|
|
hls.attachMedia(audio);
|
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
|
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
|
|
|
|
setIsPlaying(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
|
|
|
console.warn("HLS error:", data);
|
|
|
|
|
if (data.fatal) {
|
|
|
|
|
hls.destroy();
|
|
|
|
|
hlsInstance.current = null;
|
|
|
|
|
setTrackTitle("Offline");
|
|
|
|
|
setIsPlaying(false);
|
|
|
|
|
}
|
2025-08-06 15:37:07 -04:00
|
|
|
});
|
2025-07-30 08:58:18 -04:00
|
|
|
});
|
2025-07-19 08:50:04 -04:00
|
|
|
};
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Update elapsed time smoothly
|
2025-07-30 08:58:18 -04:00
|
|
|
useEffect(() => {
|
2025-08-06 15:37:07 -04:00
|
|
|
const intervalId = setInterval(() => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
2025-09-26 11:36:33 -04:00
|
|
|
let liveElapsed = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec;
|
2025-08-06 15:37:07 -04:00
|
|
|
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
|
|
|
|
setElapsedTime(liveElapsed);
|
|
|
|
|
}, 200);
|
|
|
|
|
return () => clearInterval(intervalId);
|
|
|
|
|
}, [isPlaying, trackDuration]);
|
2025-07-30 07:58:44 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll active lyric into view
|
2025-07-17 06:55:01 -04:00
|
|
|
useEffect(() => {
|
2025-09-24 16:30:13 -04:00
|
|
|
setTimeout(() => {
|
|
|
|
|
const activeElement = document.querySelector('.lrc-line.active');
|
|
|
|
|
const lyricsContainer = document.querySelector('.lrc-text');
|
|
|
|
|
if (activeElement && lyricsContainer) {
|
|
|
|
|
lyricsContainer.style.maxHeight = '220px';
|
|
|
|
|
lyricsContainer.style.overflowY = 'auto';
|
|
|
|
|
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
|
|
|
|
}, [currentLyricIndex, lyrics]);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Handle station changes: reset and start new stream
|
|
|
|
|
useEffect(() => {
|
2025-09-26 11:49:06 -04:00
|
|
|
// Reset metadata and state when switching stations
|
2025-08-06 15:37:07 -04:00
|
|
|
setTrackTitle("");
|
|
|
|
|
setTrackArtist("");
|
|
|
|
|
setTrackGenre("");
|
|
|
|
|
setTrackAlbum("");
|
|
|
|
|
setCoverArt("/images/radio_art_default.jpg");
|
|
|
|
|
setLyrics([]);
|
|
|
|
|
setCurrentLyricIndex(0);
|
|
|
|
|
setElapsedTime(0);
|
|
|
|
|
setTrackDuration(0);
|
|
|
|
|
|
|
|
|
|
currentTrackUuid.current = null;
|
|
|
|
|
baseTrackElapsed.current = 0;
|
|
|
|
|
lastUpdateTimestamp.current = Date.now();
|
|
|
|
|
|
|
|
|
|
initializeStream(activeStation);
|
2025-09-26 11:49:06 -04:00
|
|
|
|
|
|
|
|
// Update page title to reflect the new station
|
2025-09-24 16:30:13 -04:00
|
|
|
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
2025-08-06 15:37:07 -04:00
|
|
|
}, [activeStation]);
|
2025-07-24 10:06:36 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
const parseLrcString = useCallback((lrcString) => {
|
|
|
|
|
if (!lrcString || typeof lrcString !== 'string') return [];
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
const lines = lrcString.split('\n').filter(line => line.trim());
|
|
|
|
|
const parsedLyrics = [];
|
2025-08-06 15:37:07 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
for (const line of lines) {
|
|
|
|
|
const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/);
|
|
|
|
|
if (match) {
|
|
|
|
|
const [, mins, secs, text] = match;
|
|
|
|
|
const timestamp = Number(mins) * 60 + parseFloat(secs);
|
|
|
|
|
if (!isNaN(timestamp) && text.trim()) {
|
|
|
|
|
parsedLyrics.push({ timestamp, line: text.trim() });
|
|
|
|
|
}
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
2025-09-26 11:36:33 -04:00
|
|
|
}
|
2025-08-06 15:37:07 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
return parsedLyrics.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
|
}, []);
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
const handleTrackData = useCallback((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')) {
|
|
|
|
|
setTrackTitle('No track playing');
|
2025-09-24 16:30:13 -04:00
|
|
|
setLyrics([]);
|
2025-09-26 11:36:33 -04:00
|
|
|
return;
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
2025-09-26 11:36:33 -04:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
} 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
|
2025-09-27 09:29:03 -04:00
|
|
|
}, []);
|
2025-09-26 11:36:33 -04:00
|
|
|
|
|
|
|
|
const initializeWebSocket = useCallback((station) => {
|
|
|
|
|
// Clean up existing WebSocket
|
|
|
|
|
if (wsInstance.current) {
|
2025-09-26 11:49:06 -04:00
|
|
|
wsInstance.current.onclose = null; // Prevent triggering reconnection logic
|
2025-09-26 11:36:33 -04:00
|
|
|
wsInstance.current.close();
|
|
|
|
|
wsInstance.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const connectWebSocket = (retryCount = 0) => {
|
|
|
|
|
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 () {
|
|
|
|
|
// 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
|
2025-09-27 09:29:03 -04:00
|
|
|
setLyrics([]);
|
|
|
|
|
setCurrentLyricIndex(0);
|
2025-09-26 11:36:33 -04:00
|
|
|
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) {
|
|
|
|
|
// Don't retry if it was a clean close (code 1000)
|
|
|
|
|
if (event.code === 1000) return;
|
|
|
|
|
|
|
|
|
|
// Attempt reconnection with exponential backoff
|
2025-09-27 09:29:03 -04:00
|
|
|
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
connectWebSocket(retryCount + 1);
|
|
|
|
|
}, delay);
|
2025-09-26 11:36:33 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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]);
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
const setTrackMetadata = useCallback((trackData, requestStation) => {
|
2025-09-24 22:40:48 -04:00
|
|
|
setTrackTitle(trackData.song || 'Unknown Title');
|
|
|
|
|
setTrackArtist(trackData.artist || 'Unknown Artist');
|
2025-09-24 16:30:13 -04:00
|
|
|
setTrackGenre(trackData.genre || '');
|
2025-09-24 22:40:48 -04:00
|
|
|
setTrackAlbum(trackData.album || 'Unknown Album');
|
2025-09-24 16:30:13 -04:00
|
|
|
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
|
|
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
const elapsed = typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0;
|
|
|
|
|
baseTrackElapsed.current = elapsed;
|
2025-09-24 16:30:13 -04:00
|
|
|
lastUpdateTimestamp.current = Date.now();
|
2025-09-26 11:36:33 -04:00
|
|
|
setElapsedTime(elapsed);
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-09-26 11:36:33 -04:00
|
|
|
setTrackDuration(trackData.duration || 0);
|
2025-09-24 16:30:13 -04:00
|
|
|
setPageTitle(trackData.artist, trackData.song);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-09-26 11:49:06 -04:00
|
|
|
// Ensure the ref points to the current activeStation for in-flight guards
|
2025-09-22 11:15:24 -04:00
|
|
|
activeStationRef.current = activeStation;
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-09-26 11:49:06 -04:00
|
|
|
// Clean up the existing WebSocket connection before initializing a new one
|
|
|
|
|
const cleanupWebSocket = async () => {
|
|
|
|
|
if (wsInstance.current) {
|
|
|
|
|
wsInstance.current.onclose = null; // Prevent triggering reconnection logic
|
|
|
|
|
wsInstance.current.close();
|
|
|
|
|
wsInstance.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
cleanupWebSocket().then(() => {
|
|
|
|
|
// Initialize WebSocket connection for metadata
|
|
|
|
|
initializeWebSocket(activeStation);
|
|
|
|
|
});
|
2025-09-26 11:36:33 -04:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
// Clean up WebSocket on station change or component unmount
|
|
|
|
|
if (wsInstance.current) {
|
2025-09-26 11:49:06 -04:00
|
|
|
wsInstance.current.onclose = null; // Prevent triggering reconnection logic
|
2025-09-26 11:36:33 -04:00
|
|
|
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;
|
2025-08-09 07:10:04 -04:00
|
|
|
const remaining = trackDuration - elapsedTime;
|
|
|
|
|
|
|
|
|
|
const progressColorClass =
|
|
|
|
|
progress >= 90
|
|
|
|
|
? "bg-red-500 dark:bg-red-400"
|
|
|
|
|
: progress >= 75 || remaining <= 20
|
|
|
|
|
? "bg-yellow-400 dark:bg-yellow-300"
|
|
|
|
|
: "bg-blue-500 dark:bg-blue-400";
|
|
|
|
|
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-07-24 10:06:36 -04:00
|
|
|
|
2025-09-24 22:40:48 -04:00
|
|
|
const handleSkip = async (uuid = null) => {
|
2025-09-24 16:30:13 -04:00
|
|
|
try {
|
|
|
|
|
const response = await authFetch(`${API_URL}/radio/skip`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2025-09-24 22:40:48 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
skipTo: uuid,
|
|
|
|
|
station: activeStation,
|
|
|
|
|
}),
|
2025-09-24 16:30:13 -04:00
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
2025-09-24 22:40:48 -04:00
|
|
|
if (data.success) {
|
|
|
|
|
toast.success("OK!");
|
|
|
|
|
fetchQueue();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Skip failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error skipping track:", error);
|
2025-09-24 22:40:48 -04:00
|
|
|
toast.error("Skip failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleReshuffle = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await authFetch(`${API_URL}/radio/reshuffle`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2025-09-24 22:40:48 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
station: activeStation,
|
|
|
|
|
}),
|
2025-09-24 16:30:13 -04:00
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
2025-09-24 22:40:48 -04:00
|
|
|
if (data.ok) {
|
|
|
|
|
toast.success("OK!");
|
|
|
|
|
fetchQueue();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Reshuffle failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error reshuffling queue:", error);
|
2025-09-24 22:40:48 -04:00
|
|
|
toast.error("Reshuffle failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-24 22:40:48 -04:00
|
|
|
const handleQueueShift = async (uuid, next = false) => {
|
2025-09-24 16:30:13 -04:00
|
|
|
try {
|
|
|
|
|
const response = await authFetch(`${API_URL}/radio/queue_shift`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2025-09-24 22:40:48 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
uuid,
|
|
|
|
|
next,
|
|
|
|
|
station: activeStation,
|
|
|
|
|
}),
|
2025-09-24 16:30:13 -04:00
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
2025-09-24 22:40:48 -04:00
|
|
|
if (!data.err) {
|
|
|
|
|
toast.success("OK!");
|
|
|
|
|
fetchQueue();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Queue shift failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error shifting queue:", error);
|
2025-09-24 22:40:48 -04:00
|
|
|
toast.error("Queue shift failed.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-24 22:40:48 -04:00
|
|
|
const handlePlayNow = async (artistSong, next = false) => {
|
|
|
|
|
const toastId = "playNowToast"; // Unique ID for this toast
|
|
|
|
|
if (!toast.isActive(toastId)) {
|
|
|
|
|
toast.info("Trying...", { toastId });
|
|
|
|
|
}
|
2025-09-24 16:30:13 -04:00
|
|
|
try {
|
2025-09-24 22:40:48 -04:00
|
|
|
const response = await authFetch(`${API_URL}/radio/request`, {
|
2025-09-24 16:30:13 -04:00
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2025-09-24 22:40:48 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
artistsong: artistSong,
|
|
|
|
|
alsoSkip: !next,
|
|
|
|
|
station: activeStation,
|
|
|
|
|
}),
|
2025-09-24 16:30:13 -04:00
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
2025-09-24 22:40:48 -04:00
|
|
|
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 });
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-09-24 22:40:48 -04:00
|
|
|
console.error("Error playing song immediately:", error);
|
|
|
|
|
toast.update(toastId, { render: "Play Now failed.", type: "error", autoClose: 3000 });
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-24 22:40:48 -04:00
|
|
|
const handleSongRequest = async (artistSong) => {
|
|
|
|
|
const toastId = "songRequestToast"; // Unique ID for this toast
|
|
|
|
|
if (!toast.isActive(toastId)) {
|
|
|
|
|
toast.info("Trying...", { toastId });
|
|
|
|
|
}
|
2025-09-24 16:30:13 -04:00
|
|
|
try {
|
|
|
|
|
const response = await authFetch(`${API_URL}/radio/request`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2025-09-24 22:40:48 -04:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
artistsong: artistSong,
|
|
|
|
|
alsoSkip: false, // Ensure alsoSkip is false for requests
|
|
|
|
|
station: activeStation,
|
|
|
|
|
}),
|
2025-09-24 16:30:13 -04:00
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
2025-09-24 22:40:48 -04:00
|
|
|
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 });
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error requesting song:", error);
|
2025-09-24 22:40:48 -04:00
|
|
|
toast.update(toastId, { render: "Song request failed.", type: "error", autoClose: 3000 });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRemoveFromQueue = async (uuid) => {
|
|
|
|
|
try {
|
2025-10-02 13:14:13 -04:00
|
|
|
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
|
2025-09-24 22:40:48 -04:00
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
station: activeStation,
|
2025-09-26 11:36:33 -04:00
|
|
|
uuid,
|
2025-09-24 22:40:48 -04:00
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
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.");
|
2025-09-24 16:30:13 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [queueSearch, setQueueSearch] = useState("");
|
|
|
|
|
const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => {
|
|
|
|
|
const start = page * rows;
|
2025-10-02 13:14:13 -04:00
|
|
|
console.log("Fetching queue for station (ref):", activeStationRef.current);
|
2025-09-24 16:30:13 -04:00
|
|
|
try {
|
|
|
|
|
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
2025-10-02 13:14:13 -04:00
|
|
|
station: activeStationRef.current, // Use ref to ensure latest value
|
2025-09-24 16:30:13 -04:00
|
|
|
start,
|
|
|
|
|
length: rows,
|
|
|
|
|
search,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
setQueueData(data.items || []);
|
2025-09-24 22:40:48 -04:00
|
|
|
setQueueTotalRecords(
|
|
|
|
|
typeof search === 'string' && search.length > 0
|
|
|
|
|
? data.recordsFiltered ?? data.recordsTotal ?? 0
|
|
|
|
|
: data.recordsTotal ?? 0
|
|
|
|
|
);
|
2025-09-24 16:30:13 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error fetching queue:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isQueueVisible) {
|
2025-10-02 13:14:13 -04:00
|
|
|
console.log("Fetching queue for station:", activeStation);
|
|
|
|
|
fetchQueue(queuePage, queueRows, queueSearch);
|
|
|
|
|
}
|
|
|
|
|
}, [isQueueVisible, queuePage, queueRows, queueSearch, activeStation]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
console.log("Active station changed to:", activeStation);
|
|
|
|
|
if (isQueueVisible) {
|
|
|
|
|
fetchQueue(queuePage, queueRows, queueSearch);
|
|
|
|
|
}
|
|
|
|
|
}, [activeStation]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isQueueVisible) {
|
|
|
|
|
console.log("Track changed, refreshing queue for station:", activeStation);
|
2025-09-24 16:30:13 -04:00
|
|
|
fetchQueue(queuePage, queueRows, queueSearch);
|
|
|
|
|
}
|
2025-10-02 13:14:13 -04:00
|
|
|
}, [currentTrackUuid]);
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
// Always define queueFooter, fallback to Close button if user is not available
|
2025-09-24 22:40:48 -04:00
|
|
|
const isDJ = (user && user.roles.includes('dj')) || ENVIRONMENT === "Dev";
|
2025-09-24 16:30:13 -04:00
|
|
|
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>
|
|
|
|
|
);
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
2025-07-17 06:55:01 -04:00
|
|
|
<>
|
|
|
|
|
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
|
2025-08-06 15:37:07 -04:00
|
|
|
{Object.entries(STATIONS).map(([stationKey, { label }]) => (
|
2025-07-17 06:55:01 -04:00
|
|
|
<button
|
2025-08-06 15:37:07 -04:00
|
|
|
key={stationKey}
|
|
|
|
|
onClick={() => setActiveStation(stationKey)}
|
|
|
|
|
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
2025-09-24 16:30:13 -04:00
|
|
|
? "bg-neutral-800 text-white dark:bg-white dark:text-black"
|
|
|
|
|
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
2025-08-06 15:37:07 -04:00
|
|
|
}`}
|
|
|
|
|
aria-pressed={activeStation === stationKey}
|
2025-07-17 06:55:01 -04:00
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
<div className="c-container">
|
2025-09-24 22:40:48 -04:00
|
|
|
{/* {user && <span className="text-lg font-semibold">Hello, {user.user}</span>} */}
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
<div className="music-container mt-8">
|
2025-07-17 06:55:01 -04:00
|
|
|
<section className="album-cover">
|
2025-08-06 15:37:07 -04:00
|
|
|
<div className="music-player__album" title="Album">
|
|
|
|
|
{trackAlbum}
|
|
|
|
|
</div>
|
2025-09-24 16:30:13 -04:00
|
|
|
<img src={coverArt} className="cover rounded-lg shadow-md" alt="Cover Art" />
|
2025-07-17 06:55:01 -04:00
|
|
|
</section>
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
<section className="music-player">
|
2025-09-24 16:30:13 -04:00
|
|
|
<h1 className="music-player__header text-2xl font-bold">serious.FM</h1>
|
|
|
|
|
<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>
|
2025-12-05 14:21:52 -05:00
|
|
|
<p className="music-time__last text-sm">-{formatTime(trackDuration - elapsedTime)}</p>
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
2025-07-30 07:58:44 -04:00
|
|
|
<div
|
2025-08-09 07:10:04 -04:00
|
|
|
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
|
|
|
|
style={{ width: `${progress}%` }}
|
|
|
|
|
></div>
|
2025-08-06 15:37:07 -04:00
|
|
|
</div>
|
2025-08-09 07:10:04 -04:00
|
|
|
|
2025-09-24 16:30:13 -04:00
|
|
|
<div className={`lrc-text mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800 ${lyrics.length === 0 ? "empty" : ""}`}>
|
2025-08-06 15:37:07 -04:00
|
|
|
{lyrics.map((lyricObj, index) => (
|
|
|
|
|
<p
|
|
|
|
|
key={index}
|
2025-09-24 16:30:13 -04:00
|
|
|
className={`lrc-line text-sm ${index === currentLyricIndex ? "active font-bold" : ""}`}
|
2025-08-06 15:37:07 -04:00
|
|
|
>
|
|
|
|
|
{lyricObj.line}
|
|
|
|
|
</p>
|
|
|
|
|
))}
|
2025-07-30 07:58:44 -04:00
|
|
|
</div>
|
2025-08-06 15:43:45 -04:00
|
|
|
|
2025-09-24 16:30:13 -04:00
|
|
|
{isDJ && (
|
2025-09-24 22:40:48 -04:00
|
|
|
<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) => {
|
|
|
|
|
fetchTypeahead(e.query);
|
|
|
|
|
}}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
setRequestInput(e.target.value);
|
|
|
|
|
}}
|
|
|
|
|
onShow={() => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const panel = document.querySelector('.p-autocomplete-panel');
|
|
|
|
|
const items = panel?.querySelector('.p-autocomplete-items');
|
|
|
|
|
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>
|
2025-09-24 16:30:13 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* Always show play/pause button */}
|
|
|
|
|
<div className="music-control mt-4 flex justify-center">
|
2025-07-19 08:50:04 -04:00
|
|
|
<div
|
|
|
|
|
className="music-control__play"
|
2025-08-06 15:37:07 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
const audio = audioElement.current;
|
|
|
|
|
if (!audio) return;
|
|
|
|
|
if (isPlaying) {
|
|
|
|
|
audio.pause();
|
|
|
|
|
setIsPlaying(false);
|
|
|
|
|
} else {
|
|
|
|
|
audio.play().then(() => setIsPlaying(true));
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-07-19 08:50:04 -04:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-pressed={isPlaying}
|
|
|
|
|
>
|
2025-08-06 15:37:07 -04:00
|
|
|
{isPlaying ? <Pause /> : <Play />}
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
2025-09-24 16:30:13 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
<audio ref={audioElement} preload="none" />
|
2025-09-24 16:30:13 -04:00
|
|
|
|
|
|
|
|
<Dialog
|
2025-09-24 22:40:48 -04:00
|
|
|
header={`Queue - ${activeStation}`}
|
2025-09-24 16:30:13 -04:00
|
|
|
visible={isQueueVisible}
|
|
|
|
|
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
|
|
|
|
|
footer={queueFooter}
|
|
|
|
|
onHide={() => setQueueVisible(false)}
|
|
|
|
|
className={theme === "dark" ? "dark-theme" : "light-theme"}
|
2025-09-24 22:40:48 -04:00
|
|
|
dismissableMask={true}
|
2025-09-24 16:30:13 -04:00
|
|
|
>
|
|
|
|
|
<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
|
2025-09-26 11:36:33 -04:00
|
|
|
alwaysShowPaginator={true}
|
2025-09-24 16:30:13 -04:00
|
|
|
rows={queueRows}
|
|
|
|
|
first={queuePage * queueRows}
|
|
|
|
|
totalRecords={queueTotalRecords}
|
|
|
|
|
onPage={(e) => {
|
|
|
|
|
setQueuePage(e.page);
|
2025-09-26 11:36:33 -04:00
|
|
|
fetchQueue(e.page, queueRows, queueSearch);
|
2025-09-24 16:30:13 -04:00
|
|
|
}}
|
2025-09-26 11:36:33 -04:00
|
|
|
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"
|
2025-09-24 16:30:13 -04:00
|
|
|
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' }}
|
2025-09-26 11:36:33 -04:00
|
|
|
body={(rowData) => <span title={rowData.artistsong} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
|
2025-09-24 16:30:13 -04:00
|
|
|
></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' }}
|
2025-09-26 11:36:33 -04:00
|
|
|
body={(rowData) => <span title={rowData.album} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
|
2025-09-24 16:30:13 -04:00
|
|
|
></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' }}
|
2025-09-26 11:36:33 -04:00
|
|
|
body={(rowData) => <span title={rowData.genre} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
|
2025-09-24 16:30:13 -04:00
|
|
|
></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"
|
2025-09-24 22:46:55 -04:00
|
|
|
onClick={() => handleQueueShift(rowData.uuid, false)}
|
2025-09-24 16:30:13 -04:00
|
|
|
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"
|
2025-09-24 22:46:55 -04:00
|
|
|
onClick={() => handleQueueShift(rowData.uuid, true)}
|
2025-09-24 16:30:13 -04:00
|
|
|
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"
|
2025-09-24 22:40:48 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
handleRemoveFromQueue(rowData.uuid);
|
|
|
|
|
}}
|
2025-09-24 16:30:13 -04:00
|
|
|
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>
|
2025-07-17 06:55:01 -04:00
|
|
|
</>
|
|
|
|
|
);
|
2025-07-30 07:58:44 -04:00
|
|
|
}
|