Dispatcharr/frontend/src/components/tables/ChannelsTable.jsx

1705 lines
52 KiB
JavaScript

import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import ChannelBatchForm from '../forms/ChannelBatch';
import RecordingForm from '../forms/Recording';
import { useDebounce, copyToClipboard } from '../../utils';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
CirclePlay,
SquarePen,
Copy,
ScanEye,
EllipsisVertical,
ArrowUpNarrowWide,
ArrowUpDown,
ArrowDownWideNarrow,
Search,
EyeOff,
Pencil,
} from 'lucide-react';
import { listOverriddenFields } from '../../utils/forms/ChannelUtils.js';
import {
Box,
TextInput,
Popover,
ActionIcon,
Button,
Paper,
Flex,
Text,
Group,
useMantineTheme,
Center,
Switch,
Menu,
MultiSelect,
Pagination,
NativeSelect,
UnstyledButton,
Stack,
Select,
NumberInput,
Tooltip,
Skeleton,
} from '@mantine/core';
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import ChannelTableStreams from './ChannelTableStreams';
import LazyLogo from '../LazyLogo';
import useLocalStorage from '../../hooks/useLocalStorage';
import useEPGsStore from '../../store/epgs';
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import { CustomTable, useTable } from './CustomTable';
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
import {
EditableTextCell,
EditableNumberCell,
EditableGroupCell,
EditableEPGCell,
EditableLogoCell,
} from './ChannelsTable/EditableCell';
import { DraggableRow } from './ChannelsTable/DraggableRow';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
const ChannelEnabledSwitch = React.memo(
({ rowId, selectedProfileId, selectedTableIds }) => {
// Directly extract the channels set once to avoid re-renders on every change.
const isEnabled = useChannelsStore(
useCallback(
(state) =>
selectedProfileId === '0' ||
state.profiles[selectedProfileId]?.channels.has(rowId),
[rowId, selectedProfileId]
)
);
const handleToggle = () => {
if (selectedTableIds.length > 1) {
API.updateProfileChannels(
selectedTableIds,
selectedProfileId,
!isEnabled
);
} else {
API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
}
};
return (
<Center style={{ width: '100%' }}>
<Switch
size="xs"
checked={isEnabled}
onChange={handleToggle}
disabled={selectedProfileId === '0'}
/>
</Center>
);
}
);
const ChannelRowActions = React.memo(
({
theme,
row,
table,
editChannel,
deleteChannel,
handleWatchStream,
createRecording,
getChannelURL,
}) => {
// Extract the channel ID once to ensure consistency
const channelId = row.original.id;
const channelUuid = row.original.uuid;
const authUser = useAuthStore((s) => s.user);
const onEdit = useCallback(() => {
// Use the ID directly to avoid issues with filtered tables
console.log(`Editing channel ID: ${channelId}`);
editChannel(row.original);
}, [channelId, row.original]);
const onDelete = useCallback(() => {
console.log(`Deleting channel ID: ${channelId}`);
deleteChannel(channelId);
}, [channelId]);
const onPreview = useCallback(() => {
// Use direct channel UUID for preview to avoid issues
console.log(`Previewing channel UUID: ${channelUuid}`);
handleWatchStream(row.original);
}, [channelUuid]);
const onRecord = useCallback(() => {
console.log(`Recording channel ID: ${channelId}`);
createRecording(row.original);
}, [channelId]);
const tableSize = table?.tableSize ?? 'default';
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Center>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquareMinus size="18" />
</ActionIcon>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.green[5]}
onClick={onPreview}
>
<CirclePlay size="18" />
</ActionIcon>
<Menu>
<Menu.Target>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<UnstyledButton
size="xs"
onClick={() => copyToClipboard(getChannelURL(row.original))}
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
onClick={onRecord}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
leftSection={
<div
style={{
borderRadius: '50%',
width: '10px',
height: '10px',
display: 'flex',
backgroundColor: 'red',
}}
></div>
}
>
<Text size="xs">Record</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Center>
</Box>
);
},
// Custom comparator: skip re-render when the channel's data object hasn't
// changed. row.original is stable when the underlying channel hasn't been
// updated; it becomes a new reference when the store replaces that channel.
(prevProps, nextProps) => prevProps.row.original === nextProps.row.original
);
const ChannelsTable = ({ onReady }) => {
// EPG data lookup
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
const hasUnassignedEPGChannels = useChannelsTableStore(
(s) => s.hasUnassignedEPGChannels
);
// Get channel logos for logo selection
const { ensureLogosLoaded } = useChannelLogoSelection();
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const hasSignaledReady = useRef(false);
/**
* STORES
*/
// store/channelsTable
const data = useChannelsTableStore((s) => s.channels);
const pageCount = useChannelsTableStore((s) => s.pageCount);
const rowClassMap = useMemo(() => {
const map = {};
for (const channel of data) {
const hasStreams = channel.streams?.length > 0;
if (!hasStreams) {
map[channel.id] = 'no-streams-row';
} else if (channel.streams.some((s) => s.is_stale)) {
map[channel.id] = 'has-stale-streams-row';
}
}
return map;
}, [data]);
const setSelectedChannelIds = useChannelsTableStore(
(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);
const setSorting = useChannelsTableStore((s) => s.setSorting);
const totalCount = useChannelsTableStore((s) => s.totalCount);
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
// store/channels
const hasChannels = useChannelsStore((s) => s.channelIds.length > 0);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const [, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
});
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
// store/warnings
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
/**
* useState
*/
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [showDisabled, setShowDisabled] = useState(true);
const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] =
useState(false);
const [showOnlyStaleChannels, setShowOnlyStaleChannels] = useState(false);
const [showOnlyOverriddenChannels, setShowOnlyOverriddenChannels] =
useState(false);
const [visibilityFilter, setVisibilityFilter] = useState('active');
const [paginationString, setPaginationString] = useState('');
const [filters, setFilters] = useState({
name: '',
channel_group: '',
epg: '',
});
const [, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [channelToDelete, setChannelToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const hasFetchedData = useRef(false);
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
// Drag-and-drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement before dragging starts
},
})
);
// Column sizing state for resizable columns
// Store in localStorage but with empty object as default
const [columnSizing, setColumnSizing] = useLocalStorage(
'channels-table-column-sizing',
{}
);
// M3U and EPG URL configuration state
const [m3uParams, setM3uParams] = useState({
cachedlogos: true,
direct: false,
tvg_id_source: 'channel_number',
});
const [epgParams, setEpgParams] = useState({
cachedlogos: true,
tvg_id_source: 'channel_number',
days: 0,
prev_days: 0,
});
/**
* Derived variables
*/
const groupOptions = Object.values(channelGroups)
.filter((group) => group.hasChannels)
.map((group) => group.name)
.sort((a, b) => a.localeCompare(b));
const epgOptions = Object.values(epgs)
.filter((epg) => epg.is_active && epg.has_channels)
.map((epg) => epg.name)
.sort((a, b) => a.localeCompare(b));
// Only show 'No EPG' if there are channels without an EPG assigned
const epgSelectOptions = [
...(hasUnassignedEPGChannels ? [{ value: 'null', label: 'No EPG' }] : []),
...epgOptions.map((opt) => ({ value: opt, label: opt })),
];
const debouncedFilters = useDebounce(filters, 500, () => {
setPagination({
...pagination,
pageIndex: 0,
});
});
const channelsTableLength =
Object.keys(data).length > 0 || hasFetchedData.current
? Object.keys(data).length
: undefined;
/**
* Functions
*/
const fetchData = useCallback(async () => {
// Build params first to check for duplicates
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
params.append('include_streams', 'true');
if (selectedProfileId !== '0') {
params.append('channel_profile_id', selectedProfileId);
}
if (showDisabled === true) {
params.append('show_disabled', true);
}
if (showOnlyStreamlessChannels === true) {
params.append('only_streamless', true);
}
if (showOnlyStaleChannels === true) {
params.append('only_stale', true);
}
if (showOnlyOverriddenChannels === true) {
params.append('only_has_overrides', true);
}
// The backend defaults to "active"; send other choices explicitly so
// hidden rows surface when the user opts into "Hidden Only" or "Show All".
if (visibilityFilter && visibilityFilter !== 'active') {
params.append('visibility_filter', visibilityFilter);
}
// Apply sorting
if (sorting.length > 0) {
let sortField = sorting[0].id;
// Map frontend column ids to backend ordering field names
const fieldMapping = {
channel_group: 'channel_group__name',
epg: 'epg_data__name',
};
if (fieldMapping[sortField]) {
sortField = fieldMapping[sortField];
}
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) {
if (Array.isArray(value)) {
// Convert null values to "null" string for URL parameter
const processedValue = value
.map((v) => (v === null ? 'null' : v))
.join(',');
params.append(key, processedValue);
} else {
params.append(key, value);
}
}
});
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
if (
fetchInProgressRef.current &&
lastFetchParamsRef.current === paramsString
) {
return;
}
// Increment fetch version to track this specific fetch request
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
setIsLoading(true);
try {
const [, ids] = await Promise.all([
API.queryChannels(params),
API.getAllChannelIds(params),
]);
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setIsLoading(false);
hasFetchedData.current = true;
setTablePrefs((prev) => ({
...prev,
pageSize: pagination.pageSize,
}));
setAllRowIds(ids);
// Signal ready after first successful data fetch AND EPG data is loaded
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
hasSignaledReady.current = true;
onReady();
}
} catch (error) {
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
setIsLoading(false);
// API layer handles "Invalid page" errors by resetting and retrying
// Just re-throw to show notification for actual errors
throw error;
}
}, [
pagination,
sorting,
debouncedFilters,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
showOnlyStaleChannels,
showOnlyOverriddenChannels,
visibilityFilter,
]);
const stopPropagation = useCallback((e) => {
e.stopPropagation();
}, []);
const handleFilterChange = (e) => {
const { name, value } = e.target;
// Then update filters
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
// Then update filters
setFilters((prev) => ({
...prev,
channel_group: value ? value : '',
}));
};
const handleEPGChange = (value) => {
// Map 'null' (string) back to 'null' for backend, but keep UI label correct
const processedValue = value
? value.map((v) => (v === 'null' ? 'null' : v))
: '';
setFilters((prev) => ({
...prev,
epg: processedValue,
}));
};
const editChannel = useCallback(async (ch = null, opts = {}) => {
if (opts.forceAdd) {
setChannel(null);
setChannelModalOpen(true);
return;
}
const currentSelection =
useChannelsTableStore.getState().selectedChannelIds;
console.log('editChannel called with:', { ch, currentSelection });
if (currentSelection.length > 1) {
setChannelBatchModalOpen(true);
} else {
let channelToEdit = ch;
if (!channelToEdit && currentSelection.length === 1) {
const selectedId = currentSelection[0];
channelToEdit = useChannelsTableStore
.getState()
.channels.find((d) => d.id === selectedId);
}
setChannel(channelToEdit);
setChannelModalOpen(true);
}
}, []);
const deleteChannel = async (id) => {
console.log(`Deleting channel with ID: ${id}`);
const rows = table.getRowModel().rows;
const knownChannel = rows.find((row) => row.original.id === id)?.original;
table.setSelectedTableIds([]);
if (selectedChannelIds.length > 0) {
// Use bulk delete for multiple selections
setIsBulkDelete(true);
setChannelToDelete(null);
if (isWarningSuppressed('delete-channels')) {
// Skip warning if suppressed
return executeDeleteChannels();
}
setConfirmDeleteOpen(true);
return;
}
// Single channel delete
setIsBulkDelete(false);
setDeleteTarget(id);
setChannelToDelete(knownChannel); // Store the channel object for displaying details
if (isWarningSuppressed('delete-channel')) {
// Skip warning if suppressed
return executeDeleteChannel(id);
}
setConfirmDeleteOpen(true);
};
const executeDeleteChannel = async (id) => {
setDeleting(true);
try {
await API.deleteChannel(id);
API.requeryChannels();
} finally {
setDeleting(false);
setConfirmDeleteOpen(false);
}
};
const deleteChannels = async () => {
if (isWarningSuppressed('delete-channels')) {
// Skip warning if suppressed
return executeDeleteChannels();
}
setIsBulkDelete(true);
setConfirmDeleteOpen(true);
};
const executeDeleteChannels = async () => {
setIsLoading(true);
setDeleting(true);
try {
await API.deleteChannels(table.selectedTableIds);
await API.requeryChannels();
setSelectedChannelIds([]);
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
const createRecording = useCallback((channel) => {
console.log(`Recording channel ID: ${channel.id}`);
setChannel(channel);
setRecordingModalOpen(true);
}, []);
const getChannelURL = useCallback(
(channel) => {
// Make sure we're using the channel UUID consistently
if (!channel || !channel.uuid) {
console.error('Invalid channel object or missing UUID:', channel);
return '';
}
const uri = `/proxy/ts/stream/${channel.uuid}`;
let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
if (env_mode == 'dev') {
channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
}
return channelUrl;
},
[env_mode]
);
const handleWatchStream = useCallback(
(channel) => {
const url = getChannelURL(channel);
showVideo(url, 'live', { name: channel.name, channelId: channel.id });
},
[getChannelURL, showVideo]
);
const onRowSelectionChange = (newSelection) => {
setSelectedChannelIds(newSelection);
};
const onRowExpansionChange = useCallback(
(expandedIds) => {
setExpandedChannelId(expandedIds.length > 0 ? expandedIds[0] : null);
},
[setExpandedChannelId]
);
const onPageSizeChange = (e) => {
setPagination({
...pagination,
pageSize: e.target.value,
});
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination({
...pagination,
pageIndex: pageIndex - 1,
});
};
const closeChannelBatchForm = () => {
setChannelBatchModalOpen(false);
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
};
const closeRecordingForm = () => {
// setChannel(null);
setRecordingModalOpen(false);
};
// Build URLs with parameters
const buildM3UUrl = () => {
const params = new URLSearchParams();
if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false');
if (m3uParams.direct) params.append('direct', 'true');
if (m3uParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', m3uParams.tvg_id_source);
const baseUrl = m3uUrl;
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
const buildEPGUrl = () => {
const params = new URLSearchParams();
if (!epgParams.cachedlogos) params.append('cachedlogos', 'false');
if (epgParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', epgParams.tvg_id_source);
if (epgParams.days > 0) params.append('days', epgParams.days.toString());
if (epgParams.prev_days > 0)
params.append('prev_days', epgParams.prev_days.toString());
const baseUrl = epgUrl;
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
// Example copy URLs
const copyM3UUrl = async () => {
await copyToClipboard(buildM3UUrl(), {
successTitle: 'M3U URL Copied!',
successMessage: 'The M3U URL has been copied to your clipboard.',
});
};
const copyEPGUrl = async () => {
await copyToClipboard(buildEPGUrl(), {
successTitle: 'EPG URL Copied!',
successMessage: 'The EPG URL has been copied to your clipboard.',
});
};
const copyHDHRUrl = async () => {
await copyToClipboard(hdhrUrl, {
successTitle: 'HDHR URL Copied!',
successMessage: 'The HDHR URL has been copied to your clipboard.',
});
};
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 {
setSorting([]);
}
} else {
setSorting([
{
id: column,
desc: false,
},
]);
}
};
const handleDragEnd = async (event) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeIndex = rows.findIndex((row) => row.id === active.id);
const overIndex = rows.findIndex((row) => row.id === over.id);
if (activeIndex === -1 || overIndex === -1) {
return;
}
const activeChannel = rows[activeIndex].original;
const overChannel = rows[overIndex].original;
try {
// Optimistically update the local state
const reorderedData = [...data];
const [movedItem] = reorderedData.splice(activeIndex, 1);
reorderedData.splice(overIndex, 0, movedItem);
useChannelsTableStore.setState({ channels: reorderedData });
// Call backend to reorder
await API.reorderChannel(
activeChannel.id,
overIndex > activeIndex
? overChannel.id
: rows[overIndex - 1]?.original.id || null
);
// Refetch to get updated channel numbers
await API.requeryChannels();
} catch (error) {
// Revert on error
console.error('Failed to reorder channel:', error);
await API.requeryChannels();
}
};
/**
* useEffect
*/
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const profileString =
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
setEPGUrl(`${epgUrlBase}${profileString}`);
setM3UUrl(`${m3uUrlBase}${profileString}`);
}, [selectedProfileId, profiles]);
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
totalCount
);
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
// Signal ready when EPG data finishes loading (if channels were already fetched)
useEffect(() => {
if (
hasFetchedData.current &&
!hasSignaledReady.current &&
onReady &&
tvgsLoaded
) {
hasSignaledReady.current = true;
onReady();
}
}, [tvgsLoaded, onReady]);
const columns = useMemo(
() => [
{
id: 'expand',
size: 20,
enableResizing: false,
},
{
id: 'select',
size: 30,
enableResizing: false,
},
{
id: 'enabled',
size: 45,
enableResizing: false,
cell: ({ row, table }) => {
return (
<ChannelEnabledSwitch
rowId={row.original.id}
selectedProfileId={selectedProfileId}
selectedTableIds={table.getState().selectedTableIds}
/>
);
},
},
{
id: 'channel_number',
// Prefer the backend-resolved effective_channel_number so overrides
// show through to the table. Inline save still writes to the
// override row via buildInlinePatch in EditableCell.
accessorFn: (row) => row.effective_channel_number ?? row.channel_number,
size: columnSizing.channel_number || 40,
minSize: 30,
maxSize: 100,
cell: (props) => <EditableNumberCell {...props} />,
},
{
id: 'name',
accessorFn: (row) => row.effective_name ?? row.name,
size: columnSizing.name || 200,
minSize: 100,
grow: true,
cell: (props) => {
const row = props.row?.original || {};
const overriddenLabels = listOverriddenFields(row);
return (
<Flex align="center" gap={6} style={{ minWidth: 0 }}>
<Box style={{ minWidth: 0, flex: 1 }}>
<EditableTextCell {...props} />
</Box>
{overriddenLabels.length > 0 && (
<Tooltip
label={`Overrides active: ${overriddenLabels.join(', ')}`}
>
<Box
component="span"
role="img"
aria-label={`Overrides active: ${overriddenLabels.join(', ')}`}
style={{ display: 'inline-flex' }}
>
<Pencil size={14} color="#eab308" aria-hidden="true" />
</Box>
</Tooltip>
)}
{row.hidden_from_output && (
<Tooltip label="Hidden from HDHR, M3U, EPG, and XC output.">
<Box
component="span"
role="img"
aria-label="Hidden from HDHR, M3U, EPG, and XC output"
style={{ display: 'inline-flex' }}
>
<EyeOff size={14} color="#9ca3af" aria-hidden="true" />
</Box>
</Tooltip>
)}
</Flex>
);
},
},
{
id: 'epg',
header: 'EPG',
// Effective EPG id so overridden EPG assignments show in the table.
accessorFn: (row) => row.effective_epg_data_id ?? row.epg_data_id,
cell: (props) => (
<EditableEPGCell
{...props}
tvgsById={tvgsById}
epgs={epgs}
tvgsLoaded={tvgsLoaded}
/>
),
size: columnSizing.epg || 200,
minSize: 120,
},
{
id: 'channel_group',
accessorFn: (row) => {
const effectiveGroupId =
row.effective_channel_group_id ?? row.channel_group_id;
return channelGroups[effectiveGroupId]
? channelGroups[effectiveGroupId].name
: '';
},
cell: (props) => (
<EditableGroupCell {...props} channelGroups={channelGroups} />
),
size: columnSizing.channel_group || 200,
minSize: 120,
},
{
id: 'logo',
accessorFn: (row) => row.effective_logo_id ?? row.logo_id,
size: 75,
minSize: 50,
maxSize: 120,
enableResizing: false,
header: '',
cell: (props) => (
<EditableLogoCell
{...props}
LazyLogo={LazyLogo}
ensureLogosLoaded={ensureLogosLoaded}
/>
),
},
{
id: 'actions',
size: 100,
enableResizing: false,
header: '',
cell: ({ row, table }) => (
<ChannelRowActions
theme={theme}
row={row}
table={table}
editChannel={editChannel}
deleteChannel={deleteChannel}
handleWatchStream={handleWatchStream}
createRecording={createRecording}
getChannelURL={getChannelURL}
/>
),
},
],
// Note: columnSizing is intentionally excluded from dependencies to prevent
// columns from being recreated during drag operations (which causes infinite loops).
// The column.size values are only used for INITIAL sizing - TanStack Table manages
// the actual sizes through its own state after initialization.
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
// from the store, so we don't need to recreate columns when logos load.
// Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedProfileId, channelGroups, theme, tvgsById, epgs, editChannel]
);
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 'epg':
return (
<MultiSelect
placeholder="EPG"
variant="unstyled"
data={epgSelectOptions}
className="table-input-header"
size="xs"
searchable
clearable
onClick={stopPropagation}
onChange={handleEPGChange}
value={
Array.isArray(filters.epg)
? filters.epg
: filters.epg
? filters.epg.split(',').filter(Boolean)
: []
}
style={{ width: '100%' }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('epg');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
);
case 'enabled':
return (
<Center style={{ width: '100%' }}>
<ScanEye size="16" />
</Center>
);
case 'channel_number':
return (
<Flex gap={2} align="center">
#
<Center
onClick={(e) => {
e.stopPropagation();
onSortingChange('channel_number');
}}
style={{ cursor: 'pointer' }}
>
{React.createElement(sortingIcon, {
size: 14,
})}
</Center>
</Flex>
);
case 'name':
return (
<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={{ width: '100%' }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('name');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
);
case 'channel_group':
return (
<MultiSelect
placeholder="Group"
className="table-input-header"
variant="unstyled"
data={groupOptions}
size="xs"
searchable
clearable
onClick={stopPropagation}
onChange={handleGroupChange}
value={
Array.isArray(filters.channel_group)
? filters.channel_group
: filters.channel_group
? filters.channel_group.split(',').filter(Boolean)
: []
}
style={{ width: '100%' }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
onClick: (e) => {
e.stopPropagation();
onSortingChange('channel_group');
},
size: 14,
style: { cursor: 'pointer' },
})}
/>
);
}
};
const table = useTable({
data,
columns,
allRowIds,
pageCount,
filters,
pagination,
sorting,
columnSizing,
setColumnSizing,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
enableDragDrop: true,
onRowSelectionChange: onRowSelectionChange,
onRowExpansionChange: onRowExpansionChange,
state: {
pagination,
sorting,
},
columnResizeMode: 'onChange',
getExpandedRowHeight: (row) => {
return 20 + 28 * row.original.streams.length;
},
expandedRowRenderer: ({ row }) => {
return (
<Box
key={row.id}
className="tr"
style={{ display: 'flex', width: '100%' }}
>
<ChannelTableStreams channel={row.original} />
</Box>
);
},
headerCellRenderFns: {
name: renderHeaderCell,
channel_number: renderHeaderCell,
channel_group: renderHeaderCell,
enabled: renderHeaderCell,
epg: renderHeaderCell,
},
getRowStyles: (row) => {
const cls = rowClassMap[row.original.id];
return cls ? { className: cls } : {};
},
});
const rows = table.getRowModel().rows;
return (
<>
<Box>
{/* Header Row: outside the Paper */}
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Channels
</Text>
<Flex
style={{
display: 'flex',
alignItems: 'center',
marginLeft: 10,
}}
>
<Text
w={37}
h={17}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
}}
>
Links:
</Text>
<Group gap={5} style={{ paddingLeft: 10 }}>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
p={5}
color="green"
variant="subtle"
style={{
borderColor: theme.palette.custom.greenMain,
color: theme.palette.custom.greenMain,
}}
>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack
gap="sm"
style={{
minWidth: 250,
maxWidth: 'min(400px, 80vw)',
width: 'max-content',
}}
>
<Text size="sm" c="dimmed">
Use this URL in HDHomeRun-compatible apps and IPTV
clients.
</Text>
<TextInput
value={hdhrUrl}
size="sm"
readOnly
style={{ width: '100%' }}
rightSection={
<ActionIcon
onClick={copyHDHRUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<Copy size="16" />
</ActionIcon>
}
/>
</Stack>
</Popover.Dropdown>
</Popover>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
p={5}
variant="subtle"
style={{
borderColor: theme.palette.custom.indigoMain,
color: theme.palette.custom.indigoMain,
}}
>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack
gap="sm"
style={{
minWidth: 300,
maxWidth: 'min(500px, 90vw)',
width: 'max-content',
}}
onClick={stopPropagation}
onMouseDown={stopPropagation}
>
<Text size="sm" c="dimmed">
Use this URL in your media player or IPTV app to load your
channel list.
</Text>
<TextInput
value={buildM3UUrl()}
size="sm"
readOnly
label="Generated URL"
rightSection={
<ActionIcon
onClick={copyM3UUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<Copy size="16" />
</ActionIcon>
}
/>
<Switch
label="Use cached logos"
description="Proxy channel logos through Dispatcharr"
size="sm"
checked={m3uParams.cachedlogos}
onChange={(event) =>
setM3uParams((prev) => ({
...prev,
cachedlogos: event.target.checked,
}))
}
/>
<Switch
label="Direct stream URLs"
description="Bypass the Dispatcharr proxy; client connects directly to the source"
size="sm"
checked={m3uParams.direct}
onChange={(event) =>
setM3uParams((prev) => ({
...prev,
direct: event.target.checked,
}))
}
/>
<Select
label="TVG-ID Source"
description="Value used as the tvg-id attribute in the M3U"
size="sm"
value={m3uParams.tvg_id_source}
onChange={(value) =>
setM3uParams((prev) => ({
...prev,
tvg_id_source: value,
}))
}
comboboxProps={{ withinPortal: false }}
data={[
{ value: 'channel_number', label: 'Channel Number' },
{ value: 'tvg_id', label: 'TVG-ID' },
{ value: 'gracenote', label: 'Gracenote Station ID' },
]}
/>
</Stack>
</Popover.Dropdown>
</Popover>
<Popover
withArrow
shadow="md"
zIndex={1000}
position="bottom-start"
withinPortal
>
<Popover.Target>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
p={5}
variant="subtle"
color="gray.5"
style={{
borderColor: theme.palette.custom.greyBorder,
color: theme.palette.custom.greyBorder,
}}
>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack
gap="sm"
style={{
minWidth: 300,
maxWidth: 'min(450px, 85vw)',
width: 'max-content',
}}
onClick={stopPropagation}
onMouseDown={stopPropagation}
>
<Text size="sm" c="dimmed">
Use this URL in your IPTV app for program guide data.
Per-user defaults for days forward/back can be set in
account settings, which apply automatically for XC
clients.
</Text>
<TextInput
value={buildEPGUrl()}
size="sm"
readOnly
label="Generated URL"
rightSection={
<ActionIcon
onClick={copyEPGUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<Copy size="16" />
</ActionIcon>
}
/>
<Switch
label="Use cached logos"
description="Proxy channel logos through Dispatcharr"
size="sm"
checked={epgParams.cachedlogos}
onChange={(event) =>
setEpgParams((prev) => ({
...prev,
cachedlogos: event.target.checked,
}))
}
/>
<Select
label="TVG-ID Source"
description="Value used to match EPG channels to M3U streams"
size="sm"
value={epgParams.tvg_id_source}
onChange={(value) =>
setEpgParams((prev) => ({
...prev,
tvg_id_source: value,
}))
}
comboboxProps={{ withinPortal: false }}
data={[
{ value: 'channel_number', label: 'Channel Number' },
{ value: 'tvg_id', label: 'TVG-ID' },
{ value: 'gracenote', label: 'Gracenote Station ID' },
]}
/>
<NumberInput
label="Days forward (0 = all)"
description="Limit EPG to this many future days; 0 returns all available data"
size="sm"
min={0}
max={365}
value={epgParams.days}
onChange={(value) =>
setEpgParams((prev) => ({
...prev,
days: value || 0,
}))
}
/>
<NumberInput
label="Days back (0 = none)"
description="Include this many past days of EPG data (max 30)"
size="sm"
min={0}
max={30}
value={epgParams.prev_days}
onChange={(value) =>
setEpgParams((prev) => ({
...prev,
prev_days: value || 0,
}))
}
/>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
</Flex>
</Flex>
{/* Paper container: contains top toolbar and table (or ghost state) */}
<Paper
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 60px)',
backgroundColor: '#27272A',
}}
>
<ChannelTableHeader
rows={rows}
editChannel={editChannel}
deleteChannels={deleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
showDisabled={showDisabled}
setShowDisabled={setShowDisabled}
showOnlyStreamlessChannels={showOnlyStreamlessChannels}
setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels}
showOnlyStaleChannels={showOnlyStaleChannels}
setShowOnlyStaleChannels={setShowOnlyStaleChannels}
showOnlyOverriddenChannels={showOnlyOverriddenChannels}
setShowOnlyOverriddenChannels={setShowOnlyOverriddenChannels}
visibilityFilter={visibilityFilter}
setVisibilityFilter={setVisibilityFilter}
/>
{/* Table or ghost empty state inside Paper */}
<Box>
{channelsTableLength === 0 && !hasChannels && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>
{(channelsTableLength > 0 || hasChannels) && (
<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)',
}}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
<CustomTable table={table} />
</SortableContext>
</DndContext>
</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>
<ChannelForm
channel={channel}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
<ChannelBatchForm
channelIds={selectedChannelIds}
isOpen={channelBatchModalOpen}
onClose={closeChannelBatchForm}
/>
<RecordingForm
channel={channel}
isOpen={recordingModalOpen}
onClose={closeRecordingForm}
/>
</Box>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() =>
isBulkDelete
? executeDeleteChannels()
: executeDeleteChannel(deleteTarget)
}
loading={deleting}
title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Channel Deletion`}
message={
isBulkDelete ? (
`Are you sure you want to delete ${table.selectedTableIds.length} channels? This action cannot be undone.`
) : channelToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following channel?
Name: ${channelToDelete.name}
Channel Number: ${channelToDelete.channel_number}
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this channel? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey={isBulkDelete ? 'delete-channels' : 'delete-channel'}
onSuppressChange={suppressWarning}
size="md"
/>
</>
);
};
export default ChannelsTable;