import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import useChannelsStore from '../../store/channels'; import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; import useStreamsStore from '../../store/streams'; import ChannelGroupForm from './ChannelGroup'; import usePlaylistsStore from '../../store/playlists'; import logo from '../../images/logo.png'; import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; import useLogosStore from '../../store/logos'; import LazyLogo from '../LazyLogo'; import LogoForm from './Logo'; import { Box, Button, Modal, TextInput, NativeSelect, Text, Group, ActionIcon, Center, Grid, Flex, Select, Divider, Stack, useMantineTheme, Popover, ScrollArea, Tooltip, NumberInput, Image, UnstyledButton, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { FixedSizeList as List } from 'react-window'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; const ChannelForm = ({ channel = null, isOpen, onClose }) => { const theme = useMantineTheme(); const listRef = useRef(null); const logoListRef = useRef(null); const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const { logos: channelLogos, ensureLogosLoaded, isLoading: logosLoading, } = useChannelLogoSelection(); // Import the full logos store for duplicate checking const allLogos = useLogosStore((s) => s.logos); // Ensure logos are loaded when component mounts useEffect(() => { ensureLogosLoaded(); }, [ensureLogosLoaded]); const streams = useStreamsStore((state) => state.streams); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const playlists = usePlaylistsStore((s) => s.playlists); const epgs = useEPGsStore((s) => s.epgs); const tvgs = useEPGsStore((s) => s.tvgs); const tvgsById = useEPGsStore((s) => s.tvgsById); const [logoModalOpen, setLogoModalOpen] = useState(false); const [channelStreams, setChannelStreams] = useState([]); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [epgPopoverOpened, setEpgPopoverOpened] = useState(false); const [logoPopoverOpened, setLogoPopoverOpened] = useState(false); const [selectedEPG, setSelectedEPG] = useState(''); const [tvgFilter, setTvgFilter] = useState(''); const [logoFilter, setLogoFilter] = useState(''); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); const [autoMatchLoading, setAutoMatchLoading] = useState(false); const groupOptions = Object.values(channelGroups); const addStream = (stream) => { const streamSet = new Set(channelStreams); streamSet.add(stream); setChannelStreams(Array.from(streamSet)); }; const removeStream = (stream) => { const streamSet = new Set(channelStreams); streamSet.delete(stream); setChannelStreams(Array.from(streamSet)); }; const handleLogoSuccess = ({ logo }) => { if (logo && logo.id) { formik.setFieldValue('logo_id', logo.id); ensureLogosLoaded(); // Refresh logos } setLogoModalOpen(false); }; const handleAutoMatchEpg = async () => { // Only attempt auto-match for existing channels (editing mode) if (!channel || !channel.id) { notifications.show({ title: 'Info', message: 'Auto-match is only available when editing existing channels.', color: 'blue', }); return; } setAutoMatchLoading(true); try { const response = await API.matchChannelEpg(channel.id); if (response.matched) { // Update the form with the new EPG data if (response.channel && response.channel.epg_data_id) { formik.setFieldValue('epg_data_id', response.channel.epg_data_id); } notifications.show({ title: 'Success', message: response.message, color: 'green', }); } else { notifications.show({ title: 'No Match Found', message: response.message, color: 'orange', }); } } catch (error) { notifications.show({ title: 'Error', message: 'Failed to auto-match EPG data', color: 'red', }); console.error('Auto-match error:', error); } finally { setAutoMatchLoading(false); } }; const handleSetNameFromEpg = () => { const epgDataId = formik.values.epg_data_id; if (!epgDataId) { notifications.show({ title: 'No EPG Selected', message: 'Please select an EPG source first.', color: 'orange', }); return; } const tvg = tvgsById[epgDataId]; if (tvg && tvg.name) { formik.setFieldValue('name', tvg.name); notifications.show({ title: 'Success', message: `Channel name set to "${tvg.name}"`, color: 'green', }); } else { notifications.show({ title: 'No Name Available', message: 'No name found in the selected EPG data.', color: 'orange', }); } }; const handleSetLogoFromEpg = async () => { const epgDataId = formik.values.epg_data_id; if (!epgDataId) { notifications.show({ title: 'No EPG Selected', message: 'Please select an EPG source first.', color: 'orange', }); return; } const tvg = tvgsById[epgDataId]; if (!tvg || !tvg.icon_url) { notifications.show({ title: 'No EPG Icon', message: 'EPG data does not have an icon URL.', color: 'orange', }); return; } try { // Try to find a logo that matches the EPG icon URL - check ALL logos to avoid duplicates let matchingLogo = Object.values(allLogos).find( (logo) => logo.url === tvg.icon_url ); if (matchingLogo) { formik.setFieldValue('logo_id', matchingLogo.id); notifications.show({ title: 'Success', message: `Logo set to "${matchingLogo.name}"`, color: 'green', }); } else { // Logo doesn't exist - create it notifications.show({ id: 'creating-logo', title: 'Creating Logo', message: `Creating new logo from EPG icon URL...`, loading: true, }); try { const newLogoData = { name: tvg.name || `Logo for ${tvg.icon_url}`, url: tvg.icon_url, }; // Create logo by calling the Logo API directly const newLogo = await API.createLogo(newLogoData); formik.setFieldValue('logo_id', newLogo.id); notifications.update({ id: 'creating-logo', title: 'Success', message: `Created and assigned new logo "${newLogo.name}"`, loading: false, color: 'green', autoClose: 5000, }); } catch (createError) { notifications.update({ id: 'creating-logo', title: 'Error', message: 'Failed to create logo from EPG icon URL', loading: false, color: 'red', autoClose: 5000, }); throw createError; } } } catch (error) { notifications.show({ title: 'Error', message: 'Failed to set logo from EPG data', color: 'red', }); console.error('Set logo from EPG error:', error); } }; const handleSetTvgIdFromEpg = () => { const epgDataId = formik.values.epg_data_id; if (!epgDataId) { notifications.show({ title: 'No EPG Selected', message: 'Please select an EPG source first.', color: 'orange', }); return; } const tvg = tvgsById[epgDataId]; if (tvg && tvg.tvg_id) { formik.setFieldValue('tvg_id', tvg.tvg_id); notifications.show({ title: 'Success', message: `TVG-ID set to "${tvg.tvg_id}"`, color: 'green', }); } else { notifications.show({ title: 'No TVG-ID Available', message: 'No TVG-ID found in the selected EPG data.', color: 'orange', }); } }; const formik = useFormik({ initialValues: { name: '', channel_number: '', // Change from 0 to empty string for consistency channel_group_id: Object.keys(channelGroups).length > 0 ? Object.keys(channelGroups)[0] : '', stream_profile_id: '0', tvg_id: '', tvc_guide_stationid: '', epg_data_id: '', logo_id: '', user_level: '0', }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), channel_group_id: Yup.string().required('Channel group is required'), }), onSubmit: async (values, { setSubmitting }) => { let response; try { const formattedValues = { ...values }; // Convert empty or "0" stream_profile_id to null for the API if ( !formattedValues.stream_profile_id || formattedValues.stream_profile_id === '0' ) { formattedValues.stream_profile_id = null; } // Ensure tvg_id is properly included (no empty strings) formattedValues.tvg_id = formattedValues.tvg_id || null; // Ensure tvc_guide_stationid is properly included (no empty strings) formattedValues.tvc_guide_stationid = formattedValues.tvc_guide_stationid || null; if (channel) { // If there's an EPG to set, use our enhanced endpoint if (values.epg_data_id !== (channel.epg_data_id ?? '')) { // Use the special endpoint to set EPG and trigger refresh const epgResponse = await API.setChannelEPG( channel.id, values.epg_data_id ); // Remove epg_data_id from values since we've handled it separately const { epg_data_id, ...otherValues } = formattedValues; // Update other channel fields if needed if (Object.keys(otherValues).length > 0) { response = await API.updateChannel({ id: channel.id, ...otherValues, streams: channelStreams.map((stream) => stream.id), }); } } else { // No EPG change, regular update response = await API.updateChannel({ id: channel.id, ...formattedValues, streams: channelStreams.map((stream) => stream.id), }); } } else { // New channel creation - use the standard method response = await API.addChannel({ ...formattedValues, streams: channelStreams.map((stream) => stream.id), }); } } catch (error) { console.error('Error saving channel:', error); } formik.resetForm(); API.requeryChannels(); // Refresh channel profiles to update the membership information useChannelsStore.getState().fetchChannelProfiles(); setSubmitting(false); setTvgFilter(''); setLogoFilter(''); onClose(); }, }); useEffect(() => { if (channel) { if (channel.epg_data_id) { const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source]; setSelectedEPG(epgSource ? `${epgSource.id}` : ''); } formik.setValues({ name: channel.name || '', channel_number: channel.channel_number !== null ? channel.channel_number : '', channel_group_id: channel.channel_group_id ? `${channel.channel_group_id}` : '', stream_profile_id: channel.stream_profile_id ? `${channel.stream_profile_id}` : '0', tvg_id: channel.tvg_id || '', tvc_guide_stationid: channel.tvc_guide_stationid || '', epg_data_id: channel.epg_data_id ?? '', logo_id: channel.logo_id ? `${channel.logo_id}` : '', user_level: `${channel.user_level}`, }); setChannelStreams(channel.streams || []); } else { formik.resetForm(); setTvgFilter(''); setLogoFilter(''); setChannelStreams([]); // Ensure streams are cleared when adding a new channel } }, [channel, tvgsById, channelGroups]); // Memoize logo options to prevent infinite re-renders during background loading const logoOptions = useMemo(() => { const options = [{ id: '0', name: 'Default' }].concat( Object.values(channelLogos) ); return options; }, [channelLogos]); // Only depend on channelLogos object // Update the handler for when channel group modal is closed const handleChannelGroupModalClose = (newGroup) => { setChannelGroupModalOpen(false); // If a new group was created and returned, update the form with it if (newGroup && newGroup.id) { // Preserve all current form values while updating just the channel_group_id formik.setValues({ ...formik.values, channel_group_id: `${newGroup.id}`, }); } }; if (!isOpen) { return <>; } const filteredTvgs = tvgs .filter((tvg) => tvg.epg_source == selectedEPG) .filter( (tvg) => tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) || tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase()) ); const filteredLogos = logoOptions.filter((logo) => logo.name.toLowerCase().includes(logoFilter.toLowerCase()) ); const filteredGroups = groupOptions.filter((group) => group.name.toLowerCase().includes(groupFilter.toLowerCase()) ); return ( <> Channels } styles={{ content: { '--mantine-color-body': '#27272A' } }} >
Channel Name {formik.values.epg_data_id && ( )} } value={formik.values.name} onChange={formik.handleChange} error={formik.errors.name ? formik.touched.name : ''} size="xs" style={{ flex: 1 }} /> setGroupPopoverOpened(true)} size="xs" /> e.stopPropagation()}> setGroupFilter(event.currentTarget.value) } mb="xs" size="xs" /> {({ index, style }) => ( { formik.setFieldValue( 'channel_group_id', filteredGroups[index].id ); setGroupPopoverOpened(false); }} > {filteredGroups[index].name} )} {/* { formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value }} error={ formik.errors.stream_profile_id ? formik.touched.stream_profile_id : '' } data={[{ value: '0', label: '(use default)' }].concat( streamProfiles.map((option) => ({ value: `${option.id}`, label: option.name, })) )} size="xs" /> ({ value: `${epg.id}`, label: epg.name, }))} size="xs" mb="xs" /> {/* Filter Input */} setTvgFilter(event.currentTarget.value) } mb="xs" size="xs" autoFocus /> {({ index, style }) => (
)}
setLogoModalOpen(false)} onSuccess={handleLogoSuccess} /> ); }; export default ChannelForm;