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 (
{toFriendlyDuration(uptime, 'seconds')}
stopChannel(channel.channel_id)}
>
{/* Stream Profile on right - absolutely positioned */}
{streamProfileName}
{/* 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 && (
{channel.name && (
)}
)}
{/* Add stream information badges */}
{channel.resolution && (
{channel.resolution}
)}
{channel.source_fps && (
{channel.source_fps} FPS
)}
{channel.video_codec && (
{channel.video_codec.toUpperCase()}
)}
{channel.audio_codec && (
{channel.audio_codec.toUpperCase()}
)}
{channel.audio_channels && (
{channel.audio_channels}
)}
{channel.stream_type && (
{channel.stream_type.toUpperCase()}
)}
{channel.ffmpeg_speed && (
=
getBufferingSpeedThreshold(settings['proxy_settings'])
? 'green'
: 'red'
}
>
{parseFloat(channel.ffmpeg_speed).toFixed(2)}x
)}
{formatSpeed(bitrates.at(-1) || 0)}
Avg: {avgBitrate}
{formatBytes(totalBytes)}
{clientCount}
);
};
export default StreamConnectionCard;