import { useLocation } from 'react-router-dom'; import React, { useEffect, useMemo, useState } from 'react'; import usePlaylistsStore from '../../store/playlists.jsx'; import useSettingsStore from '../../store/settings.jsx'; import useUsersStore from '../../store/users.jsx'; import { ActionIcon, Badge, Box, Card, Center, Group, Progress, Select, Stack, Text, Tooltip, useMantineTheme, } from '@mantine/core'; import { ChevronDown, ChevronRight, CirclePlay, Gauge, HardDriveDownload, HardDriveUpload, Radio, SquareX, Timer, Users, Video, } from 'lucide-react'; import { toFriendlyDuration, useDateTimeFormat, } from '../../utils/dateTimeUtils.js'; import { CustomTable, useTable } from '../tables/CustomTable/index.jsx'; import { TableHelper } from '../../helpers/index.jsx'; import logo from '../../images/logo.png'; import { formatBytes, formatSpeed } from '../../utils/networkUtils.js'; import { showNotification } from '../../utils/notificationUtils.js'; import { connectedAccessor, durationAccessor, getBufferingSpeedThreshold, getChannelStreams, getLogoUrl, getM3uAccountsMap, getSelectedStream, getStartDate, getStreamOptions, getStreamsByIds, switchStream, } from '../../utils/cards/StreamConnectionCardUtils.js'; import useVideoStore from '../../store/useVideoStore'; const formatProgramTime = (seconds) => { const absSeconds = Math.abs(seconds); const hours = Math.floor(absSeconds / 3600); const minutes = Math.floor((absSeconds % 3600) / 60); const secs = Math.floor(absSeconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; }; const ProgramProgress = ({ currentProgram }) => { const now = new Date(); const startTime = new Date(currentProgram.start_time); const endTime = new Date(currentProgram.end_time); const totalDuration = (endTime - startTime) / 1000; // in seconds const elapsed = (now - startTime) / 1000; // in seconds const remaining = (endTime - now) / 1000; // in seconds const percentage = Math.min( 100, Math.max(0, (elapsed / totalDuration) * 100) ); return ( {formatProgramTime(elapsed)} elapsed {formatProgramTime(remaining)} remaining ); }; // Create a separate component for each channel card to properly handle the hook const StreamConnectionCard = ({ channel, clients, stopClient, stopChannel, logos, channelsByUUID, channels, currentProgram, }) => { const location = useLocation(); const [availableStreams, setAvailableStreams] = useState([]); const [isLoadingStreams, setIsLoadingStreams] = useState(false); const [activeStreamId, setActiveStreamId] = useState(null); const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile const [data, setData] = useState([]); const [previewedStream, setPreviewedStream] = useState(null); const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false); const theme = useMantineTheme(); // Get M3U account data from the playlists store const m3uAccounts = usePlaylistsStore((s) => s.playlists); // Get users for resolving user_id → username on client rows const users = useUsersStore((s) => s.users); // Get settings for speed threshold and environment mode const settings = useSettingsStore((s) => s.settings); const env_mode = useSettingsStore((s) => s.environment?.env_mode) || 'production'; // Get video preview function const showVideo = useVideoStore((s) => s.showVideo); // Get user's date/time format preferences const { fullDateTimeFormat } = useDateTimeFormat(); // Create a map of M3U account IDs to names for quick lookup const m3uAccountsMap = useMemo(() => { return getM3uAccountsMap(m3uAccounts); }, [m3uAccounts]); // Create a map of user IDs to usernames for quick lookup const usersMap = useMemo(() => { const map = {}; users.forEach((u) => { map[String(u.id)] = u.username; }); return map; }, [users]); // Update M3U profile information when channel data changes useEffect(() => { // If the channel data includes M3U profile information, update our state if (channel.m3u_profile || channel.m3u_profile_name) { setCurrentM3UProfile({ name: channel.m3u_profile?.name || channel.m3u_profile_name || 'Default M3U', }); } }, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]); // Fetch available streams for this channel useEffect(() => { const fetchStreams = async () => { setIsLoadingStreams(true); try { // Get channel ID from UUID const channelId = channelsByUUID[channel.channel_id]; if (channelId) { const streamData = await getChannelStreams(channelId); // Use streams in the order returned by the API without sorting setAvailableStreams(streamData); // Match by server-reported stream_id. if (channel.stream_id && streamData.length > 0) { const matchingStream = streamData.find( (s) => s.id.toString() === channel.stream_id.toString() ); if (matchingStream) { setActiveStreamId(matchingStream.id.toString()); if (matchingStream.m3u_profile) { setCurrentM3UProfile(matchingStream.m3u_profile); } } } } } catch (error) { console.error('Error fetching streams:', error); } finally { setIsLoadingStreams(false); } }; fetchStreams(); }, [channel.channel_id, channel.stream_id, channelsByUUID]); useEffect(() => { setData( clients .filter((client) => client.channel.channel_id === channel.channel_id) .map((client) => ({ id: client.client_id, ...client, })) ); }, [clients, channel.channel_id]); const renderHeaderCell = (header) => { switch (header.id) { default: return ( {header.column.columnDef.header} ); } }; const renderBodyCell = ({ cell, row }) => { switch (cell.column.id) { case 'actions': return (
stopClient( row.original.channel.uuid, row.original.client_id ) } >
); } }; const checkStreamsAfterChange = (streamId) => { return async () => { try { const channelId = channelsByUUID[channel.channel_id]; if (channelId) { const updatedStreamData = await getChannelStreams(channelId); console.log('Channel streams after switch:', updatedStreamData); // Update current stream information with fresh data const updatedStream = getSelectedStream(updatedStreamData, streamId); if (updatedStream?.m3u_profile) { setCurrentM3UProfile(updatedStream.m3u_profile); } } } catch (error) { console.error('Error checking streams after switch:', error); } }; }; // Handle stream switching const handleStreamChange = async (streamId) => { try { console.log('Switching to stream ID:', streamId); // Find the selected stream in availableStreams for debugging const selectedStream = getSelectedStream(availableStreams, streamId); console.log('Selected stream details:', selectedStream); // Make sure we're passing the correct ID to the API const response = await switchStream(channel, streamId); console.log('Stream switch API response:', response); // Update the local active stream ID immediately setActiveStreamId(streamId); // Update M3U profile information if available in the response if (response?.m3u_profile) { setCurrentM3UProfile(response.m3u_profile); } else if (selectedStream && selectedStream.m3u_profile) { // Fallback to the profile from the selected stream setCurrentM3UProfile(selectedStream.m3u_profile); } // Show detailed notification with stream name showNotification({ title: 'Stream switching', message: `Switching to "${selectedStream?.name}" for ${channel.name}`, color: 'blue.5', }); // After a short delay, fetch streams again to confirm the switch setTimeout(checkStreamsAfterChange(streamId), 2000); } catch (error) { console.error('Stream switch error:', error); showNotification({ title: 'Error switching stream', message: error.toString(), color: 'red.5', }); } }; const clientsColumns = useMemo( () => [ { id: 'expand', size: 20, }, { header: 'IP Address', accessorKey: 'ip_address', grow: true, minSize: 85, cell: ({ cell }) => ( {cell.getValue()} ), }, { id: 'user', header: 'User', grow: true, minSize: 60, accessorFn: (row) => { const uid = row.user_id ? String(row.user_id) : null; if (!uid || uid === '0') return 'Anonymous'; return usersMap[uid] || `User ${uid}`; }, cell: ({ cell }) => ( {cell.getValue()} ), }, // Updated Connected column with tooltip { id: 'connected', header: 'Connected', grow: 1.5, minSize: 70, maxSize: 150, accessorFn: connectedAccessor(fullDateTimeFormat), cell: ({ cell }) => ( {cell.getValue()} ), }, // Update Duration column with tooltip showing exact seconds { id: 'duration', header: 'Duration', size: 82, minSize: 60, accessorFn: durationAccessor(), cell: ({ cell, row }) => { const exactDuration = row.original.connected_since || row.original.connection_duration; return ( {cell.getValue()} ); }, }, { id: 'actions', header: 'Actions', size: 60, minSize: 40, }, ], [fullDateTimeFormat, usersMap] ); const channelClientsTable = useTable({ ...TableHelper.defaultProperties, columns: clientsColumns, data, allRowIds: data.map((client) => client.id), tableCellProps: () => ({ padding: 4, borderColor: '#444', color: '#E0E0E0', fontSize: '0.85rem', }), headerCellRenderFns: { ip_address: renderHeaderCell, user: renderHeaderCell, connected: renderHeaderCell, duration: renderHeaderCell, actions: renderHeaderCell, }, bodyCellRenderFns: { actions: renderBodyCell, }, getExpandedRowHeight: (row) => { return 20 + 28 * row.original.streams.length; }, expandedRowRenderer: ({ row }) => { return ( User Agent: {row.original.user_agent || 'Unknown'} ); }, mantineExpandButtonProps: ({ row, table }) => ({ size: 'xs', style: { transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', transition: 'transform 0.2s', }, }), displayColumnDefOptions: { 'mrt-row-expand': { size: 15, header: '', }, 'mrt-row-actions': { size: 74, }, }, }); // Get logo URL from the logos object if available const logoUrl = getLogoUrl(channel.logo_id, logos, previewedStream); useEffect(() => { let isMounted = true; // Only fetch if we have a stream_id and NO channel.name if (!channel.name && channel.stream_id) { getStreamsByIds(channel.stream_id).then((streams) => { if (isMounted && streams && streams.length > 0) { setPreviewedStream(streams[0]); } }); } return () => { isMounted = false; }; }, [channel.name, channel.stream_id]); const channelName = channel.name || previewedStream?.name || 'Unnamed Channel'; const uptime = channel.uptime || 0; const bitrates = channel.bitrates || []; const totalBytes = channel.total_bytes || 0; const clientCount = channel.client_count || 0; const avgBitrate = channel.avg_bitrate || '0 Kbps'; const streamProfileName = channel.stream_profile?.name || 'Unknown Profile'; // Use currentM3UProfile if available, otherwise fall back to channel data const m3uProfileName = currentM3UProfile?.name || channel.m3u_profile?.name || channel.m3u_profile_name || 'Unknown M3U Profile'; // Create select options for available streams const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap); // Handle preview channel button click const handlePreviewChannel = () => { const channelDbId = channelsByUUID[channel.channel_id]; if (!channelDbId) return; const actualChannel = channels[channelDbId]; if (!actualChannel?.uuid) return; const uri = `/proxy/ts/stream/${actualChannel.uuid}`; let url = `${window.location.protocol}//${window.location.host}${uri}`; if (env_mode === 'dev') { url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; } showVideo(url, 'live', { name: actualChannel.name }); }; if (location.pathname !== '/stats') { return <>; } // Safety check - if channel doesn't have required data, don't render if (!channel || !channel.channel_id) { return null; } return ( channel logo
{toFriendlyDuration(uptime, 'seconds')}
stopChannel(channel.channel_id)} >
{/* Stream Profile on right - absolutely positioned */} {/* M3U Profile on right - absolutely positioned */} {m3uProfileName} {/* Channel Name on left */} {channelName} {/* Display current program on its own line */} {currentProgram && ( Now Playing: {currentProgram.title} setIsProgramDescExpanded(!isProgramDescExpanded)} style={{ flexShrink: 0 }} > {isProgramDescExpanded ? ( ) : ( )} )} {/* Expandable program description */} {currentProgram && isProgramDescExpanded && currentProgram.description && ( {currentProgram.description} )} {/* Program progress bar */} {currentProgram && isProgramDescExpanded && currentProgram.start_time && currentProgram.end_time && ( )} {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && (