mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
9367d76dac
commit
e733b9872f
3 changed files with 162 additions and 159 deletions
119
frontend/src/components/ProgramPreview.jsx
Normal file
119
frontend/src/components/ProgramPreview.jsx
Normal 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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue