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:
2026-01-25 13:11:25 -05:00
parent 256d5d9c7f
commit 1da33de892
9 changed files with 477 additions and 115 deletions

View File

@@ -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"