Refactor: Extract shared ProgramPreview component and fix EPG preview bugs

- Extract ProgramPreview component from duplicated code in Channel.jsx
  and StreamConnectionCard.jsx (~80+ lines each)
- Fix watch('epg_data_id') called directly in useEffect dependency array,
  which could cause infinite re-renders
- Add request cancellation for rapid EPG selection changes in channel form
- Remove unused imports (Radio, Progress, ChevronDown, ChevronRight)
This commit is contained in:
FiveBoroughs 2026-02-04 10:58:21 +01:00 committed by Five Boroughs
parent 9367d76dac
commit e733b9872f
No known key found for this signature in database
3 changed files with 162 additions and 159 deletions

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import {
ActionIcon,
Box,
Group,
Progress,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { ChevronDown, ChevronRight, Radio } from 'lucide-react';
const formatProgramTime = (seconds) => {
const absSeconds = Math.abs(seconds);
const hours = Math.floor(absSeconds / 3600);
const minutes = Math.floor((absSeconds % 3600) / 60);
const secs = Math.floor(absSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const ProgramPreview = ({ program, loading, fetched, label = 'Now Playing:' }) => {
const [isExpanded, setIsExpanded] = useState(false);
if (loading) {
return (
<Group gap={5}>
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" c="dimmed">Loading EPG data...</Text>
</Group>
);
}
if (fetched && !program) {
return (
<Group gap={5}>
<Radio size="14" style={{ color: '#6b7280', flexShrink: 0 }} />
<Text size="xs" c="dimmed">No current program (EPG may need refresh)</Text>
</Group>
);
}
if (!program) {
return null;
}
const now = new Date();
const startTime = program.start_time ? new Date(program.start_time) : null;
const endTime = program.end_time ? new Date(program.end_time) : null;
let elapsed = 0, remaining = 0, percentage = 0;
let hasValidTime = false;
if (startTime && endTime) {
const totalDuration = (endTime - startTime) / 1000;
if (totalDuration > 0) {
hasValidTime = true;
elapsed = (now - startTime) / 1000;
remaining = (endTime - now) / 1000;
percentage = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
}
}
return (
<>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
{label}
</Text>
<Tooltip label={program.title}>
<Text size="xs" c="dimmed" truncate>
{program.title}
</Text>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsExpanded(!isExpanded)}
style={{ flexShrink: 0 }}
>
{isExpanded ? <ChevronDown size="14" /> : <ChevronRight size="14" />}
</ActionIcon>
</Group>
{isExpanded && program.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{program.description}
</Text>
</Box>
)}
{isExpanded && hasValidTime && (
<Stack gap="xs" mt={4} ml={24}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
)}
</>
);
};
export default ProgramPreview;

View file

@ -11,7 +11,6 @@ import {
Card,
Center,
Group,
Progress,
Select,
Stack,
Text,
@ -19,18 +18,16 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
ChevronDown,
ChevronRight,
CirclePlay,
Gauge,
HardDriveDownload,
HardDriveUpload,
Radio,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import ProgramPreview from '../ProgramPreview';
import {
toFriendlyDuration,
formatExactDuration,
@ -120,7 +117,7 @@ const StreamConnectionCard = ({
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [data, setData] = useState([]);
const [previewedStream, setPreviewedStream] = useState(null);
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
const theme = useMantineTheme();
@ -663,48 +660,11 @@ const StreamConnectionCard = ({
{/* Display current program on its own line */}
{currentProgram && (
<Group gap={5} mt={-9} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
Now Playing:
</Text>
<Text size="xs" c="dimmed" truncate>
{currentProgram.title}
</Text>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
style={{ flexShrink: 0 }}
>
{isProgramDescExpanded ? (
<ChevronDown size="14" />
) : (
<ChevronRight size="14" />
)}
</ActionIcon>
</Group>
<Box mt={-9}>
<ProgramPreview program={currentProgram} />
</Box>
)}
{/* Expandable program description */}
{currentProgram &&
isProgramDescExpanded &&
currentProgram.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{currentProgram.description}
</Text>
</Box>
)}
{/* Program progress bar */}
{currentProgram &&
isProgramDescExpanded &&
currentProgram.start_time &&
currentProgram.end_time && (
<ProgramProgress currentProgram={currentProgram} />
)}
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (
<Box mt={-10}>

View file

@ -33,9 +33,9 @@ import {
Tooltip,
UnstyledButton,
useMantineTheme,
Progress,
} from '@mantine/core';
import { ListOrdered, Radio, SquarePlus, Undo2, X, Zap } from 'lucide-react';
import { ListOrdered, SquarePlus, Undo2, X, Zap } from 'lucide-react';
import ProgramPreview from '../ProgramPreview';
import useEPGsStore from '../../store/epgs';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
@ -137,7 +137,6 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
const [tvgFilter, setTvgFilter] = useState('');
const [logoFilter, setLogoFilter] = useState('');
const [currentProgram, setCurrentProgram] = useState(null);
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
const [hasFetchedProgram, setHasFetchedProgram] = useState(false);
@ -461,31 +460,43 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
}
}, [defaultValues, channel, reset, epgs, tvgsById]);
const epgDataId = watch('epg_data_id');
useEffect(() => {
const fetchCurrentProgram = async () => {
const epgDataId = watch('epg_data_id');
if (epgDataId && epgDataId !== '0' && epgDataId !== '') {
setIsLoadingProgram(true);
setHasFetchedProgram(false);
try {
const program = await API.getCurrentProgramForEpg(epgDataId);
if (!epgDataId || epgDataId === '0' || epgDataId === '') {
setCurrentProgram(null);
setIsLoadingProgram(false);
setHasFetchedProgram(false);
return;
}
let cancelled = false;
setIsLoadingProgram(true);
setHasFetchedProgram(false);
API.getCurrentProgramForEpg(epgDataId)
.then((program) => {
if (!cancelled) {
setCurrentProgram(program);
} catch (error) {
}
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to fetch current program:', error);
setCurrentProgram(null);
} finally {
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingProgram(false);
setHasFetchedProgram(true);
}
} else {
setCurrentProgram(null);
setIsLoadingProgram(false);
setHasFetchedProgram(false);
}
};
});
fetchCurrentProgram();
}, [watch('epg_data_id')]);
return () => {
cancelled = true;
};
}, [epgDataId]);
// Memoize logo options to prevent infinite re-renders during background loading
const logoOptions = useMemo(() => {
@ -1251,101 +1262,14 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
</PopoverDropdown>
</Popover>
{isLoadingProgram && (
{(isLoadingProgram || hasFetchedProgram || currentProgram) && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5}>
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" c="dimmed">Loading EPG data...</Text>
</Group>
</Box>
)}
{hasFetchedProgram && !currentProgram && !isLoadingProgram && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5}>
<Radio size="14" style={{ color: '#6b7280', flexShrink: 0 }} />
<Text size="xs" c="dimmed">No current program (EPG may need refresh)</Text>
</Group>
</Box>
)}
{currentProgram && (
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
Current Program:
</Text>
<Tooltip label={currentProgram.title}>
<Text size="xs" c="dimmed" truncate>
{currentProgram.title}
</Text>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
style={{ flexShrink: 0 }}
>
{isProgramDescExpanded ? '▼' : '▶'}
</ActionIcon>
</Group>
{isProgramDescExpanded && currentProgram.description && (
<Box mt={4} ml={24}>
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{currentProgram.description}
</Text>
</Box>
)}
{isProgramDescExpanded &&
currentProgram.start_time &&
currentProgram.end_time &&
(() => {
const now = new Date();
const startTime = new Date(currentProgram.start_time);
const endTime = new Date(currentProgram.end_time);
const totalDuration = (endTime - startTime) / 1000;
const elapsed = (now - startTime) / 1000;
const remaining = (endTime - now) / 1000;
const percentage = Math.min(
100,
Math.max(0, (elapsed / totalDuration) * 100)
);
const formatProgramTime = (seconds) => {
const absSeconds = Math.abs(seconds);
const hours = Math.floor(absSeconds / 3600);
const minutes = Math.floor((absSeconds % 3600) / 60);
const secs = Math.floor(absSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<Stack gap="xs" mt={4} ml={24}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
);
})()}
<ProgramPreview
program={currentProgram}
loading={isLoadingProgram}
fetched={hasFetchedProgram}
label="Current Program:"
/>
</Box>
)}
</Stack>