Performance:

- **Channel table performance**:
  - Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports.
  - Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization.
  - Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator.
  - Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator.
Enhancement:
- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state.
- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows.
- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths.
- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed.
This commit is contained in:
SergeantPanda 2026-04-18 22:04:15 -05:00
parent 3bdbf30c21
commit 17fbf1d4f7
8 changed files with 627 additions and 527 deletions

View file

@ -13,9 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Performance
- Reduced unnecessary re-renders in the channels table. Removed three Zustand store subscriptions from `ChannelsTable` that caused a full component re-render on every unrelated store update: the entire `channels` map, `selectedProfileChannels`, and `selectedProfileChannelIds` (these were superseded when `ChannelEnabledSwitch` was refactored to subscribe to the store directly). Narrowed the `channelIds` subscription to a stable boolean `hasChannels` so only the empty↔non-empty transition triggers a re-render instead of every channel add/remove. Added a custom `React.memo` comparator to `ChannelRowActions` that compares `row.original.id` rather than the full TanStack Table row object (which is recreated each render cycle), preventing every action cell from re-rendering on table updates. Memoized `getChannelURL`, `createRecording`, and `handleWatchStream` with `useCallback` for stable column cell references.
- **Channel table performance**:
- Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports.
- Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization.
- Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator.
- Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator.
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)
### Changed
- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state.
- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows.
- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths.
- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed.
## [0.23.0] - 2026-04-17
### Security

View file

@ -1,4 +1,10 @@
import React, { useMemo, useState, useEffect } from 'react';
import React, {
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import API from '../../api';
import { copyToClipboard } from '../../utils';
import {
@ -21,7 +27,6 @@ import {
Collapse,
Button,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useReactTable,
getCoreRowModel,
@ -54,6 +59,114 @@ 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,
@ -129,7 +242,250 @@ const DraggableRow = ({ row, index }) => {
);
};
const ChannelStreams = ({ channel, isExpanded }) => {
// 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
)
}
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(
@ -140,13 +496,17 @@ const ChannelStreams = ({ channel, isExpanded }) => {
const authUser = useAuthStore((s) => s.user);
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
function handleWatchStream(streamHash, streamName) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
}
const handleWatchStream = useCallback(
(streamHash, streamName) => {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
},
[env_mode, showVideo]
);
const [data, setData] = useState(channelStreams || []);
@ -154,19 +514,22 @@ const ChannelStreams = ({ channel, isExpanded }) => {
setData(channelStreams);
}, [channelStreams]);
const dataIds = data?.map(({ id }) => id);
const dataIds = useMemo(() => data?.map(({ id }) => id), [data]);
const removeStream = async (stream) => {
const newStreamList = data.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
streams: newStreamList.map((s) => s.id),
});
await API.requeryChannels();
await API.requeryStreams();
};
const removeStream = useCallback(
async (stream) => {
const newStreamList = data.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
streams: newStreamList.map((s) => s.id),
});
await API.requeryChannels();
await API.requeryStreams();
},
[channel, data]
);
// Create M3U account map for quick lookup
// M3U account map for quick lookup
const m3uAccountsMap = useMemo(() => {
const map = {};
if (playlists && Array.isArray(playlists)) {
@ -179,426 +542,121 @@ const ChannelStreams = ({ channel, isExpanded }) => {
return map;
}, [playlists]);
// Add state for tracking which streams have advanced stats expanded
const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set());
// Track expanded advanced stats via ref so toggling doesn't recreate columns
const expandedAdvancedStatsRef = useRef(new Set());
// Helper function to categorize stream stats
const categorizeStreamStats = (stats) => {
if (!stats)
return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {},
};
// Define which stats go in which category
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: [], // Will catch anything not categorized above
};
// Categorize each stat
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 not categorized, put it in 'other'
if (!categorized) {
categories.other[key] = value;
}
});
return categories;
};
// Function to format stat values for display
const formatStatValue = (key, value) => {
if (value === null || value === undefined) return 'N/A';
// Handle specific formatting cases
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':
// Convert bytes to appropriate unit
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':
// Format duration if it's in seconds
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();
}
};
// Function to render a stats category
const renderStatsCategory = (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>
);
};
// Function to toggle advanced stats for a stream
const toggleAdvancedStats = (streamId) => {
const newExpanded = new Set(expandedAdvancedStats);
if (newExpanded.has(streamId)) {
newExpanded.delete(streamId);
const toggleAdvancedStats = useCallback((streamId) => {
const current = expandedAdvancedStatsRef.current;
if (current.has(streamId)) {
current.delete(streamId);
} else {
newExpanded.add(streamId);
current.add(streamId);
}
setExpandedAdvancedStats(newExpanded);
};
}, []);
// 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 playlistName = playlists[stream.m3u_account]?.name || 'Unknown';
const accountName =
m3uAccountsMap[stream.m3u_account] || playlistName;
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>
),
},
],
[
playlists,
m3uAccountsMap,
theme,
authUser.user_level,
removeStream,
toggleAdvancedStats,
handleWatchStream,
]
);
const table = useReactTable({
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 playlistName =
playlists[stream.m3u_account]?.name || 'Unknown';
const accountName =
m3uAccountsMap[stream.m3u_account] || playlistName;
// Categorize stream stats
const categorizedStats = categorizeStreamStats(stream.stream_stats);
const hasAdvancedStats = Object.values(categorizedStats).some(
(category) => Object.keys(category).length > 0
);
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
)
}
style={{ marginLeft: 2 }}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
{/* Basic Stream Stats (always shown) */}
{stream.stream_stats && (
<Group gap="xs" mt={4} align="center">
{/* Video Information */}
{(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>
)}
</>
)}
{/* Audio Information */}
{(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>
)}
</>
)}
{/* Output Bitrate */}
{stream.stream_stats.ffmpeg_output_bitrate && (
<>
<Text size="xs" c="dimmed" fw={500}>
Output Bitrate:
</Text>
{stream.stream_stats.ffmpeg_output_bitrate && (
<Badge
size="xs"
variant="light"
color="orange"
style={{ textTransform: 'none' }}
>
{stream.stream_stats.ffmpeg_output_bitrate} kbps
</Badge>
)}
</>
)}
</Group>
)}
{/* Advanced Stats Toggle Button */}
{hasAdvancedStats && (
<Group gap="xs" mt={6}>
<Button
variant="subtle"
size="xs"
leftSection={
expandedAdvancedStats.has(stream.id) ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
}
onClick={() => toggleAdvancedStats(stream.id)}
c="dimmed"
>
{expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'}{' '}
Advanced Stats
</Button>
</Group>
)}
{/* Advanced Stats (expandable) */}
<Collapse in={expandedAdvancedStats.has(stream.id)}>
<Box
mt="sm"
p="xs"
style={{
backgroundColor: 'rgba(0,0,0,0.1)',
borderRadius: '4px',
}}
>
{renderStatsCategory('Video', categorizedStats.video)}
{renderStatsCategory('Audio', categorizedStats.audio)}
{renderStatsCategory(
'Technical',
categorizedStats.technical
)}
{renderStatsCategory('Other', categorizedStats.other)}
{/* Show when stats were last updated */}
{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>
);
},
},
{
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>
),
},
],
[data, playlists, m3uAccountsMap, expandedAdvancedStats]
),
columns,
data,
state: {
data,
},
defaultColumn: {
size: undefined,
minSize: 0,
},
defaultColumn: defaultColumnConfig,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getCoreRowModel: coreRowModel,
});
const handleDragEnd = (event) => {
if (authUser.user_level != USER_LEVELS.ADMIN) {
return;
}
const handleDragEnd = useCallback(
(event) => {
if (authUser.user_level != USER_LEVELS.ADMIN) {
return;
}
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id);
const newIndex = dataIds.indexOf(over.id);
const retval = arrayMove(data, oldIndex, newIndex);
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);
const { streams: _, ...channelUpdate } = channel;
API.updateChannel({
...channelUpdate,
streams: retval.map((row) => row.id),
}).then(() => {
API.requeryChannels();
const { streams: _, ...channelUpdate } = channel;
API.updateChannel({
...channelUpdate,
streams: retval.map((row) => row.id),
}).then(() => {
API.requeryChannels();
});
return retval;
});
return retval; //this is just a splice util
});
}
};
}
},
[authUser.user_level, channel]
);
const sensors = useSensors(
useSensor(MouseSensor, {}),
@ -606,10 +664,6 @@ const ChannelStreams = ({ channel, isExpanded }) => {
useSensor(KeyboardSensor, {})
);
if (!isExpanded) {
return <></>;
}
const rows = table.getRowModel().rows;
return (
@ -623,7 +677,6 @@ const ChannelStreams = ({ channel, isExpanded }) => {
onDragEnd={handleDragEnd}
sensors={sensors}
>
{' '}
<Box
className="divTable table-striped"
style={{
@ -663,9 +716,9 @@ const ChannelStreams = ({ channel, isExpanded }) => {
</Box>
)}
{rows.length > 0 &&
table
.getRowModel()
.rows.map((row) => <DraggableRow key={row.id} row={row} />)}
rows.map((row, index) => (
<DraggableRow key={row.id} row={row} index={index} />
))}
</SortableContext>
</Box>
</Box>

View file

@ -288,6 +288,9 @@ const ChannelsTable = ({ onReady }) => {
(s) => s.setSelectedChannelIds
);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const setExpandedChannelId = useChannelsTableStore(
(s) => s.setExpandedChannelId
);
const pagination = useChannelsTableStore((s) => s.pagination);
const setPagination = useChannelsTableStore((s) => s.setPagination);
const sorting = useChannelsTableStore((s) => s.sorting);
@ -695,6 +698,13 @@ const ChannelsTable = ({ onReady }) => {
setSelectedChannelIds(newSelection);
};
const onRowExpansionChange = useCallback(
(expandedIds) => {
setExpandedChannelId(expandedIds.length > 0 ? expandedIds[0] : null);
},
[setExpandedChannelId]
);
const onPageSizeChange = (e) => {
setPagination({
...pagination,
@ -1136,6 +1146,7 @@ const ChannelsTable = ({ onReady }) => {
enableRowSelection: true,
enableDragDrop: true,
onRowSelectionChange: onRowSelectionChange,
onRowExpansionChange: onRowExpansionChange,
state: {
pagination,
sorting,
@ -1151,7 +1162,7 @@ const ChannelsTable = ({ onReady }) => {
className="tr"
style={{ display: 'flex', width: '100%' }}
>
<ChannelTableStreams channel={row.original} isExpanded={true} />
<ChannelTableStreams channel={row.original} />
</Box>
);
},

View file

@ -57,11 +57,10 @@ const CustomTable = ({ table }) => {
expandedRowIds={table.expandedRowIds}
expandedRowRenderer={table.expandedRowRenderer}
renderBodyCell={table.renderBodyCell}
getExpandedRowHeight={table.getExpandedRowHeight}
getRowStyles={table.getRowStyles}
tableBodyProps={table.tableBodyProps}
tableCellProps={table.tableCellProps}
enableDragDrop={table.enableDragDrop}
selectedTableIdsSet={table.selectedTableIdsSet}
/>
</Box>
);

View file

@ -1,68 +1,35 @@
import { Box, Flex } from '@mantine/core';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
import React, { useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableBody = ({
getRowModel,
expandedRowIds,
expandedRowRenderer,
renderBodyCell,
getExpandedRowHeight,
getRowStyles,
tableBodyProps,
tableCellProps,
enableDragDrop = false,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
return expandedRowRenderer({ row });
}
return <></>;
};
const rows = getRowModel().rows;
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (rows.length === 0) return 0;
return rows[0].getVisibleCells().reduce((total, cell) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = cell.column.columnDef.size
? cell.column.getSize()
: cell.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0);
}, [rows]);
const renderTableBodyContents = () => {
return (
<Box className="tbody" style={{ flex: 1 }}>
{rows.map((row, index) => renderTableBodyRow(row, index))}
</Box>
);
};
const renderTableBodyRow = (row, index, style = {}) => {
// Get custom styles for this row if the function exists
// Memoized row only re-renders when this specific row's data, expansion
// state, or drag-drop config actually changes. Callback functions are read
// from refs so the memoized row always uses the latest version when it *does*
// re-render, without needing them as comparator inputs.
const MemoizedTableRow = React.memo(
({
row,
index,
isExpanded,
isSelected,
renderBodyCellRef,
expandedRowRendererRef,
getRowStyles,
tableCellProps,
enableDragDrop,
}) => {
const renderBodyCell = renderBodyCellRef.current;
const customRowStyles = getRowStyles ? getRowStyles(row) : {};
// Extract any className from customRowStyles
const customClassName = customRowStyles.className || '';
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
delete customRowStyles.className;
return (
<DraggableRowWrapper
row={row}
key={`row-${row.id}`}
style={style}
enableDragDrop={enableDragDrop}
>
<Box
@ -71,11 +38,11 @@ const CustomTableBody = ({
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
minWidth: '100%',
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
...customRowStyles, // Apply the remaining custom styles here
...customRowStyles,
}}
>
{row.getVisibleCells().map((cell) => {
@ -107,12 +74,63 @@ const CustomTableBody = ({
);
})}
</Box>
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
{isExpanded && expandedRowRendererRef.current({ row })}
</DraggableRowWrapper>
);
};
},
(prev, next) => {
return (
prev.row.original === next.row.original &&
prev.index === next.index &&
prev.isExpanded === next.isExpanded &&
prev.isSelected === next.isSelected &&
prev.enableDragDrop === next.enableDragDrop
);
}
);
return renderTableBodyContents();
const CustomTableBody = ({
getRowModel,
expandedRowIds,
expandedRowRenderer,
renderBodyCell,
getRowStyles,
tableCellProps,
enableDragDrop = false,
selectedTableIdsSet,
}) => {
// Store callbacks in refs so memoized rows always access the latest versions
// without the function references themselves triggering re-renders.
const renderBodyCellRef = useRef(renderBodyCell);
renderBodyCellRef.current = renderBodyCell;
const expandedRowRendererRef = useRef(expandedRowRenderer);
expandedRowRendererRef.current = expandedRowRenderer;
const rows = getRowModel().rows;
return (
<Box className="tbody" style={{ flex: 1 }}>
{rows.map((row, index) => (
<MemoizedTableRow
key={`row-${row.id}`}
row={row}
index={index}
isExpanded={expandedRowIds.includes(row.original.id)}
isSelected={
selectedTableIdsSet
? selectedTableIdsSet.has(row.original.id)
: false
}
renderBodyCellRef={renderBodyCellRef}
expandedRowRendererRef={expandedRowRendererRef}
getRowStyles={getRowStyles}
tableCellProps={tableCellProps}
enableDragDrop={enableDragDrop}
/>
))}
</Box>
);
};
const DraggableRowWrapper = ({

View file

@ -8,7 +8,7 @@ import {
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const useTable = ({
@ -17,6 +17,7 @@ const useTable = ({
bodyCellRenderFns = {},
expandedRowRenderer = () => <></>,
onRowSelectionChange = null,
onRowExpansionChange = null,
getExpandedRowHeight = null,
state = {},
columnSizing,
@ -25,6 +26,8 @@ const useTable = ({
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
const selectedTableIdsRef = useRef(selectedTableIds);
selectedTableIdsRef.current = selectedTableIds;
const [expandedRowIds, setExpandedRowIds] = useState([]);
const [lastClickedId, setLastClickedId] = useState(null);
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
@ -146,12 +149,15 @@ const useTable = ({
};
const onRowExpansion = (row) => {
let isExpanded = false;
const rowId = row.original.id;
let newIds;
setExpandedRowIds((prev) => {
isExpanded = prev.includes(row.original.id) ? [] : [row.original.id];
return isExpanded;
newIds = prev.includes(rowId) ? [] : [rowId];
return newIds;
});
updateSelectedTableIds([row.original.id]);
if (onRowExpansionChange) {
onRowExpansionChange(newIds);
}
};
// Handle the shift+click selection
@ -174,7 +180,7 @@ const useTable = ({
const rangeIds = allRowIds.slice(startIndex, endIndex + 1);
// Preserve existing selections outside the range
const idsOutsideRange = selectedTableIds.filter(
const idsOutsideRange = selectedTableIdsRef.current.filter(
(id) => !rangeIds.includes(id)
);
const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])];
@ -206,7 +212,7 @@ const useTable = ({
// Try to handle with shift-select logic first
if (!handleShiftSelect(rowId, isShiftKey)) {
// If not handled by shift-select, do regular toggle
const newSet = new Set(selectedTableIds);
const newSet = new Set(selectedTableIdsRef.current);
if (e.target.checked) {
newSet.add(rowId);
} else {

View file

@ -74,19 +74,23 @@ const StreamRowActions = ({
editStream,
deleteStream,
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
table,
}) => {
const tableSize = table?.tableSize ?? 'default';
const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const targetChannelId =
expandedChannelId ||
(selectedChannelIds.length === 1 ? selectedChannelIds[0] : null);
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
state.channels.find((chan) => chan.id === targetChannelId)?.streams
);
const addStreamToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
id: targetChannelId,
streams: [
...new Set(
channelSelectionStreams.map((s) => s.id).concat([row.original.id])
@ -129,7 +133,7 @@ const StreamRowActions = ({
onClick={addStreamToChannel}
style={{ background: 'none' }}
disabled={
selectedChannelIds.length !== 1 ||
!targetChannelId ||
(channelSelectionStreams &&
channelSelectionStreams
.map((s) => s.id)
@ -331,10 +335,14 @@ const StreamsTable = ({ onReady }) => {
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const targetChannelId =
expandedChannelId ||
(selectedChannelIds.length === 1 ? selectedChannelIds[0] : null);
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
state.channels.find((chan) => chan.id === targetChannelId)?.streams
);
const channelProfiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
@ -1033,7 +1041,7 @@ const StreamsTable = ({ onReady }) => {
const addStreamsToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
id: targetChannelId,
streams: [
...new Set(
channelSelectionStreams.map((s) => s.id).concat(selectedStreamIds)
@ -1267,20 +1275,12 @@ const StreamsTable = ({ onReady }) => {
editStream={editStream}
deleteStream={deleteStream}
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
createChannelFromStream={createChannelFromStream}
/>
);
}
},
[
selectedChannelIds,
channelSelectionStreams,
theme,
editStream,
deleteStream,
handleWatchStream,
]
[theme, editStream, deleteStream, handleWatchStream]
);
const table = useTable({
@ -1462,14 +1462,13 @@ const StreamsTable = ({ onReady }) => {
>
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip
label="Add selected stream(s) to the selected channel"
label="Add selected stream(s) to the target channel"
openDelay={500}
>
<Button
leftSection={<SquarePlus size={18} />}
variant={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? 'light'
: 'default'
}
@ -1477,14 +1476,12 @@ const StreamsTable = ({ onReady }) => {
onClick={addStreamsToChannel}
p={5}
color={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? theme.tailwind.green[5]
: undefined
}
style={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
selectedStreamIds.length > 0 && targetChannelId
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
@ -1492,12 +1489,7 @@ const StreamsTable = ({ onReady }) => {
}
: undefined
}
disabled={
!(
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
)
}
disabled={!(selectedStreamIds.length > 0 && targetChannelId)}
>
Add to Channel
</Button>

View file

@ -1,5 +1,8 @@
import { create } from 'zustand';
// Stable empty array to avoid creating new references in getChannelStreams
const emptyStreams = [];
const useChannelsTableStore = create((set, get) => ({
channels: [],
pageCount: 0,
@ -12,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
},
selectedChannelIds: [],
expandedChannelId: null,
allQueryIds: [],
isUnlocked: false,
@ -38,9 +42,15 @@ const useChannelsTableStore = create((set, get) => ({
});
},
setExpandedChannelId: (expandedChannelId) => {
set({
expandedChannelId,
});
},
getChannelStreams: (id) => {
const channel = get().channels.find((c) => c.id === id);
return channel?.streams ?? [];
return channel?.streams || emptyStreams;
},
setPagination: (pagination) => {