Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
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 (
|
||||
<Stack gap="xs" mt={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(elapsed)} elapsed
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(remaining)} remaining
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
color="#3BA882"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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',
|
||||
grow: true,
|
||||
minSize: 85,
|
||||
cell: ({ cell }) => (
|
||||
<Tooltip label={cell.getValue()}>
|
||||
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
|
||||
{cell.getValue()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }) => (
|
||||
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
|
||||
{cell.getValue()}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
// Updated Connected column with tooltip
|
||||
{
|
||||
id: 'connected',
|
||||
header: 'Connected',
|
||||
grow: 1.5,
|
||||
minSize: 70,
|
||||
maxSize: 150,
|
||||
accessorFn: connectedAccessor(fullDateTimeFormat),
|
||||
cell: ({ cell }) => (
|
||||
<Tooltip
|
||||
label={
|
||||
cell.getValue() !== 'Unknown'
|
||||
? `Connected at ${cell.getValue()}`
|
||||
: 'Unknown connection time'
|
||||
}
|
||||
>
|
||||
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
|
||||
{cell.getValue()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
// 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 (
|
||||
<Tooltip
|
||||
label={
|
||||
exactDuration
|
||||
? `${exactDuration.toFixed(1)} seconds`
|
||||
: 'Unknown duration'
|
||||
}
|
||||
>
|
||||
<Text size="xs" style={{ whiteSpace: 'nowrap' }}>
|
||||
{cell.getValue()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<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);
|
||||
|
||||
// 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 (
|
||||
<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" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
w={140}
|
||||
h={70}
|
||||
display="flex"
|
||||
>
|
||||
<img
|
||||
src={logoUrl || logo}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt="channel logo"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group mt={10}>
|
||||
<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>
|
||||
|
||||
{/* Stream Profile on right - absolutely positioned */}
|
||||
<Box pos="absolute" top={65} right={16} style={{ zIndex: 1 }}>
|
||||
<Tooltip label="Active Stream Profile">
|
||||
<Group gap={5}>
|
||||
<Video size="18" />
|
||||
{streamProfileName}
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* M3U Profile on right - absolutely positioned */}
|
||||
<Box pos="absolute" top={95} right={16} style={{ zIndex: 1 }}>
|
||||
<Group gap={5}>
|
||||
<HardDriveUpload size="18" />
|
||||
<Tooltip label="Current M3U Profile">
|
||||
<Text size="xs">{m3uProfileName}</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Channel Name on left */}
|
||||
<Box mt={4}>
|
||||
<Text fw={500}>{channelName}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Display current program on its own line */}
|
||||
{currentProgram && (
|
||||
<Group gap={5} mt={-9} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
Now Playing:
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentProgram.title}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{isProgramDescExpanded ? (
|
||||
<ChevronDown size="14" />
|
||||
) : (
|
||||
<ChevronRight size="14" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Expandable program description */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.description && (
|
||||
<Box mt={4} ml={24}>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
|
||||
{currentProgram.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Program progress bar */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.start_time &&
|
||||
currentProgram.end_time && (
|
||||
<ProgramProgress currentProgram={currentProgram} />
|
||||
)}
|
||||
|
||||
{/* Add stream selection dropdown and preview button */}
|
||||
{availableStreams.length > 0 && (
|
||||
<Box mt={-10}>
|
||||
<Group align="flex-end" gap="xs">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<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}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{channel.name && (
|
||||
<Tooltip label="Preview Channel">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={handlePreviewChannel}
|
||||
style={{ marginBottom: 1 }}
|
||||
>
|
||||
<CirclePlay size="20" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Add stream information badges */}
|
||||
<Group gap="xs" mt="5">
|
||||
{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;
|
||||
Reference in New Issue
Block a user