Dispatcharr/frontend/src/components/tables/ChannelTableStreams.jsx

785 lines
22 KiB
JavaScript

import React, {
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import API from '../../api';
import { copyToClipboard } from '../../utils';
import {
GripHorizontal,
SquareMinus,
ChevronDown,
ChevronRight,
Eye,
} from 'lucide-react';
import {
Box,
ActionIcon,
Flex,
Text,
useMantineTheme,
Center,
Badge,
Group,
Tooltip,
Collapse,
Button,
} from '@mantine/core';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import usePlaylistsStore from '../../store/playlists';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useDraggable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shallow } from 'zustand/shallow';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
// ── Static values (created once, shared across all instances) ────────────────
const coreRowModel = getCoreRowModel();
const defaultColumnConfig = {
size: undefined,
minSize: 0,
};
const categoryMapping = {
basic: [
'resolution',
'video_codec',
'source_fps',
'audio_codec',
'audio_channels',
],
video: [
'video_bitrate',
'pixel_format',
'width',
'height',
'aspect_ratio',
'frame_rate',
],
audio: [
'audio_bitrate',
'sample_rate',
'audio_format',
'audio_channels_layout',
],
technical: [
'stream_type',
'container_format',
'duration',
'file_size',
'ffmpeg_output_bitrate',
'input_bitrate',
],
other: [],
};
const categorizeStreamStats = (stats) => {
if (!stats)
return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {},
};
Object.entries(stats).forEach(([key, value]) => {
let categorized = false;
for (const [category, keys] of Object.entries(categoryMapping)) {
if (keys.includes(key)) {
categories[category][key] = value;
categorized = true;
break;
}
}
if (!categorized) {
categories.other[key] = value;
}
});
return categories;
};
const formatStatValue = (key, value) => {
if (value === null || value === undefined) return 'N/A';
switch (key) {
case 'video_bitrate':
case 'audio_bitrate':
case 'ffmpeg_output_bitrate':
return `${value} kbps`;
case 'source_fps':
case 'frame_rate':
return `${value} fps`;
case 'sample_rate':
return `${value} Hz`;
case 'file_size':
if (typeof value === 'number') {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024)
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return value;
case 'duration':
if (typeof value === 'number') {
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = Math.floor(value % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return value;
default:
return value.toString();
}
};
// ── Sub-components ───────────────────────────────────────────────────────────
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: rowId,
});
return (
<Center>
<ActionIcon
ref={setNodeRef}
{...listeners}
{...attributes}
variant="transparent"
size="xs"
style={{
cursor: 'grab', // this is enough
}}
>
<GripHorizontal color="white" />
</ActionIcon>
</Center>
);
};
// Row Component
const DraggableRow = React.memo(
({ row, index }) => {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
});
const style = {
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: 'relative',
};
return (
<Box
ref={setNodeRef}
key={row.id}
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'}${row.original.is_stale ? ' stale-stream-row' : ''}`}
style={{
...style,
display: 'flex',
width: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
}}
>
{row.getVisibleCells().map((cell) => {
return (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
<Text component="div" size="xs">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text>
</Flex>
</Box>
);
})}
</Box>
);
},
(prev, next) =>
prev.row.original === next.row.original && prev.index === next.index
);
// Stats category display component
const StatsCategory = ({ categoryName, stats }) => {
if (!stats || Object.keys(stats).length === 0) return null;
return (
<Box key={categoryName} mb="xs">
<Text size="xs" fw={600} mb={4} tt="uppercase" c="dimmed">
{categoryName}
</Text>
<Group gap={4} mb="xs">
{Object.entries(stats).map(([key, value]) => (
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
<Badge size="xs" variant="light" color="gray">
{key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '}
{formatStatValue(key, value)}
</Badge>
</Tooltip>
))}
</Group>
</Box>
);
};
// Extracted stream info cell — reads expandedAdvancedStats from a ref
// so toggling advanced stats doesn't recreate the columns array.
const StreamInfoCell = React.memo(
({
stream,
accountName,
expandedAdvancedStatsRef,
toggleAdvancedStats,
handleWatchStream,
}) => {
const [, forceUpdate] = useState(0);
const isExpanded = expandedAdvancedStatsRef.current.has(stream.id);
const categorizedStats = useMemo(
() => categorizeStreamStats(stream.stream_stats),
[stream.stream_stats]
);
const hasAdvancedStats = useMemo(
() =>
Object.values(categorizedStats).some(
(category) => Object.keys(category).length > 0
),
[categorizedStats]
);
const onToggle = useCallback(() => {
toggleAdvancedStats(stream.id);
forceUpdate((n) => n + 1);
}, [stream.id, toggleAdvancedStats]);
return (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{stream.name}
</Text>
<Badge size="xs" variant="light" color="teal">
{accountName}
</Badge>
{stream.quality && (
<Badge size="xs" variant="light" color="gray">
{stream.quality}
</Badge>
)}
{stream.url && (
<>
<Tooltip label={stream.url}>
<Badge
size="xs"
variant="light"
color="indigo"
style={{ cursor: 'pointer' }}
onClick={async (e) => {
e.stopPropagation();
await copyToClipboard(stream.url, {
successTitle: 'URL Copied',
successMessage: 'Stream URL copied to clipboard',
});
}}
>
URL
</Badge>
</Tooltip>
<Tooltip label="Preview Stream">
<ActionIcon
size="xs"
color="blue"
variant="light"
onClick={() =>
handleWatchStream(
stream.stream_hash || stream.id,
stream.name,
stream.id
)
}
style={{ marginLeft: 2 }}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
{/* Basic Stream Stats (always shown) */}
{stream.stream_stats && (
<Group gap="xs" mt={4} align="center">
{(stream.stream_stats.video_codec ||
stream.stream_stats.resolution ||
stream.stream_stats.video_bitrate ||
stream.stream_stats.source_fps) && (
<>
<Text size="xs" c="dimmed" fw={500}>
Video:
</Text>
{stream.stream_stats.resolution && (
<Badge size="xs" variant="light" color="red">
{stream.stream_stats.resolution}
</Badge>
)}
{stream.stream_stats.video_bitrate && (
<Badge
size="xs"
variant="light"
color="orange"
style={{ textTransform: 'none' }}
>
{stream.stream_stats.video_bitrate} kbps
</Badge>
)}
{stream.stream_stats.source_fps && (
<Badge size="xs" variant="light" color="orange">
{stream.stream_stats.source_fps} FPS
</Badge>
)}
{stream.stream_stats.video_codec && (
<Badge size="xs" variant="light" color="blue">
{stream.stream_stats.video_codec.toUpperCase()}
</Badge>
)}
</>
)}
{(stream.stream_stats.audio_codec ||
stream.stream_stats.audio_channels) && (
<>
<Text size="xs" c="dimmed" fw={500}>
Audio:
</Text>
{stream.stream_stats.audio_channels && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_channels}
</Badge>
)}
{stream.stream_stats.audio_codec && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_codec.toUpperCase()}
</Badge>
)}
</>
)}
{stream.stream_stats.ffmpeg_output_bitrate && (
<>
<Text size="xs" c="dimmed" fw={500}>
Output Bitrate:
</Text>
<Badge
size="xs"
variant="light"
color="orange"
style={{ textTransform: 'none' }}
>
{stream.stream_stats.ffmpeg_output_bitrate} kbps
</Badge>
</>
)}
</Group>
)}
{hasAdvancedStats && (
<Group gap="xs" mt={6}>
<Button
variant="subtle"
size="xs"
leftSection={
isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
}
onClick={onToggle}
c="dimmed"
>
{isExpanded ? 'Hide' : 'Show'} Advanced Stats
</Button>
</Group>
)}
<Collapse in={isExpanded}>
<Box
mt="sm"
p="xs"
style={{
backgroundColor: 'rgba(0,0,0,0.1)',
borderRadius: '4px',
}}
>
<StatsCategory
categoryName="Video"
stats={categorizedStats.video}
/>
<StatsCategory
categoryName="Audio"
stats={categorizedStats.audio}
/>
<StatsCategory
categoryName="Technical"
stats={categorizedStats.technical}
/>
<StatsCategory
categoryName="Other"
stats={categorizedStats.other}
/>
{stream.stream_stats_updated_at && (
<Text size="xs" c="dimmed" mt="xs">
Last updated:{' '}
{new Date(stream.stream_stats_updated_at).toLocaleString()}
</Text>
)}
</Box>
</Collapse>
</Box>
);
}
);
// ── Main component ───────────────────────────────────────────────────────────
const ChannelStreams = ({ channel }) => {
const theme = useMantineTheme();
const channelStreams = useChannelsTableStore(
(state) => state.getChannelStreams(channel.id),
shallow
);
const patchChannelStreamStats = useChannelsTableStore(
(s) => s.patchChannelStreamStats
);
const playlists = usePlaylistsStore((s) => s.playlists);
const authUser = useAuthStore((s) => s.user);
const showVideo = useVideoStore((s) => s.showVideo);
const isVideoVisible = useVideoStore((s) => s.isVisible);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const handleWatchStream = useCallback(
(streamHash, streamName, streamId) => {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
const meta = {};
if (streamName) meta.name = streamName;
if (streamId != null) meta.streamId = streamId;
showVideo(vidUrl, 'live', Object.keys(meta).length ? meta : null);
},
[env_mode, showVideo]
);
const [data, setData] = useState(channelStreams || []);
useEffect(() => {
setData(channelStreams);
}, [channelStreams]);
// Refs so stable callbacks always see the latest values without being in deps
const channelRef = useRef(channel);
channelRef.current = channel;
const dataRef = useRef(data);
dataRef.current = data;
const dataIds = useMemo(() => data?.map(({ id }) => id), [data]);
// Fire-and-forget refresh of stream stats. Cursor is the newest
// stream_stats_updated_at already in the store; server returns only
// entries strictly newer than that (empty array when nothing changed).
const refreshStats = useCallback(
(opts) => {
const channelId = channelRef.current?.id;
if (!channelId) return;
const streams = dataRef.current || [];
let since = null;
for (const s of streams) {
const t = s.stream_stats_updated_at;
if (t && (since === null || t > since)) since = t;
}
const ids = opts && opts.ids;
API.getChannelStreamStats(channelId, since, ids).then((updates) => {
if (!updates || updates.length === 0) return;
patchChannelStreamStats(channelId, updates);
});
},
[patchChannelStreamStats]
);
// Refresh once when the row is expanded.
useEffect(() => {
refreshStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh just the previewed stream when the floating player closes.
// Metadata is captured while visible because hideVideo clears it.
const prevVisibleRef = useRef(isVideoVisible);
const lastPreviewMetaRef = useRef(null);
useEffect(() => {
if (isVideoVisible) {
lastPreviewMetaRef.current = useVideoStore.getState().metadata;
}
const wasVisible = prevVisibleRef.current;
prevVisibleRef.current = isVideoVisible;
if (wasVisible && !isVideoVisible) {
const meta = lastPreviewMetaRef.current;
lastPreviewMetaRef.current = null;
const streamId = meta && meta.streamId;
if (
streamId != null &&
(dataRef.current || []).some((s) => s.id === streamId)
) {
refreshStats({ ids: [streamId] });
}
}
}, [isVideoVisible, refreshStats]);
const removeStream = useCallback(async (stream) => {
const newStreamList = dataRef.current.filter((s) => s.id !== stream.id);
setData(newStreamList);
await API.reorderChannelStreams(
channelRef.current.id,
newStreamList.map((s) => s.id)
);
}, []);
// M3U account map for quick lookup
const m3uAccountsMap = useMemo(() => {
const map = {};
if (playlists && Array.isArray(playlists)) {
playlists.forEach((account) => {
if (account.id) {
map[account.id] = account.name;
}
});
}
return map;
}, [playlists]);
// Track expanded advanced stats via ref so toggling doesn't recreate columns
const expandedAdvancedStatsRef = useRef(new Set());
const toggleAdvancedStats = useCallback((streamId) => {
const current = expandedAdvancedStatsRef.current;
if (current.has(streamId)) {
current.delete(streamId);
} else {
current.add(streamId);
}
}, []);
// Columns are now stable — they don't depend on data or expandedAdvancedStats.
// Cell renderers receive the row from TanStack at render time.
// StreamInfoCell reads the expanded stats ref directly.
const columns = useMemo(
() => [
{
id: 'drag-handle',
header: 'Move',
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
size: 30,
},
{
id: 'name',
header: 'Stream Info',
accessorKey: 'name',
cell: ({ row }) => {
const stream = row.original;
const accountName = m3uAccountsMap[stream.m3u_account] || 'Unknown';
return (
<StreamInfoCell
stream={stream}
accountName={accountName}
expandedAdvancedStatsRef={expandedAdvancedStatsRef}
toggleAdvancedStats={toggleAdvancedStats}
handleWatchStream={handleWatchStream}
/>
);
},
},
{
id: 'actions',
header: '',
size: 30,
cell: ({ row }) => (
<Center>
<ActionIcon variant="transparent" size="xs">
<SquareMinus
color={theme.tailwind.red[6]}
onClick={() => removeStream(row.original)}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
/>
</ActionIcon>
</Center>
),
},
],
[
m3uAccountsMap,
theme,
authUser.user_level,
toggleAdvancedStats,
handleWatchStream,
]
);
const table = useReactTable({
columns,
data,
defaultColumn: defaultColumnConfig,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
getRowId: (row) => row.id,
getCoreRowModel: coreRowModel,
});
const handleDragEnd = useCallback(
(event) => {
if (authUser.user_level != USER_LEVELS.ADMIN) {
return;
}
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((prevData) => {
const currentIds = prevData.map(({ id }) => id);
const oldIndex = currentIds.indexOf(active.id);
const newIndex = currentIds.indexOf(over.id);
const retval = arrayMove(prevData, oldIndex, newIndex);
API.reorderChannelStreams(
channel.id,
retval.map((row) => row.id)
);
return retval;
});
}
},
[authUser.user_level, channel]
);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
const rows = table.getRowModel().rows;
return (
<Box
className="channel-streams-container"
style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}
>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<Box
className="divTable table-striped"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Box className="tbody">
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{rows.length === 0 && (
<Box
className="tr"
style={{
display: 'flex',
width: '100%',
}}
>
<Box
className="td"
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<Flex
align="center"
justify="center"
style={{ height: '100%' }}
>
<Text size="xs">No Data</Text>
</Flex>
</Box>
</Box>
)}
{rows.length > 0 &&
rows.map((row, index) => (
<DraggableRow key={row.id} row={row} index={index} />
))}
</SortableContext>
</Box>
</Box>
</DndContext>
</Box>
);
};
export default ChannelStreams;