From 4a4df1495067d29fe5e6bd91504dfd0bbe94a04a Mon Sep 17 00:00:00 2001 From: None Date: Fri, 30 Jan 2026 23:30:36 -0600 Subject: [PATCH] Add column visibility toggle and Source Info column to StreamsTable - Add three-dot menu for toggling column visibility (Name, Group, M3U, TVG-ID, Source Info) - Add Source Info column showing stream resolution and codec with detailed tooltip - Column visibility persists to localStorage with proper migration handling for existing users - Default hidden columns: TVG-ID, Source Info - Auto-refresh table data when video preview closes to show updated stream stats - Fix state spreading bug in CustomTable/index.jsx (was spreading options.state instead of state) I think the people are gonna like this one! --- .../components/tables/CustomTable/index.jsx | 4 +- .../src/components/tables/StreamsTable.jsx | 210 ++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index d523d757..883e01d7 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -21,6 +21,7 @@ const useTable = ({ state = [], columnSizing, setColumnSizing, + onColumnVisibilityChange, ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); @@ -98,12 +99,13 @@ const useTable = ({ }, ...options, state: { - ...options.state, + ...state, selectedTableIds, ...(columnSizing && { columnSizing }), }, onStateChange: options.onStateChange, ...(setColumnSizing && { onColumnSizingChange: setColumnSizing }), + ...(onColumnVisibilityChange && { onColumnVisibilityChange }), getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(), enableColumnResizing: true, columnResizeMode: 'onChange', diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 3ac06ac1..e8d99ad7 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -23,6 +23,9 @@ import { Filter, Square, SquareCheck, + Eye, + EyeOff, + RotateCcw, } from 'lucide-react'; import { TextInput, @@ -242,6 +245,64 @@ const StreamsTable = ({ onReady }) => { 'streams-table-column-sizing', {} ); + + // Column visibility - persisted to localStorage + // Default visible: name, group, m3u + // Default hidden: tvg_id, source_info + const DEFAULT_COLUMN_VISIBILITY = { + actions: true, + select: true, + name: true, + group: true, + m3u: true, + tvg_id: false, + source_info: false, + }; + + const [storedColumnVisibility, setStoredColumnVisibility] = useLocalStorage( + 'streams-table-column-visibility', + null // Use null as default to detect fresh install + ); + + // Merge defaults with stored values, ensuring all columns have values + // - Fresh install (null): use defaults + // - Existing users: merge settings with defaults for any new columns + const columnVisibility = useMemo(() => { + if (!storedColumnVisibility || typeof storedColumnVisibility !== 'object') { + return DEFAULT_COLUMN_VISIBILITY; + } + // Merge: start with defaults, overlay stored values only for keys that exist in defaults + const merged = { ...DEFAULT_COLUMN_VISIBILITY }; + for (const key of Object.keys(DEFAULT_COLUMN_VISIBILITY)) { + if (key in storedColumnVisibility && typeof storedColumnVisibility[key] === 'boolean') { + merged[key] = storedColumnVisibility[key]; + } + } + return merged; + }, [storedColumnVisibility]); + + const setColumnVisibility = (newValue) => { + if (typeof newValue === 'function') { + setStoredColumnVisibility((prev) => { + const prevMerged = prev && typeof prev === 'object' ? { ...DEFAULT_COLUMN_VISIBILITY, ...prev } : DEFAULT_COLUMN_VISIBILITY; + return newValue(prevMerged); + }); + } else { + setStoredColumnVisibility(newValue); + } + }; + + const toggleColumnVisibility = (columnId) => { + setColumnVisibility((prev) => ({ + ...prev, + [columnId]: !prev[columnId], + })); + }; + + const resetColumnVisibility = () => { + setStoredColumnVisibility(DEFAULT_COLUMN_VISIBILITY); + }; + const debouncedFilters = useDebounce(filters, 500, () => { // Reset to first page whenever filters change to avoid "Invalid page" errors setPagination({ @@ -272,6 +333,7 @@ const StreamsTable = ({ onReady }) => { const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); + const videoIsVisible = useVideoStore((s) => s.isVisible); const data = useStreamsTableStore((s) => s.streams); const pageCount = useStreamsTableStore((s) => s.pageCount); @@ -389,6 +451,61 @@ const StreamsTable = ({ onReady }) => { ), }, + { + header: 'Source Info', + id: 'source_info', + accessorKey: 'stream_stats', + size: columnSizing.source_info || 120, + cell: ({ getValue }) => { + const stats = getValue(); + if (!stats) return -; + + // Build compact display (resolution + video codec) + const parts = []; + if (stats.resolution) { + // Convert "1920x1080" to "1080p" format + const height = stats.resolution.split('x')[1]; + if (height) parts.push(`${height}p`); + } + if (stats.video_codec) { + parts.push(stats.video_codec.toUpperCase()); + } + const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; + + // Build tooltip content with friendly labels + const tooltipLines = []; + if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`); + if (stats.video_codec) tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`); + if (stats.video_bitrate) tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); + if (stats.source_fps) tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); + if (stats.audio_codec) tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`); + if (stats.audio_channels) tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); + if (stats.audio_bitrate) tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); + + const tooltipContent = tooltipLines.length > 0 + ? tooltipLines.join('\n') + : 'No source info available'; + + return ( + {tooltipContent}} + openDelay={500} + multiline + w={220} + > + + {compactDisplay} + + + ); + }, + }, ], [channelGroups, playlists, columnSizing] ); @@ -1053,6 +1170,7 @@ const StreamsTable = ({ onReady }) => { sorting, columnSizing, setColumnSizing, + onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: onRowSelectionChange, manualPagination: true, manualSorting: true, @@ -1061,6 +1179,7 @@ const StreamsTable = ({ onReady }) => { state: { pagination, sorting, + columnVisibility, }, headerCellRenderFns: { name: renderHeaderCell, @@ -1089,6 +1208,16 @@ const StreamsTable = ({ onReady }) => { fetchData(); }, [fetchData]); + // Refetch data when video player closes to update stream stats + const prevVideoVisible = useRef(false); + useEffect(() => { + if (prevVideoVisible.current && !videoIsVisible) { + // Video was closed, refetch to get updated stream stats + fetchData({ showLoader: false }); + } + prevVideoVisible.current = videoIsVisible; + }, [videoIsVisible, fetchData]); + useEffect(() => { if ( Object.keys(channelGroups).length > 0 || @@ -1306,6 +1435,87 @@ const StreamsTable = ({ onReady }) => { + + + + + + + + + + + Toggle Columns + toggleColumnVisibility('name')} + leftSection={ + columnVisibility.name !== false ? ( + + ) : ( + + ) + } + > + Name + + toggleColumnVisibility('group')} + leftSection={ + columnVisibility.group !== false ? ( + + ) : ( + + ) + } + > + Group + + toggleColumnVisibility('m3u')} + leftSection={ + columnVisibility.m3u !== false ? ( + + ) : ( + + ) + } + > + M3U + + toggleColumnVisibility('tvg_id')} + leftSection={ + columnVisibility.tvg_id !== false ? ( + + ) : ( + + ) + } + > + TVG-ID + + toggleColumnVisibility('source_info')} + leftSection={ + columnVisibility.source_info !== false ? ( + + ) : ( + + ) + } + > + Source Info + + + } + > + Reset to Default + + + +