mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
3bdbf30c21
commit
17fbf1d4f7
8 changed files with 627 additions and 527 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue