mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
590 lines
No EOL
18 KiB
JavaScript
590 lines
No EOL
18 KiB
JavaScript
import { useLocation } from 'react-router-dom';
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
|
|
import usePlaylistsStore from '../../store/playlists.jsx';
|
|
import useSettingsStore from '../../store/settings.jsx';
|
|
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
|
|
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
|
|
import { toFriendlyDuration } 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,
|
|
getMatchingStreamByUrl,
|
|
getSelectedStream,
|
|
getStartDate,
|
|
getStreamOptions,
|
|
getStreamsByIds,
|
|
switchStream,
|
|
} from '../../utils/cards/StreamConnectionCardUtils.js';
|
|
|
|
// Create a separate component for each channel card to properly handle the hook
|
|
const StreamConnectionCard = ({
|
|
channel,
|
|
clients,
|
|
stopClient,
|
|
stopChannel,
|
|
logos,
|
|
channelsByUUID,
|
|
}) => {
|
|
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);
|
|
|
|
// Get M3U account data from the playlists store
|
|
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
|
|
// Get settings for speed threshold
|
|
const settings = useSettingsStore((s) => s.settings);
|
|
|
|
// Get Date-format from localStorage
|
|
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
|
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
|
|
const [tableSize] = useLocalStorage('table-size', 'default');
|
|
|
|
// Create a map of M3U account IDs to names for quick lookup
|
|
const m3uAccountsMap = useMemo(() => {
|
|
return getM3uAccountsMap(m3uAccounts);
|
|
}, [m3uAccounts]);
|
|
|
|
// 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);
|
|
|
|
// If we have a channel URL, try to find the matching stream
|
|
if (channel.url && streamData.length > 0) {
|
|
// Try to find matching stream based on URL
|
|
const matchingStream = getMatchingStreamByUrl(streamData, channel.url);
|
|
|
|
if (matchingStream) {
|
|
setActiveStreamId(matchingStream.id.toString());
|
|
|
|
// If the stream has M3U profile info, save it
|
|
if (matchingStream.m3u_profile) {
|
|
setCurrentM3UProfile(matchingStream.m3u_profile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching streams:', error);
|
|
} finally {
|
|
setIsLoadingStreams(false);
|
|
}
|
|
};
|
|
|
|
fetchStreams();
|
|
}, [channel.channel_id, channel.url, 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 (
|
|
<Group>
|
|
<Text size="sm" name={header.id}>
|
|
{header.column.columnDef.header}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderBodyCell = ({ cell, row }) => {
|
|
switch (cell.column.id) {
|
|
case 'actions':
|
|
return (
|
|
<Box sx={{ justifyContent: 'right' }}>
|
|
<Center>
|
|
<Tooltip label="Disconnect client">
|
|
<ActionIcon
|
|
size="sm"
|
|
variant="transparent"
|
|
color="red.9"
|
|
onClick={() =>
|
|
stopClient(
|
|
row.original.channel.uuid,
|
|
row.original.client_id
|
|
)
|
|
}
|
|
>
|
|
<SquareX size="18" />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Center>
|
|
</Box>
|
|
);
|
|
}
|
|
};
|
|
|
|
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',
|
|
},
|
|
// Updated Connected column with tooltip
|
|
{
|
|
id: 'connected',
|
|
header: 'Connected',
|
|
accessorFn: connectedAccessor(dateFormat),
|
|
cell: ({ cell }) => (
|
|
<Tooltip
|
|
label={
|
|
cell.getValue() !== 'Unknown'
|
|
? `Connected at ${cell.getValue()}`
|
|
: 'Unknown connection time'
|
|
}
|
|
>
|
|
<Text size="xs">{cell.getValue()}</Text>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
// Update Duration column with tooltip showing exact seconds
|
|
{
|
|
id: 'duration',
|
|
header: 'Duration',
|
|
accessorFn: durationAccessor(),
|
|
cell: ({ cell, row }) => {
|
|
const exactDuration =
|
|
row.original.connected_since || row.original.connection_duration;
|
|
return (
|
|
<Tooltip
|
|
label={
|
|
exactDuration
|
|
? `${exactDuration.toFixed(1)} seconds`
|
|
: 'Unknown duration'
|
|
}
|
|
>
|
|
<Text size="xs">{cell.getValue()}</Text>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: 'Actions',
|
|
size: tableSize == 'compact' ? 75 : 100,
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
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,
|
|
connected: renderHeaderCell,
|
|
duration: renderHeaderCell,
|
|
actions: renderHeaderCell,
|
|
},
|
|
bodyCellRenderFns: {
|
|
actions: renderBodyCell,
|
|
},
|
|
getExpandedRowHeight: (row) => {
|
|
return 20 + 28 * row.original.streams.length;
|
|
},
|
|
expandedRowRenderer: ({ row }) => {
|
|
return (
|
|
<Box p="xs">
|
|
<Group spacing="xs" align="flex-start">
|
|
<Text size="xs" fw={500} color="dimmed">
|
|
User Agent:
|
|
</Text>
|
|
<Text size="xs">{row.original.user_agent || 'Unknown'}</Text>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
},
|
|
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);
|
|
|
|
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 (
|
|
<Card
|
|
key={channel.channel_id}
|
|
shadow="sm"
|
|
padding="md"
|
|
radius="md"
|
|
withBorder
|
|
style={{
|
|
backgroundColor: '#27272A',
|
|
}}
|
|
color='#fff'
|
|
maw={700}
|
|
w={'100%'}
|
|
>
|
|
<Stack pos='relative' >
|
|
<Group justify="space-between">
|
|
<Box
|
|
style={{
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
w={100}
|
|
h={50}
|
|
display='flex'
|
|
>
|
|
<img
|
|
src={logoUrl || logo}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
}}
|
|
alt="channel logo"
|
|
/>
|
|
</Box>
|
|
|
|
<Group>
|
|
<Box>
|
|
<Tooltip label={getStartDate(uptime)}>
|
|
<Center>
|
|
<Timer pr={5} />
|
|
{toFriendlyDuration(uptime, 'seconds')}
|
|
</Center>
|
|
</Tooltip>
|
|
</Box>
|
|
<Center>
|
|
<Tooltip label="Stop Channel">
|
|
<ActionIcon
|
|
variant="transparent"
|
|
color="red.9"
|
|
onClick={() => stopChannel(channel.channel_id)}
|
|
>
|
|
<SquareX size="24" />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Center>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Flex justify="space-between" align="center">
|
|
<Group>
|
|
<Text fw={500}>{channelName}</Text>
|
|
</Group>
|
|
|
|
<Tooltip label="Active Stream Profile">
|
|
<Group gap={5}>
|
|
<Video size="18" />
|
|
{streamProfileName}
|
|
</Group>
|
|
</Tooltip>
|
|
</Flex>
|
|
|
|
{/* Display M3U profile information */}
|
|
<Flex justify="flex-end" align="center" mt={-8}>
|
|
<Group gap={5}>
|
|
<HardDriveUpload size="18" />
|
|
<Tooltip label="Current M3U Profile">
|
|
<Text size="xs">{m3uProfileName}</Text>
|
|
</Tooltip>
|
|
</Group>
|
|
</Flex>
|
|
|
|
{/* Add stream selection dropdown */}
|
|
{availableStreams.length > 0 && (
|
|
<Tooltip label="Switch to another stream source">
|
|
<Select
|
|
size="xs"
|
|
label="Active Stream"
|
|
placeholder={
|
|
isLoadingStreams ? 'Loading streams...' : 'Select stream'
|
|
}
|
|
data={streamOptions}
|
|
value={activeStreamId || channel.stream_id?.toString() || null}
|
|
onChange={handleStreamChange}
|
|
disabled={isLoadingStreams}
|
|
mt={8}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* Add stream information badges */}
|
|
<Group gap="xs" mt="xs">
|
|
{channel.resolution && (
|
|
<Tooltip label="Video resolution">
|
|
<Badge size="sm" variant="light" color="red">
|
|
{channel.resolution}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.source_fps && (
|
|
<Tooltip label="Source frames per second">
|
|
<Badge size="sm" variant="light" color="orange">
|
|
{channel.source_fps} FPS
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.video_codec && (
|
|
<Tooltip label="Video codec">
|
|
<Badge size="sm" variant="light" color="blue">
|
|
{channel.video_codec.toUpperCase()}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.audio_codec && (
|
|
<Tooltip label="Audio codec">
|
|
<Badge size="sm" variant="light" color="pink">
|
|
{channel.audio_codec.toUpperCase()}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.audio_channels && (
|
|
<Tooltip label="Audio channel configuration">
|
|
<Badge size="sm" variant="light" color="pink">
|
|
{channel.audio_channels}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.stream_type && (
|
|
<Tooltip label="Stream type">
|
|
<Badge size="sm" variant="light" color="cyan">
|
|
{channel.stream_type.toUpperCase()}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{channel.ffmpeg_speed && (
|
|
<Tooltip
|
|
label={`Current Speed: ${parseFloat(channel.ffmpeg_speed).toFixed(2)}x`}
|
|
>
|
|
<Badge
|
|
size="sm"
|
|
variant="light"
|
|
color={
|
|
parseFloat(channel.ffmpeg_speed) >=
|
|
getBufferingSpeedThreshold(settings['proxy-settings'])
|
|
? 'green'
|
|
: 'red'
|
|
}
|
|
>
|
|
{parseFloat(channel.ffmpeg_speed).toFixed(2)}x
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
|
|
<Group justify="space-between">
|
|
<Group gap={4}>
|
|
<Tooltip
|
|
label={`Current bitrate: ${formatSpeed(bitrates.at(-1) || 0)}`}
|
|
>
|
|
<Group gap={4} style={{ cursor: 'help' }}>
|
|
<Gauge pr={5} size="22" />
|
|
<Text size="sm">{formatSpeed(bitrates.at(-1) || 0)}</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
<Tooltip label={`Average bitrate: ${avgBitrate}`}>
|
|
<Text size="sm" style={{ cursor: 'help' }}>
|
|
Avg: {avgBitrate}
|
|
</Text>
|
|
</Tooltip>
|
|
|
|
<Group gap={4}>
|
|
<Tooltip label={`Total transferred: ${formatBytes(totalBytes)}`}>
|
|
<Group gap={4} style={{ cursor: 'help' }}>
|
|
<HardDriveDownload size="18" />
|
|
<Text size="sm">{formatBytes(totalBytes)}</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
<Group gap={5}>
|
|
<Tooltip
|
|
label={`${clientCount} active client${clientCount !== 1 ? 's' : ''}`}
|
|
>
|
|
<Group gap={4} style={{ cursor: 'help' }}>
|
|
<Users size="18" />
|
|
<Text size="sm">{clientCount}</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
</Group>
|
|
</Group>
|
|
|
|
<CustomTable table={channelClientsTable} />
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default StreamConnectionCard; |