Refactor components to TypeScript, enhance media request handling, and improve UI elements
- Updated Radio component to TypeScript and added WebSocket connection checks. - Enhanced DiscordLogs component with thread detection and improved bot presence checks. - Modified Login component to include a client ID for authentication. - Refactored RadioBanner for better styling and accessibility. - Improved BreadcrumbNav with Astro reload attribute for better navigation. - Enhanced MediaRequestForm to prevent rapid clicks during track play/pause. - Updated RequestManagement to handle track lists and finalizing job status more effectively. - Improved CSS for RequestManagement to enhance progress bar and track list display.
This commit is contained in:
@@ -156,6 +156,10 @@ export default function Player({ user }: PlayerProps) {
|
||||
const lastUpdateTimestamp = useRef<number>(Date.now());
|
||||
const activeStationRef = useRef<string>(activeStation);
|
||||
const wsInstance = useRef<WebSocket | null>(null);
|
||||
const wsReconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wsConnectionCheckTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const wsLastMessageTime = useRef<number>(Date.now());
|
||||
const [isStreamReady, setIsStreamReady] = useState(false);
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
|
||||
@@ -171,6 +175,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
|
||||
// Initialize or switch HLS stream
|
||||
const initializeStream = (station) => {
|
||||
setIsStreamReady(false);
|
||||
import('hls.js').then(({ default: Hls }) => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
@@ -188,11 +193,13 @@ export default function Player({ user }: PlayerProps) {
|
||||
// Handle audio load errors
|
||||
audio.onerror = () => {
|
||||
setIsPlaying(false);
|
||||
setIsStreamReady(false);
|
||||
};
|
||||
|
||||
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
audio.src = streamUrl;
|
||||
audio.load();
|
||||
setIsStreamReady(true);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
@@ -222,6 +229,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
hls.attachMedia(audio);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsStreamReady(true);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
@@ -233,6 +241,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
hlsInstance.current = null;
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
setIsStreamReady(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -351,11 +360,20 @@ export default function Player({ user }: PlayerProps) {
|
||||
const handleTrackData = useCallback((trackData) => {
|
||||
const requestStation = activeStationRef.current;
|
||||
|
||||
// Guard: if trackData is null/undefined or empty object, ignore
|
||||
if (!trackData || (typeof trackData === 'object' && Object.keys(trackData).length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set "No track playing" if we have clear indication of no track
|
||||
// and not just missing song field (to avoid flickering during transitions)
|
||||
// AND we don't already have a valid track playing (to prevent race conditions)
|
||||
if ((!trackData.song || trackData.song === 'N/A') && (!trackData.artist || trackData.artist === 'N/A')) {
|
||||
setTrackTitle('No track playing');
|
||||
setLyrics([]);
|
||||
// Only clear if we don't have a current track UUID (meaning we never had valid data)
|
||||
if (!currentTrackUuid.current) {
|
||||
setTrackTitle('No track playing');
|
||||
setLyrics([]);
|
||||
}
|
||||
// Otherwise ignore this empty data - keep showing current track
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -383,27 +401,65 @@ export default function Player({ user }: PlayerProps) {
|
||||
}, []);
|
||||
|
||||
const initializeWebSocket = useCallback((station) => {
|
||||
// Clean up existing WebSocket
|
||||
// Clean up existing WebSocket and timers
|
||||
if (wsReconnectTimer.current) {
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
wsReconnectTimer.current = null;
|
||||
}
|
||||
if (wsConnectionCheckTimer.current) {
|
||||
clearInterval(wsConnectionCheckTimer.current);
|
||||
wsConnectionCheckTimer.current = null;
|
||||
}
|
||||
if (wsInstance.current) {
|
||||
wsInstance.current.onclose = null; // Prevent triggering reconnection logic
|
||||
wsInstance.current.onerror = null;
|
||||
wsInstance.current.close();
|
||||
wsInstance.current = null;
|
||||
}
|
||||
|
||||
const connectWebSocket = (retryCount = 0) => {
|
||||
const baseDelay = 1000; // 1 second
|
||||
const maxDelay = 30000; // 30 seconds
|
||||
let retryCount = 0;
|
||||
const baseDelay = 1000; // 1 second
|
||||
const maxDelay = 30000; // 30 seconds
|
||||
let isIntentionallyClosed = false;
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (isIntentionallyClosed) return;
|
||||
|
||||
const wsUrl = `${API_URL.replace(/^https?:/, 'wss:')}/radio/ws/${station}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function () {
|
||||
// Reset retry count on successful connection
|
||||
retryCount = 0;
|
||||
wsLastMessageTime.current = Date.now();
|
||||
|
||||
// Start periodic connection check - if no messages received for 2 minutes,
|
||||
// the connection is likely dead (server sends track updates regularly)
|
||||
if (wsConnectionCheckTimer.current) {
|
||||
clearInterval(wsConnectionCheckTimer.current);
|
||||
}
|
||||
wsConnectionCheckTimer.current = setInterval(() => {
|
||||
const timeSinceLastMessage = Date.now() - wsLastMessageTime.current;
|
||||
const isStale = timeSinceLastMessage > 120000; // 2 minutes without any message
|
||||
|
||||
// Check if connection appears dead
|
||||
if (ws.readyState !== WebSocket.OPEN || isStale) {
|
||||
console.warn('WebSocket connection appears stale, reconnecting...');
|
||||
// Force close and let onclose handle reconnection
|
||||
ws.close();
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
};
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
// Track last message time for connection health monitoring
|
||||
wsLastMessageTime.current = Date.now();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Ignore pong responses and other control messages
|
||||
if (data.type === 'pong' || data.type === 'ping' || data.type === 'error' || data.type === 'status') return;
|
||||
|
||||
if (data.type === 'track_change') {
|
||||
// Handle track change
|
||||
@@ -415,24 +471,35 @@ export default function Player({ user }: PlayerProps) {
|
||||
const parsedLyrics = parseLrcString(data.data);
|
||||
setLyrics(parsedLyrics);
|
||||
setCurrentLyricIndex(0);
|
||||
} else {
|
||||
// Handle initial now playing data
|
||||
} else if (data.type === 'now_playing' || data.type === 'initial') {
|
||||
// Explicit now playing message
|
||||
handleTrackData(data.data || data);
|
||||
} else if (!data.type && (data.song || data.artist || data.uuid)) {
|
||||
// Untyped message with track data fields - treat as now playing
|
||||
handleTrackData(data);
|
||||
}
|
||||
// Ignore any other message types that don't contain track 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;
|
||||
// Clear connection check timer
|
||||
if (wsConnectionCheckTimer.current) {
|
||||
clearInterval(wsConnectionCheckTimer.current);
|
||||
wsConnectionCheckTimer.current = null;
|
||||
}
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
// Always attempt reconnection unless intentionally closed
|
||||
if (isIntentionallyClosed) return;
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
||||
retryCount++;
|
||||
|
||||
setTimeout(() => {
|
||||
connectWebSocket(retryCount + 1);
|
||||
wsReconnectTimer.current = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
@@ -445,6 +512,25 @@ export default function Player({ user }: PlayerProps) {
|
||||
};
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
isIntentionallyClosed = true;
|
||||
if (wsReconnectTimer.current) {
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
wsReconnectTimer.current = null;
|
||||
}
|
||||
if (wsConnectionCheckTimer.current) {
|
||||
clearInterval(wsConnectionCheckTimer.current);
|
||||
wsConnectionCheckTimer.current = null;
|
||||
}
|
||||
if (wsInstance.current) {
|
||||
wsInstance.current.onclose = null;
|
||||
wsInstance.current.onerror = null;
|
||||
wsInstance.current.close();
|
||||
wsInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [handleTrackData, parseLrcString]);
|
||||
|
||||
const setTrackMetadata = useCallback((trackData, requestStation) => {
|
||||
@@ -467,34 +553,43 @@ export default function Player({ user }: PlayerProps) {
|
||||
// Ensure the ref points to the current activeStation for in-flight guards
|
||||
activeStationRef.current = activeStation;
|
||||
|
||||
// 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;
|
||||
// Initialize WebSocket connection for metadata
|
||||
const cleanupWs = initializeWebSocket(activeStation);
|
||||
|
||||
// Handle visibility change - reconnect when page becomes visible
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Check if WebSocket is not connected or not open
|
||||
if (!wsInstance.current || wsInstance.current.readyState !== WebSocket.OPEN) {
|
||||
// Reinitialize WebSocket connection
|
||||
if (cleanupWs) cleanupWs();
|
||||
initializeWebSocket(activeStation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
cleanupWebSocket().then(() => {
|
||||
// Initialize WebSocket connection for metadata
|
||||
initializeWebSocket(activeStation);
|
||||
});
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
// Clean up WebSocket on station change or component unmount
|
||||
if (wsInstance.current) {
|
||||
wsInstance.current.onclose = null; // Prevent triggering reconnection logic
|
||||
wsInstance.current.close();
|
||||
wsInstance.current = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (cleanupWs) cleanupWs();
|
||||
};
|
||||
}, [activeStation, initializeWebSocket]);
|
||||
|
||||
// Cleanup WebSocket on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wsReconnectTimer.current) {
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
wsReconnectTimer.current = null;
|
||||
}
|
||||
if (wsConnectionCheckTimer.current) {
|
||||
clearInterval(wsConnectionCheckTimer.current);
|
||||
wsConnectionCheckTimer.current = null;
|
||||
}
|
||||
if (wsInstance.current) {
|
||||
wsInstance.current.onclose = null;
|
||||
wsInstance.current.onerror = null;
|
||||
wsInstance.current.close();
|
||||
wsInstance.current = null;
|
||||
}
|
||||
@@ -666,7 +761,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
const [queueSearch, setQueueSearch] = useState("");
|
||||
const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => {
|
||||
const start = page * rows;
|
||||
console.log("Fetching queue for station (ref):", activeStationRef.current);
|
||||
// console.log("Fetching queue for station (ref):", activeStationRef.current);
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
||||
method: "POST",
|
||||
@@ -692,13 +787,13 @@ export default function Player({ user }: PlayerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueueVisible) {
|
||||
console.log("Fetching queue for station:", activeStation);
|
||||
// console.log("Fetching queue for station:", activeStation);
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
}, [isQueueVisible, queuePage, queueRows, queueSearch, activeStation]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Active station changed to:", activeStation);
|
||||
// console.log("Active station changed to:", activeStation);
|
||||
if (isQueueVisible) {
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
@@ -706,7 +801,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueueVisible) {
|
||||
console.log("Track changed, refreshing queue for station:", activeStation);
|
||||
// console.log("Track changed, refreshing queue for station:", activeStation);
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
}, [currentTrackUuid]);
|
||||
@@ -907,14 +1002,27 @@ export default function Player({ user }: PlayerProps) {
|
||||
<div className="music-control mt-4 flex justify-center">
|
||||
<div
|
||||
className="music-control__play"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().then(() => setIsPlaying(true));
|
||||
// If stream is not ready, reinitialize it
|
||||
if (!isStreamReady || !audio.src || audio.error) {
|
||||
initializeStream(activeStation);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await audio.play();
|
||||
setIsPlaying(true);
|
||||
} catch (err) {
|
||||
console.warn('Playback failed, reinitializing stream:', err);
|
||||
// Reinitialize stream on playback failure
|
||||
initializeStream(activeStation);
|
||||
}
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
@@ -962,7 +1070,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
fetchQueue(e.page ?? 0, queueRows, queueSearch);
|
||||
}}
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
|
||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"
|
||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} tracks"
|
||||
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"
|
||||
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
|
||||
@@ -982,7 +1090,7 @@ export default function Player({ user }: PlayerProps) {
|
||||
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 title={rowData.artistsong} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
|
||||
body={(rowData) => <span title={`${rowData.artist} - ${rowData.song}`} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artist} - {rowData.song}</span>}
|
||||
></Column>
|
||||
<Column
|
||||
field="album"
|
||||
|
||||
Reference in New Issue
Block a user