Dispatcharr/frontend/src/components/tables/StreamsTable.jsx
2026-06-27 01:55:27 -07:00

1769 lines
53 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { copyToClipboard, useDebounce } from '../../utils';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
Copy,
EllipsisVertical,
Eye,
EyeOff,
Filter,
ListPlus,
RotateCcw,
Search,
Square,
SquareCheck,
SquareMinus,
SquarePlus,
} from 'lucide-react';
import {
ActionIcon,
Box,
Button,
Card,
Center,
Divider,
Flex,
Group,
LoadingOverlay,
Menu,
MenuDivider,
MenuDropdown,
MenuItem,
MenuLabel,
MenuTarget,
MultiSelect,
NativeSelect,
Pagination,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useChannelsTableStore from '../../store/channelsTable';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
import CreateChannelModal from '../modals/CreateChannelModal';
import useStreamsTableStore from '../../store/streamsTable';
import { showNotification } from '../../utils/notificationUtils.js';
import { requeryChannels } from '../../utils/forms/ChannelUtils.js';
import {
addStreamsToChannel,
appendFetchPageParams,
createChannelFromStream,
createChannelsFromStreamsAsync,
deleteStream,
deleteStreams,
getAllStreamIds,
getChannelNumberValue,
getChannelProfileIds,
getFilterParams,
getStatsTooltip,
getStreamFilterOptions,
getStreams,
queryStreamsTable,
requeryStreams,
} from '../../utils/tables/StreamsTableUtils.js';
const StreamRowActions = ({
theme,
row,
editStream,
handleDeleteStream,
handleWatchStream,
handleCreateChannelFromStream,
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 === targetChannelId)?.streams
);
const addStreamToChannel = async () => {
await addStreamsToChannel(targetChannelId, channelSelectionStreams, [
row.original,
]);
};
const onEdit = useCallback(() => {
editStream(row.original);
}, [row.original, editStream]);
const onDelete = useCallback(() => {
handleDeleteStream(row.original.id);
}, [row.original.id, handleDeleteStream]);
const onPreview = useCallback(() => {
console.log(
'Previewing stream:',
row.original.name,
'ID:',
row.original.id,
'Hash:',
row.original.stream_hash
);
handleWatchStream(row.original.stream_hash, row.original.name);
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<>
<Tooltip label="Add to Channel" openDelay={500}>
<ActionIcon
size={iconSize}
color={theme.tailwind.blue[6]}
variant="transparent"
onClick={addStreamToChannel}
style={{ background: 'none' }}
disabled={
!targetChannelId ||
(channelSelectionStreams &&
channelSelectionStreams
.map((s) => s.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Tooltip label="Create New Channel" openDelay={500}>
<ActionIcon
size={iconSize}
color={theme.tailwind.green[5]}
variant="transparent"
onClick={() => handleCreateChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<MenuTarget>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</MenuTarget>
<MenuDropdown>
<MenuItem leftSection={<Copy size="14" />}>
<UnstyledButton
variant="unstyled"
size="xs"
onClick={() => copyToClipboard(row.original.url)}
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</MenuItem>
<MenuItem onClick={onEdit} disabled={!row.original.is_custom}>
<Text size="xs">Edit</Text>
</MenuItem>
<MenuItem onClick={onDelete} disabled={!row.original.is_custom}>
<Text size="xs">Delete Stream</Text>
</MenuItem>
<MenuItem onClick={onPreview}>
<Text size="xs">Preview Stream</Text>
</MenuItem>
</MenuDropdown>
</Menu>
</>
);
};
const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
const hasSignaledReady = useRef(false);
const hasFetchedOnce = useRef(false);
const hasFetchedPlaylists = useRef(false);
const hasFetchedChannelGroups = useRef(false);
/**
* useState
*/
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [initialDataCount, setInitialDataCount] = useState(null);
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
const initialDataCountRef = useRef(null); // First page count, kept in a ref so the page fetcher doesn't recreate when set
const lastIdsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the IDs fetch
const lastFilterOptionsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the filter-options fetch
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
useState(false);
const [numberingMode, setNumberingMode] = useState('provider'); // 'provider', 'auto', 'highest', or 'custom'
const [customStartNumber, setCustomStartNumber] = useState(1);
const [rememberChoice, setRememberChoice] = useState(false);
const [bulkSelectedProfileIds, setBulkSelectedProfileIds] = useState([]);
// Channel creation modal state (single)
const [singleChannelModalOpen, setSingleChannelModalOpen] = useState(false);
const [singleChannelMode, setSingleChannelMode] = useState('provider'); // 'provider', 'auto', 'highest', or 'specific'
const [specificChannelNumber, setSpecificChannelNumber] = useState(1);
const [rememberSingleChoice, setRememberSingleChoice] = useState(false);
const [currentStreamForChannel, setCurrentStreamForChannel] = useState(null);
const [singleSelectedProfileIds, setSingleSelectedProfileIds] = useState([]);
// Confirmation dialog state
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [streamToDelete, setStreamToDelete] = useState(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [filters, setFilters] = useState({
name: '',
channel_group: '',
m3u_account: '',
unassigned: false,
hide_stale: false,
});
const [columnSizing, setColumnSizing] = useLocalStorage(
'streams-table-column-sizing',
{}
);
// Column visibility - persisted to localStorage
// Default visible: name, group, m3u
// Default hidden: tvg_id, stats
const DEFAULT_COLUMN_VISIBILITY = {
actions: true,
select: true,
name: true,
group: true,
m3u: true,
tvg_id: false,
stats: 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({
...pagination,
pageIndex: 0,
});
});
const navigate = useNavigate();
/**
* Stores
*/
const playlists = usePlaylistsStore((s) => s.playlists);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const playlistsLoading = usePlaylistsStore((s) => s.isLoading);
// Get direct access to channel groups without depending on other data
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 === targetChannelId)?.streams
);
const channelProfiles = useChannelsStore((s) => s.profiles);
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);
const totalCount = useStreamsTableStore((s) => s.totalCount);
const allRowIds = useStreamsTableStore((s) => s.allQueryIds);
const setAllRowIds = useStreamsTableStore((s) => s.setAllQueryIds);
const pagination = useStreamsTableStore((s) => s.pagination);
const setPagination = useStreamsTableStore((s) => s.setPagination);
const sorting = useStreamsTableStore((s) => s.sorting);
const setSorting = useStreamsTableStore((s) => s.setSorting);
const selectedStreamIds = useStreamsTableStore((s) => s.selectedStreamIds);
const setSelectedStreamIds = useStreamsTableStore(
(s) => s.setSelectedStreamIds
);
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const handleSelectClick = (e) => {
e.stopPropagation();
e.preventDefault();
};
/**
* useMemo
*/
const columns = useMemo(
() => [
{
id: 'actions',
size: columnSizing.actions || 75,
minSize: 65,
},
{
id: 'select',
size: columnSizing.select || 30,
minSize: 30,
},
{
header: 'Name',
accessorKey: 'name',
grow: true,
size: columnSizing.name || 200,
minSize: 100,
cell: ({ getValue }) => (
<Tooltip label={getValue()} openDelay={500}>
<Box
style={{
whiteSpace: 'pre',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
</Tooltip>
),
},
{
header: 'Group',
id: 'group',
accessorFn: (row) =>
channelGroups[row.channel_group]
? channelGroups[row.channel_group].name
: '',
size: columnSizing.group || 150,
minSize: 75,
cell: ({ getValue }) => (
<Tooltip label={getValue()} openDelay={500}>
<Box
style={{
whiteSpace: 'pre',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
</Tooltip>
),
},
{
header: 'M3U',
id: 'm3u',
size: columnSizing.m3u || 150,
minSize: 75,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ getValue }) => (
<Tooltip label={getValue()} openDelay={500}>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
</Tooltip>
),
},
{
header: 'TVG-ID',
id: 'tvg_id',
accessorKey: 'tvg_id',
size: columnSizing.tvg_id || 120,
minSize: 75,
cell: ({ getValue }) => (
<Tooltip label={getValue()} openDelay={500}>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
</Tooltip>
),
},
{
header: 'Stats',
id: 'stats',
accessorKey: 'stream_stats',
size: columnSizing.stats || 120,
minSize: 75,
cell: ({ getValue }) => {
const stats = getValue();
if (!stats)
return (
<Text size="xs" c="dimmed">
-
</Text>
);
const { compactDisplay, tooltipContent } = getStatsTooltip(stats);
return (
<Tooltip
label={
<Text size="xs" style={{ whiteSpace: 'pre-line' }}>
{tooltipContent}
</Text>
}
openDelay={500}
multiline
w={220}
>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<Text size="xs">{compactDisplay}</Text>
</Box>
</Tooltip>
);
},
},
],
[channelGroups, playlists, columnSizing]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
channel_group: value && value.length > 0 ? value.join(',') : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value && value.length > 0 ? value.join(',') : '',
}));
};
const toggleUnassignedOnly = () => {
setFilters((prev) => ({
...prev,
unassigned: !prev.unassigned,
}));
};
const toggleHideStale = () => {
setFilters((prev) => ({
...prev,
hide_stale: !prev.hide_stale,
}));
};
// Build a URLSearchParams object containing only the filter portion of the
// query. Page-rows fetches add page/page_size/ordering on top of this.
const buildFilterParams = useCallback(() => {
return getFilterParams(debouncedFilters);
}, [debouncedFilters]);
// Fetch the visible page of stream rows. Depends on pagination, sorting,
// and filters.
const fetchPageData = useCallback(
async ({ showLoader = true } = {}) => {
const params = buildFilterParams();
appendFetchPageParams(params, pagination, sorting);
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
if (
fetchInProgressRef.current &&
lastFetchParamsRef.current === paramsString
) {
return;
}
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
if (showLoader) {
setIsLoading(true);
}
try {
const result = await queryStreamsTable(params);
fetchInProgressRef.current = false;
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
if (initialDataCountRef.current === null) {
initialDataCountRef.current = result.count;
setInitialDataCount(result.count);
}
if (!hasSignaledReady.current && onReady) {
hasSignaledReady.current = true;
onReady();
}
} catch (error) {
fetchInProgressRef.current = false;
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
console.error('Error fetching data:', error);
}
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
hasFetchedOnce.current = true;
if (showLoader) {
setIsLoading(false);
}
},
[pagination, sorting, buildFilterParams, onReady]
);
// Bulk creation: create channels from selected streams asynchronously
const createChannelsFromStreams = async () => {
if (selectedStreamIds.length === 0) return;
// Set default profile selection based on current profile filter
const defaultProfileIds =
selectedProfileId === '0' ? ['all'] : [selectedProfileId];
setBulkSelectedProfileIds(defaultProfileIds);
// Check if user has suppressed the channel numbering dialog
const actionKey = 'channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
// Use the remembered settings or default to 'provider' mode
const savedMode =
localStorage.getItem('channel-numbering-mode') || 'provider';
const savedStartNumber =
localStorage.getItem('channel-numbering-start') || '1';
const startingChannelNumberValue =
getChannelNumberValue(savedMode, savedStartNumber);
await executeChannelCreation(
startingChannelNumberValue,
defaultProfileIds
);
} else {
setChannelNumberingModalOpen(true);
}
};
const resolveSelectedStream = async (streamId) => {
const streamFromCurrentPage = data.find(
(candidate) => Number(candidate.id) === Number(streamId)
);
if (streamFromCurrentPage) {
return streamFromCurrentPage;
}
const response = await getStreams([streamId]);
return (
response?.find(
(candidate) => Number(candidate.id) === Number(streamId)
) ?? null
);
};
const createChannelsFromSelection = async () => {
if (selectedStreamIds.length === 1) {
const selectedStream = await resolveSelectedStream(selectedStreamIds[0]);
if (selectedStream) {
await handleCreateChannelFromStream(selectedStream);
} else {
showNotification({
color: 'red',
title: 'Stream not found',
message:
'The selected stream could not be resolved. Please refresh and try again.',
});
}
return;
}
await createChannelsFromStreams();
};
// Separate function to actually execute the channel creation
const executeChannelCreation = async (
startingChannelNumberValue,
profileIds = null
) => {
try {
const channelProfileIds = getChannelProfileIds(
profileIds,
selectedProfileId
);
// Use the async API for all bulk operations
const response = await createChannelsFromStreamsAsync(
selectedStreamIds,
channelProfileIds,
startingChannelNumberValue
);
console.log(
`Bulk creation task started: ${response.task_id} for ${response.stream_count} streams`
);
// Clear selection since the task has started
setSelectedStreamIds([]);
// Note: This is a background task, so the update happens on WebSocket completion
} catch (error) {
console.error('Error starting bulk channel creation:', error);
// Error notifications will be handled by WebSocket
}
};
// Handle confirming the channel numbering modal
const handleChannelNumberingConfirm = async () => {
// Save the choice if user wants to remember it
if (rememberChoice) {
suppressWarning('channel-numbering-choice');
localStorage.setItem('channel-numbering-mode', numberingMode);
if (numberingMode === 'custom') {
localStorage.setItem(
'channel-numbering-start',
customStartNumber.toString()
);
}
}
// Convert mode to API value
const startingChannelNumberValue = getChannelNumberValue(numberingMode, customStartNumber);
setChannelNumberingModalOpen(false);
await executeChannelCreation(
startingChannelNumberValue,
bulkSelectedProfileIds
);
};
const handleBulkNumberingModeChange = (nextMode) => {
setNumberingMode(nextMode);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const handleDeleteStream = async (id) => {
// Get stream details for the confirmation dialog
const streamObj = data.find((s) => s.id === id);
setStreamToDelete(streamObj);
setDeleteTarget(id);
setIsBulkDelete(false);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-stream')) {
return executeDeleteStream(id);
}
setConfirmDeleteOpen(true);
};
const executeDeleteStream = async (id) => {
setDeleting(true);
setIsLoading(true);
try {
await deleteStream(id);
// Clear the selection for the deleted stream
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
const handleDeleteStreams = async () => {
setIsBulkDelete(true);
setStreamToDelete(null);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-streams')) {
return executeDeleteStreams();
}
setConfirmDeleteOpen(true);
};
const executeDeleteStreams = async () => {
setDeleting(true);
setIsLoading(true);
try {
await deleteStreams(selectedStreamIds);
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
const closeStreamForm = async () => {
setStream(null);
setModalOpen(false);
setIsLoading(true);
try {
await requeryStreams();
} finally {
setIsLoading(false);
}
};
// Single channel creation functions
const handleCreateChannelFromStream = async (stream) => {
// Set default profile selection based on current profile filter
const defaultProfileIds =
selectedProfileId === '0' ? ['all'] : [selectedProfileId];
setSingleSelectedProfileIds(defaultProfileIds);
// Check if user has suppressed the single channel numbering dialog
const actionKey = 'single-channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
// Use the remembered settings or default to 'provider' mode
const savedMode =
localStorage.getItem('single-channel-numbering-mode') || 'provider';
const savedChannelNumber =
localStorage.getItem('single-channel-numbering-specific') || '1';
const channelNumberValue = getChannelNumberValue(savedMode, savedChannelNumber);
await executeSingleChannelCreation(
stream,
channelNumberValue,
defaultProfileIds
);
} else {
// Show the modal to let user choose
setCurrentStreamForChannel(stream);
setSingleChannelModalOpen(true);
}
};
// Separate function to actually execute single channel creation
const executeSingleChannelCreation = async (
stream,
channelNumber = null,
profileIds = null
) => {
const channelProfileIds = getChannelProfileIds(profileIds, selectedProfileId);
await createChannelFromStream({
name: stream.name,
channel_number: channelNumber,
stream_id: stream.id,
channel_profile_ids: channelProfileIds,
});
await requeryChannels();
};
// Handle confirming the single channel numbering modal
const handleSingleChannelNumberingConfirm = async () => {
// Save the choice if user wants to remember it
if (rememberSingleChoice) {
suppressWarning('single-channel-numbering-choice');
localStorage.setItem('single-channel-numbering-mode', singleChannelMode);
if (singleChannelMode === 'specific') {
localStorage.setItem(
'single-channel-numbering-specific',
specificChannelNumber.toString()
);
}
}
// Convert mode to API value
const channelNumberValue = getChannelNumberValue(singleChannelMode, specificChannelNumber);
setSingleChannelModalOpen(false);
await executeSingleChannelCreation(
currentStreamForChannel,
channelNumberValue,
singleSelectedProfileIds
);
};
const handleSingleNumberingModeChange = (nextMode) => {
setSingleChannelMode(nextMode);
};
const handleAddStreamsToChannel = async () => {
// Look up full stream objects from the current page data
const selectedIdSet = new Set(selectedStreamIds);
const newStreams = data.filter((s) => selectedIdSet.has(s.id));
await addStreamsToChannel(
targetChannelId,
channelSelectionStreams,
newStreams
);
};
const onRowSelectionChange = (updatedIds) => {
setSelectedStreamIds(updatedIds);
};
const onPageSizeChange = (e) => {
const newPageSize = parseInt(e.target.value);
setPagination({
...pagination,
pageSize: newPageSize,
});
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination({
...pagination,
pageIndex: pageIndex - 1,
});
};
function handleWatchStream(streamHash, streamName) {
let vidUrl = buildLiveStreamUrl(`/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 onSortingChange = (column) => {
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
if (sortField === column) {
if (sortDirection === false) {
setSorting([
{
id: column,
desc: true,
},
]);
} else {
// Reset to default sort (name ascending) instead of clearing
setSorting([{ id: 'name', desc: false }]);
}
} else {
setSorting([
{
id: column,
desc: false,
},
]);
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
switch (header.id) {
case 'name':
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
leftSection={<Search size={14} opacity={0.5} />}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('name');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
</Flex>
);
case 'group': {
const selectedGroups = filters.channel_group
? filters.channel_group.split(',').filter(Boolean)
: [];
return (
<MultiSelect
placeholder="Group"
searchable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
value={selectedGroups}
data={groupOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
style={{ width: '100%' }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('group');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
);
}
case 'm3u': {
const selectedM3Us = filters.m3u_account
? filters.m3u_account.split(',').filter(Boolean)
: [];
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<MultiSelect
placeholder="M3U"
searchable
clearable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
value={selectedM3Us}
data={m3uOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('m3u');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
</Flex>
);
}
case 'tvg_id':
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<TextInput
name="tvg_id"
placeholder="TVG-ID"
value={filters.tvg_id || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
leftSection={<Search size={14} opacity={0.5} />}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('tvg_id');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
</Flex>
);
case 'stats':
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<div
className="table-input-header"
style={{
flex: 1,
minWidth: 75,
display: 'flex',
alignItems: 'center',
pointerEvents: 'none',
userSelect: 'none',
cursor: 'default',
color: '#cfcfcf',
fontWeight: 400,
fontSize: 14,
lineHeight: '1',
}}
>
<span style={{ width: '100%' }}>Stats</span>
</div>
</Flex>
);
}
};
const renderBodyCell = useCallback(
({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<StreamRowActions
theme={theme}
row={row}
editStream={editStream}
handleDeleteStream={handleDeleteStream}
handleWatchStream={handleWatchStream}
handleCreateChannelFromStream={handleCreateChannelFromStream}
/>
);
}
},
[theme, editStream, handleDeleteStream, handleWatchStream]
);
const table = useTable({
columns,
data,
allRowIds,
filters,
pagination,
sorting,
columnSizing,
setColumnSizing,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: onRowSelectionChange,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
state: {
pagination,
sorting,
columnVisibility,
},
headerCellRenderFns: {
name: renderHeaderCell,
group: renderHeaderCell,
m3u: renderHeaderCell,
tvg_id: renderHeaderCell,
stats: renderHeaderCell,
},
bodyCellRenderFns: {
actions: renderBodyCell,
},
getRowStyles: (row) => {
if (row.original.is_stale) {
return {
className: 'stale-stream-row',
};
}
return {};
},
});
/**
* useEffects
*/
useEffect(() => {
// Load page rows independently, don't wait for logos or other data
fetchPageData();
}, [fetchPageData]);
// The full ID list and filter options only depend on filters, not pagination
// or sort order, so they get their own effects to avoid refetching on every
// page change or sort toggle.
useEffect(() => {
const params = buildFilterParams();
const paramsString = params.toString();
if (lastIdsParamsRef.current === paramsString) {
return;
}
lastIdsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const ids = await getAllStreamIds(params);
if (!cancelled && ids) {
setAllRowIds(ids);
}
})();
return () => {
cancelled = true;
};
}, [buildFilterParams, setAllRowIds]);
useEffect(() => {
const params = buildFilterParams();
const paramsString = params.toString();
if (lastFilterOptionsParamsRef.current === paramsString) {
return;
}
lastFilterOptionsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const filterOptions = await getStreamFilterOptions(params);
if (cancelled || !filterOptions || typeof filterOptions !== 'object') {
return;
}
setGroupOptions(
(filterOptions.groups || [])
.filter((group) => group != null && group !== '')
.map((group) => String(group))
);
setM3uOptions(
(filterOptions.m3u_accounts || [])
.filter((m3u) => m3u && m3u.id != null && m3u.name)
.map((m3u) => ({
label: String(m3u.name),
value: String(m3u.id),
}))
);
})();
return () => {
cancelled = true;
lastFilterOptionsParamsRef.current = null;
};
}, [buildFilterParams]);
// Refetch page rows 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
fetchPageData({ showLoader: false });
}
prevVideoVisible.current = videoIsVisible;
}, [videoIsVisible, fetchPageData]);
useEffect(() => {
if (
Object.keys(channelGroups).length > 0 ||
hasFetchedChannelGroups.current
) {
return;
}
(async () => {
hasFetchedChannelGroups.current = true;
try {
await fetchChannelGroups();
} catch (error) {
console.error('Error fetching channel groups:', error);
}
})();
}, [channelGroups, fetchChannelGroups]);
useEffect(() => {
if (
playlists.length > 0 ||
hasFetchedPlaylists.current ||
playlistsLoading
) {
return;
}
const loadPlaylists = async () => {
hasFetchedPlaylists.current = true;
try {
await fetchPlaylists();
} catch (error) {
console.error('Error fetching playlists:', error);
}
};
loadPlaylists();
}, [playlists, fetchPlaylists, playlistsLoading]);
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1;
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
totalCount
);
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
// Clear dependent filters if selected values are no longer in filtered options
useEffect(() => {
// Clear group filter if the selected groups are no longer available
if (filters.channel_group) {
const selectedGroups = filters.channel_group.split(',').filter(Boolean);
const stillValid = selectedGroups.filter((group) =>
groupOptions.includes(group)
);
if (stillValid.length !== selectedGroups.length) {
setFilters((prev) => ({
...prev,
channel_group: stillValid.join(','),
}));
}
}
// Clear M3U filter if the selected M3Us are no longer available
if (filters.m3u_account) {
const selectedIds = filters.m3u_account.split(',').filter(Boolean);
const availableIds = m3uOptions.map((opt) => opt.value);
const stillValid = selectedIds.filter((id) => availableIds.includes(id));
if (stillValid.length !== selectedIds.length) {
setFilters((prev) => ({
...prev,
m3u_account: stillValid.join(','),
}));
}
}
}, [groupOptions, m3uOptions, filters.channel_group, filters.m3u_account]);
return (
<>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 12 }}
gap={15}
>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
marginBottom: 0,
}}
>
Streams
</Text>
</Flex>
<Paper
style={{
height: 'calc(100vh - 60px)',
backgroundColor: '#27272A',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Flex
justify="space-between"
align="center"
wrap="nowrap"
style={{ padding: 10 }}
gap={6}
>
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip
label="Add selected stream(s) to the target channel"
openDelay={500}
>
<Button
leftSection={<SquarePlus size={18} />}
variant={
selectedStreamIds.length > 0 && targetChannelId
? 'light'
: 'default'
}
size="xs"
onClick={handleAddStreamsToChannel}
p={5}
color={
selectedStreamIds.length > 0 && targetChannelId
? theme.tailwind.green[5]
: undefined
}
style={
selectedStreamIds.length > 0 && targetChannelId
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}
: undefined
}
disabled={!(selectedStreamIds.length > 0 && targetChannelId)}
>
Add to Channel
</Button>
</Tooltip>
<Tooltip
label={`Create channels from ${selectedStreamIds.length} stream(s)`}
openDelay={500}
>
<Button
leftSection={<SquarePlus size={18} />}
variant="default"
size="xs"
onClick={createChannelsFromSelection}
p={5}
disabled={selectedStreamIds.length == 0}
>
{selectedStreamIds.length <= 1
? `Create Channel (${selectedStreamIds.length})`
: `Create Channels (${selectedStreamIds.length})`}
</Button>
</Tooltip>
</Flex>
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Menu shadow="md" width={200}>
<MenuTarget>
<Tooltip label="Filters" openDelay={500}>
<Button size="xs" variant="default">
<Filter size={18} />
</Button>
</Tooltip>
</MenuTarget>
<MenuDropdown>
<MenuItem
onClick={toggleUnassignedOnly}
leftSection={
filters.unassigned === true ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Only Unassociated</Text>
</MenuItem>
<MenuItem
onClick={toggleHideStale}
leftSection={
filters.hide_stale === true ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Hide Stale</Text>
</MenuItem>
</MenuDropdown>
</Menu>
<Tooltip label="Create a new custom stream" openDelay={500}>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStream()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Create Stream
</Button>
</Tooltip>
<Tooltip label="Delete selected stream(s)" openDelay={500}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={handleDeleteStreams}
disabled={selectedStreamIds.length == 0}
>
Delete
</Button>
</Tooltip>
<Menu shadow="md" width={200}>
<MenuTarget>
<Tooltip label="Table Settings" openDelay={500}>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Tooltip>
</MenuTarget>
<MenuDropdown>
<MenuLabel>Toggle Columns</MenuLabel>
<MenuItem
onClick={() => toggleColumnVisibility('name')}
leftSection={
columnVisibility.name !== false ? (
<Eye size={18} />
) : (
<EyeOff size={18} />
)
}
>
<Text size="xs">Name</Text>
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('group')}
leftSection={
columnVisibility.group !== false ? (
<Eye size={18} />
) : (
<EyeOff size={18} />
)
}
>
<Text size="xs">Group</Text>
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('m3u')}
leftSection={
columnVisibility.m3u !== false ? (
<Eye size={18} />
) : (
<EyeOff size={18} />
)
}
>
<Text size="xs">M3U</Text>
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('tvg_id')}
leftSection={
columnVisibility.tvg_id !== false ? (
<Eye size={18} />
) : (
<EyeOff size={18} />
)
}
>
<Text size="xs">TVG-ID</Text>
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('stats')}
leftSection={
columnVisibility.stats !== false ? (
<Eye size={18} />
) : (
<EyeOff size={18} />
)
}
>
<Text size="xs">Stats</Text>
</MenuItem>
<MenuDivider />
<MenuItem
onClick={resetColumnVisibility}
leftSection={<RotateCcw size={18} />}
>
<Text size="xs">Reset to Default</Text>
</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
</Flex>
{initialDataCount === 0 && (
<Center style={{ paddingTop: 20 }}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{
backgroundColor: '#222',
borderColor: '#444',
textAlign: 'center',
width: '400px',
}}
>
<Stack align="center">
<Title order={3} style={{ color: '#d4d4d8' }}>
Getting started
</Title>
<Text size="sm" color="dimmed">
In order to get started, add your M3U or start <br />
adding custom streams.
</Text>
<Button
variant="default"
radius="md"
size="md"
onClick={() => navigate('/sources')}
style={{
backgroundColor: '#444',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add M3U
</Button>
<Divider label="or" labelPosition="center" color="gray" />
<Button
variant="default"
radius="md"
size="md"
onClick={() => editStream()}
style={{
backgroundColor: '#333',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add Individual Stream
</Button>
</Stack>
</Card>
</Center>
)}
{initialDataCount > 0 && (
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 100px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</Box>
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
}}
>
<Group
gap={5}
justify="center"
style={{
padding: 8,
borderTop: '1px solid #666',
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
)}
</Paper>
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={closeStreamForm}
/>
{/* Bulk Channel Creation Modal */}
<CreateChannelModal
opened={channelNumberingModalOpen}
onClose={() => setChannelNumberingModalOpen(false)}
mode={numberingMode}
onModeChange={handleBulkNumberingModeChange}
numberValue={customStartNumber}
onNumberValueChange={setCustomStartNumber}
rememberChoice={rememberChoice}
onRememberChoiceChange={setRememberChoice}
onConfirm={handleChannelNumberingConfirm}
isBulk={true}
streamCount={selectedStreamIds.length}
selectedProfileIds={bulkSelectedProfileIds}
onProfileIdsChange={setBulkSelectedProfileIds}
channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
/>
{/* Single Channel Creation Modal */}
<CreateChannelModal
opened={singleChannelModalOpen}
onClose={() => setSingleChannelModalOpen(false)}
mode={singleChannelMode}
onModeChange={handleSingleNumberingModeChange}
numberValue={specificChannelNumber}
onNumberValueChange={setSpecificChannelNumber}
rememberChoice={rememberSingleChoice}
onRememberChoiceChange={setRememberSingleChoice}
onConfirm={handleSingleChannelNumberingConfirm}
isBulk={false}
streamName={currentStreamForChannel?.name}
selectedProfileIds={singleSelectedProfileIds}
onProfileIdsChange={setSingleSelectedProfileIds}
channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() =>
isBulkDelete
? executeDeleteStreams()
: executeDeleteStream(deleteTarget)
}
title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Stream Deletion`}
message={
isBulkDelete ? (
`Are you sure you want to delete ${selectedStreamIds.length} stream${selectedStreamIds.length !== 1 ? 's' : ''}? This action cannot be undone.`
) : streamToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following stream?
Name: ${streamToDelete.name}
${streamToDelete.channel_group ? `Group: ${channelGroups[streamToDelete.channel_group]?.name || 'Unknown'}` : ''}
${streamToDelete.m3u_account ? `M3U Account: ${playlists.find((p) => p.id === streamToDelete.m3u_account)?.name || 'Unknown'}` : ''}
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this stream? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey={isBulkDelete ? 'delete-streams' : 'delete-stream'}
onSuppressChange={suppressWarning}
loading={deleting}
size="md"
/>
</>
);
};
export default StreamsTable;