attempt at table rewrite for efficient virtualized table

This commit is contained in:
dekzter 2025-04-15 13:48:06 -04:00
parent eb48083cce
commit af638326e1
10 changed files with 814 additions and 638 deletions

View file

@ -16,6 +16,7 @@
"@mantine/hooks": "^7.17.2",
"@mantine/notifications": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
"axios": "^1.8.2",
"clsx": "^2.1.1",
@ -31,6 +32,7 @@
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"recharts": "^2.15.1",
"video.js": "^8.21.0",
@ -1741,12 +1743,12 @@
}
},
"node_modules/@tanstack/react-table": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz",
"integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz",
"integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
"@tanstack/table-core": "8.21.2"
},
"engines": {
"node": ">=12"
@ -1778,9 +1780,9 @@
}
},
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -3452,6 +3454,39 @@
"react-dom": ">=18.0"
}
},
"node_modules/mantine-react-table/node_modules/@tanstack/react-table": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz",
"integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/mantine-react-table/node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4081,6 +4116,16 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-virtualized-auto-sizer": {
"version": "1.0.26",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
"integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==",
"license": "MIT",
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",

View file

@ -18,6 +18,7 @@
"@mantine/hooks": "^7.17.2",
"@mantine/notifications": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
"axios": "^1.8.2",
"clsx": "^2.1.1",
@ -33,6 +34,7 @@
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"recharts": "^2.15.1",
"video.js": "^8.21.0",

View file

@ -122,7 +122,7 @@ export const WebsocketProvider = ({ children }) => {
message: 'EPG match is complete!',
color: 'green.5',
});
fetchChannels();
// fetchChannels();
fetchEPGData();
break;

View file

@ -320,7 +320,7 @@ export default class API {
});
// Optionally refesh the channel list in Zustand
await useChannelsStore.getState().fetchChannels();
// await useChannelsStore.getState().fetchChannels();
return response;
} catch (e) {
@ -604,7 +604,7 @@ export default class API {
usePlaylistsStore.getState().removePlaylists([id]);
// @TODO: MIGHT need to optimize this later if someone has thousands of channels
// but I'm feeling laze right now
useChannelsStore.getState().fetchChannels();
// useChannelsStore.getState().fetchChannels();
} catch (e) {
errorNotification(`Failed to delete playlist ${id}`, e);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,86 @@
// HeadlessChannelsTable.jsx
import React, { useMemo, useState, useCallback, useRef } from 'react';
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
getExpandedRowModel,
} from '@tanstack/react-table';
import {
Table,
Box,
Checkbox,
ActionIcon,
ScrollArea,
} from '@mantine/core';
import { ChevronRight, ChevronDown } from 'lucide-react';
import ChannelsTableRow from './ChannelsTableRow';
import { useVirtualizer } from '@tanstack/react-virtual'
const ChannelsTableBody = ({ rows, height, onEdit, onDelete, onPreview, onRecord, virtualizedItems }) => {
const rowHeight = 48;
// return (
// <tbody>
// <AutoSizer disableWidth>
// {({ height }) => (
// <List
// height={height}
// itemCount={rows.length}
// itemSize={rowHeight}
// width="100%"
// >
// {({ index, style }) => {
// const row = rows[index];
// return (
// <React.Fragment key={row.id}>
// <ChannelsTableRow
// row={row}
// onEdit={onEdit}
// onDelete={onDelete}
// onPreview={onPreview}
// onRecord={onRecord}
// />
// {row.getIsExpanded() && <ChannelsDetailPanel row={row} />}
// </React.Fragment>
// );
// }}
// </List>
// )}
// </AutoSizer>
// </tbody>
// );
return (
<Table.Tbody style={{
position: 'relative',
// display: 'block',
height: `${height}px`,
// overflowY: 'auto',
}}>
{virtualizedItems.map((virtualRow, index) => {
const row = rows[virtualRow.index]
return (
<ChannelsTableRow
row={row}
virtualRow={virtualRow}
index={index}
onEdit={onEdit}
onDelete={onDelete}
onPreview={onPreview}
onRecord={onRecord}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
/>
);
})}
</Table.Tbody>
);
};
export default ChannelsTableBody;

View file

@ -0,0 +1,61 @@
// HeadlessChannelsTable.jsx
import React, { useMemo, useState, useCallback } from 'react';
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
getExpandedRowModel,
} from '@tanstack/react-table';
import {
Table,
Box,
Checkbox,
ActionIcon,
ScrollArea,
Center,
useMantineTheme,
} from '@mantine/core';
import { ChevronRight, ChevronDown } from 'lucide-react';
import useSettingsStore from '../../../store/settings';
import useChannelsStore from '../../../store/channels';
const ExpandIcon = ({ row, toggle }) => (
<ActionIcon size="xs" onClick={toggle}>
{row.getIsExpanded() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</ActionIcon>
);
const ChannelsTableRow = ({ row, virtualRow, index, style, onEdit, onDelete, onPreview, onRecord }) => {
return (
<Table.Tr style={{
...style,
position: 'absolute',
// top: 0,
display: 'table',
tableLayout: 'fixed',
width: '100%',
}}>
{row.getVisibleCells().map(cell => {
return (
<Table.Td key={cell.id} align={cell.column.columnDef.meta?.align} style={{
padding: 0,
width: cell.column.getSize(),
minWidth: cell.column.columnDef.meta?.minWidth,
maxWidth: cell.column.columnDef.meta?.maxWidth,
// maxWidth: cell.column.getSize(),
}}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
)
})}
</Table.Tr>
)
};
export default ChannelsTableRow

View file

@ -0,0 +1,78 @@
export default () => {
return (
<Box
style={{
paddingTop: 20,
bgcolor: theme.palette.background.paper,
}}
>
<Center>
<Box
style={{
textAlign: 'center',
width: '55%',
}}
>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or
streams.
</Text>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like,
and map them later.
</Text>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
color="gray"
style={{
marginTop: 20,
borderWidth: '1px',
borderColor: 'gray',
color: 'white',
}}
>
Create Channel
</Button>
</Box>
</Center>
<Center>
<Box
component="img"
src={ghostImage}
alt="Ghost"
style={{
paddingTop: 30,
width: '120px',
height: 'auto',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
</Center>
</Box>
)
}

View file

@ -32,7 +32,7 @@ const useAuthStore = create((set, get) => ({
initData: async () => {
await Promise.all([
useChannelsStore.getState().fetchChannels(),
// useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchLogos(),
useChannelsStore.getState().fetchChannelProfiles(),

View file

@ -310,75 +310,75 @@ const useChannelsStore = create((set, get) => ({
})),
setChannelStats: (stats) => {
return set((state) => {
const {
channels,
stats: currentStats,
activeChannels: oldChannels,
activeClients: oldClients,
channelsByUUID,
} = state;
// return set((state) => {
// const {
// channels,
// stats: currentStats,
// activeChannels: oldChannels,
// activeClients: oldClients,
// channelsByUUID,
// } = state;
const newClients = {};
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
// const newClients = {};
// const newChannels = stats.channels.reduce((acc, ch) => {
// acc[ch.channel_id] = ch;
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
notifications.show({
title: 'New channel streaming',
message: channels[channelsByUUID[ch.channel_id]].name,
color: 'blue.5',
});
}
}
// if (currentStats.channels) {
// if (oldChannels[ch.channel_id] === undefined) {
// notifications.show({
// title: 'New channel streaming',
// message: channels[channelsByUUID[ch.channel_id]].name,
// color: 'blue.5',
// });
// }
// }
ch.clients.map((client) => {
newClients[client.client_id] = client;
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
if (oldClients[client.client_id] === undefined) {
notifications.show({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
color: 'blue.5',
});
}
}
});
// ch.clients.map((client) => {
// newClients[client.client_id] = client;
// // This check prevents the notifications if streams are active on page load
// if (currentStats.channels) {
// if (oldClients[client.client_id] === undefined) {
// notifications.show({
// title: 'New client started streaming',
// message: `Client streaming from ${client.ip_address}`,
// color: 'blue.5',
// });
// }
// }
// });
return acc;
}, {});
// return acc;
// }, {});
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
notifications.show({
title: 'Channel streaming stopped',
message: channels[channelsByUUID[uuid]].name,
color: 'blue.5',
});
}
}
// // This check prevents the notifications if streams are active on page load
// if (currentStats.channels) {
// for (const uuid in oldChannels) {
// if (newChannels[uuid] === undefined) {
// notifications.show({
// title: 'Channel streaming stopped',
// message: channels[channelsByUUID[uuid]].name,
// color: 'blue.5',
// });
// }
// }
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
notifications.show({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
}
}
}
// for (const clientId in oldClients) {
// if (newClients[clientId] === undefined) {
// notifications.show({
// title: 'Client stopped streaming',
// message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
// color: 'blue.5',
// });
// }
// }
// }
return {
stats,
activeChannels: newChannels,
activeClients: newClients,
};
});
// return {
// stats,
// activeChannels: newChannels,
// activeClients: newClients,
// };
// });
},
fetchRecordings: async () => {