mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #761 from nick4810/enhancement/component-cleanup
Enhancement/component cleanup
This commit is contained in:
commit
af88756197
24 changed files with 1924 additions and 1717 deletions
|
|
@ -19,7 +19,6 @@ import Users from './pages/Users';
|
|||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
import useAuthStore from './store/auth';
|
||||
import useLogosStore from './store/logos';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
import { Box, AppShell, MantineProvider } from '@mantine/core';
|
||||
|
|
@ -40,8 +39,6 @@ const defaultRoute = '/channels';
|
|||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [backgroundLoadingStarted, setBackgroundLoadingStarted] =
|
||||
useState(false);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
|
@ -81,11 +78,7 @@ const App = () => {
|
|||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
// Start background logo loading after app is fully initialized (only once)
|
||||
if (!backgroundLoadingStarted) {
|
||||
setBackgroundLoadingStarted(true);
|
||||
useLogosStore.getState().startBackgroundLoading();
|
||||
}
|
||||
// Logos are now loaded at the end of initData, no need for background loading
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
|
|
@ -96,7 +89,7 @@ const App = () => {
|
|||
};
|
||||
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, logout, backgroundLoadingStarted]);
|
||||
}, [initializeAuth, initData, logout]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
|
|
|
|||
18
frontend/src/components/ErrorBoundary.jsx
Normal file
18
frontend/src/components/ErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div>Something went wrong</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
import useLogosStore from '../store/logos';
|
||||
import logo from '../images/logo.png'; // Default logo
|
||||
|
|
@ -16,15 +16,16 @@ const LazyLogo = ({
|
|||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const fetchAttempted = useRef(new Set()); // Track which IDs we've already tried to fetch
|
||||
const fetchAttempted = useRef(new Set());
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds);
|
||||
const allowLogoRendering = useLogosStore((s) => s.allowLogoRendering);
|
||||
|
||||
// Determine the logo source
|
||||
const logoData = logoId && logos[logoId];
|
||||
const logoSrc = logoData?.cache_url || fallbackSrc; // Only use cache URL if we have logo data
|
||||
const logoSrc = logoData?.cache_url || fallbackSrc;
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -34,6 +35,9 @@ const LazyLogo = ({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't start fetching until logo rendering is allowed
|
||||
if (!allowLogoRendering) return;
|
||||
|
||||
// If we have a logoId but no logo data, add it to the batch request queue
|
||||
if (
|
||||
logoId &&
|
||||
|
|
@ -44,7 +48,7 @@ const LazyLogo = ({
|
|||
isMountedRef.current
|
||||
) {
|
||||
setIsLoading(true);
|
||||
fetchAttempted.current.add(logoId); // Mark this ID as attempted
|
||||
fetchAttempted.current.add(logoId);
|
||||
logoRequestQueue.add(logoId);
|
||||
|
||||
// Clear existing timer and set new one to batch requests
|
||||
|
|
@ -82,7 +86,7 @@ const LazyLogo = ({
|
|||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logoId, fetchLogosByIds, logoData]); // Include logoData to detect when it becomes available
|
||||
}, [logoId, fetchLogosByIds, logoData, allowLogoRendering]);
|
||||
|
||||
// Reset error state when logoId changes
|
||||
useEffect(() => {
|
||||
|
|
@ -91,8 +95,10 @@ const LazyLogo = ({
|
|||
}
|
||||
}, [logoId]);
|
||||
|
||||
// Show skeleton while loading
|
||||
if (isLoading && !logoData) {
|
||||
// Show skeleton if:
|
||||
// 1. Logo rendering is not allowed yet, OR
|
||||
// 2. We don't have logo data yet (regardless of loading state)
|
||||
if (logoId && (!allowLogoRendering || !logoData)) {
|
||||
return (
|
||||
<Skeleton
|
||||
height={style.maxHeight || 18}
|
||||
|
|
|
|||
26
frontend/src/components/RecordingSynopsis.jsx
Normal file
26
frontend/src/components/RecordingSynopsis.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Text, } from '@mantine/core';
|
||||
|
||||
// Short preview that triggers the details modal when clicked
|
||||
const RecordingSynopsis = ({ description, onOpen }) => {
|
||||
const truncated = description?.length > 140;
|
||||
const preview = truncated
|
||||
? `${description.slice(0, 140).trim()}...`
|
||||
: description;
|
||||
|
||||
if (!description) return null;
|
||||
|
||||
return (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
lineClamp={2}
|
||||
title={description}
|
||||
onClick={() => onOpen?.()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{preview}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingSynopsis;
|
||||
422
frontend/src/components/cards/RecordingCard.jsx
Normal file
422
frontend/src/components/cards/RecordingCard.jsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import useSettingsStore from '../../store/settings.jsx';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import React from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { AlertTriangle, SquareX } from 'lucide-react';
|
||||
import RecordingSynopsis from '../RecordingSynopsis';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
deleteSeriesAndRule,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
getSeriesInfo,
|
||||
getShowVideoUrl,
|
||||
removeRecording,
|
||||
runComSkip,
|
||||
} from './../../utils/cards/RecordingCardUtils.js';
|
||||
|
||||
const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [timeformat, dateformat] = useDateTimeFormat();
|
||||
|
||||
const channel = channels?.[recording.channel];
|
||||
|
||||
const customProps = recording.custom_properties || {};
|
||||
const program = customProps.program || {};
|
||||
const recordingName = program.title || 'Custom Recording';
|
||||
const subTitle = program.sub_title || '';
|
||||
const description = program.description || customProps.description || '';
|
||||
const isRecurringRule = customProps?.rule?.type === 'recurring';
|
||||
|
||||
// Poster or channel logo
|
||||
const posterUrl = getPosterUrl(
|
||||
customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode);
|
||||
|
||||
const start = toUserTime(recording.start_time);
|
||||
const end = toUserTime(recording.end_time);
|
||||
const now = userNow();
|
||||
const status = customProps.status;
|
||||
const isTimeActive = now.isAfter(start) && now.isBefore(end);
|
||||
const isInterrupted = status === 'interrupted';
|
||||
const isInProgress = isTimeActive; // Show as recording by time, regardless of status glitches
|
||||
const isUpcoming = now.isBefore(start);
|
||||
const isSeriesGroup = Boolean(
|
||||
recording._group_count && recording._group_count > 1
|
||||
);
|
||||
// Season/Episode display if present
|
||||
const season = customProps.season ?? program?.custom_properties?.season;
|
||||
const episode = customProps.episode ?? program?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
customProps.onscreen_episode ??
|
||||
program?.custom_properties?.onscreen_episode;
|
||||
const seLabel = getSeasonLabel(season, episode, onscreen);
|
||||
|
||||
const handleWatchLive = () => {
|
||||
if (!channel) return;
|
||||
showVideo(getShowVideoUrl(channel, env_mode), 'live');
|
||||
};
|
||||
|
||||
const handleWatchRecording = () => {
|
||||
// Only enable if backend provides a playable file URL in custom properties
|
||||
const fileUrl = getRecordingUrl(customProps, env_mode);
|
||||
if (!fileUrl) return;
|
||||
|
||||
showVideo(fileUrl, 'vod', {
|
||||
name: recordingName,
|
||||
logo: { url: posterUrl },
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunComskip = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await runComSkip(recording);
|
||||
notifications.show({
|
||||
title: 'Removing commercials',
|
||||
message: 'Queued comskip for this recording',
|
||||
color: 'blue.5',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to queue comskip for recording', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel handling for series groups
|
||||
const [cancelOpen, setCancelOpen] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const handleCancelClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isRecurringRule) {
|
||||
onOpenRecurring?.(recording, true);
|
||||
return;
|
||||
}
|
||||
if (isSeriesGroup) {
|
||||
setCancelOpen(true);
|
||||
} else {
|
||||
removeRecording(recording.id);
|
||||
}
|
||||
};
|
||||
|
||||
const seriesInfo = getSeriesInfo(customProps);
|
||||
|
||||
const removeUpcomingOnly = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await deleteRecordingById(recording.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setCancelOpen(false);
|
||||
try {
|
||||
await fetchRecordings();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh recordings', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSeriesAndRule = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await deleteSeriesAndRule(seriesInfo);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setCancelOpen(false);
|
||||
try {
|
||||
await fetchRecordings();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to refresh recordings after series removal',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnMainCardClick = () => {
|
||||
if (isRecurringRule) {
|
||||
onOpenRecurring?.(recording, false);
|
||||
} else {
|
||||
onOpenDetails?.(recording);
|
||||
}
|
||||
}
|
||||
|
||||
const WatchLive = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const WatchRecording = () => {
|
||||
return <Tooltip
|
||||
label={
|
||||
customProps.file_url || customProps.output_file_url
|
||||
? 'Watch recording'
|
||||
: 'Recording playback not available yet'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWatchRecording();
|
||||
}}
|
||||
disabled={
|
||||
customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)
|
||||
}
|
||||
>
|
||||
Watch
|
||||
</Button>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
const MainCard = (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
color: '#fff',
|
||||
backgroundColor: isInterrupted ? '#2b1f20' : '#27272A',
|
||||
borderColor: isInterrupted ? '#a33' : undefined,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex justify="space-between" align="center" pb={8}>
|
||||
<Group gap={8} flex={1} miw={0}>
|
||||
<Badge
|
||||
color={
|
||||
isInterrupted
|
||||
? 'red.7'
|
||||
: isInProgress
|
||||
? 'red.6'
|
||||
: isUpcoming
|
||||
? 'yellow.6'
|
||||
: 'gray.6'
|
||||
}
|
||||
>
|
||||
{isInterrupted
|
||||
? 'Interrupted'
|
||||
: isInProgress
|
||||
? 'Recording'
|
||||
: isUpcoming
|
||||
? 'Scheduled'
|
||||
: 'Completed'}
|
||||
</Badge>
|
||||
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
|
||||
<Stack gap={2} flex={1} miw={0}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} lineClamp={1} title={recordingName}>
|
||||
{recordingName}
|
||||
</Text>
|
||||
{isSeriesGroup && (
|
||||
<Badge color="teal" variant="filled">
|
||||
Series
|
||||
</Badge>
|
||||
)}
|
||||
{isRecurringRule && (
|
||||
<Badge color="blue" variant="light">
|
||||
Recurring
|
||||
</Badge>
|
||||
)}
|
||||
{seLabel && !isSeriesGroup && (
|
||||
<Badge color="gray" variant="light">
|
||||
{seLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Center>
|
||||
<Tooltip label={isUpcoming || isInProgress ? 'Cancel' : 'Delete'}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleCancelClick}
|
||||
>
|
||||
<SquareX size="20" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={posterUrl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={6} flex={1}>
|
||||
{!isSeriesGroup && subTitle && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Episode
|
||||
</Text>
|
||||
<Text size="sm" fw={700} title={subTitle}>
|
||||
{subTitle}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Channel
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{channel ? `${channel.channel_number} • ${channel.name}` : '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isSeriesGroup ? 'Next recording' : 'Time'}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!isSeriesGroup && description && (
|
||||
<RecordingSynopsis
|
||||
description={description}
|
||||
onOpen={() => onOpenDetails?.(recording)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isInterrupted && customProps.interrupted_reason && (
|
||||
<Text size="xs" c="red.4">
|
||||
{customProps.interrupted_reason}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="xs" pt={4}>
|
||||
{isInProgress && <WatchLive />}
|
||||
|
||||
{!isUpcoming && <WatchRecording />}
|
||||
{!isUpcoming &&
|
||||
customProps?.status === 'completed' &&
|
||||
(!customProps?.comskip ||
|
||||
customProps?.comskip?.status !== 'completed') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={handleRunComskip}
|
||||
>
|
||||
Remove commercials
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Flex>
|
||||
{/* If this card is a grouped upcoming series, show count */}
|
||||
{recording._group_count > 1 && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ position: 'absolute', bottom: 6, right: 12 }}
|
||||
>
|
||||
Next of {recording._group_count}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
if (!isSeriesGroup) return MainCard;
|
||||
|
||||
// Stacked look for series groups: render two shadow layers behind the main card
|
||||
return (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
title="Cancel Series"
|
||||
centered
|
||||
size="md"
|
||||
zIndex={9999}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text>This is a series rule. What would you like to cancel?</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
loading={busy}
|
||||
onClick={removeUpcomingOnly}
|
||||
>
|
||||
Only this upcoming
|
||||
</Button>
|
||||
<Button color="red" loading={busy} onClick={removeSeriesAndRule}>
|
||||
Entire series + rule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: 'translate(10px, 10px) rotate(-1deg)',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#1f1f23',
|
||||
border: '1px solid #2f2f34',
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: 'translate(5px, 5px) rotate(1deg)',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#232327',
|
||||
border: '1px solid #333',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.30)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Box style={{ position: 'relative', zIndex: 2 }}>{MainCard}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingCard;
|
||||
362
frontend/src/components/forms/RecordingDetailsModal.jsx
Normal file
362
frontend/src/components/forms/RecordingDetailsModal.jsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
|
||||
import React from 'react';
|
||||
import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
getPosterUrl,
|
||||
getRecordingUrl,
|
||||
getSeasonLabel,
|
||||
getShowVideoUrl,
|
||||
runComSkip,
|
||||
} from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
getRating,
|
||||
getStatRows,
|
||||
getUpcomingEpisodes,
|
||||
} from '../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
|
||||
const RecordingDetailsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
recording,
|
||||
channel,
|
||||
posterUrl,
|
||||
onWatchLive,
|
||||
onWatchRecording,
|
||||
env_mode,
|
||||
onEdit,
|
||||
}) => {
|
||||
const allRecordings = useChannelsStore((s) => s.recordings);
|
||||
const channelMap = useChannelsStore((s) => s.channels);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [childOpen, setChildOpen] = React.useState(false);
|
||||
const [childRec, setChildRec] = React.useState(null);
|
||||
const [timeformat, dateformat] = useDateTimeFormat();
|
||||
|
||||
const safeRecording = recording || {};
|
||||
const customProps = safeRecording.custom_properties || {};
|
||||
const program = customProps.program || {};
|
||||
const recordingName = program.title || 'Custom Recording';
|
||||
const description = program.description || customProps.description || '';
|
||||
const start = toUserTime(safeRecording.start_time);
|
||||
const end = toUserTime(safeRecording.end_time);
|
||||
const stats = customProps.stream_info || {};
|
||||
|
||||
const statRows = getStatRows(stats);
|
||||
|
||||
// Rating (if available)
|
||||
const rating = getRating(customProps, program);
|
||||
const ratingSystem = customProps.rating_system || 'MPAA';
|
||||
|
||||
const fileUrl = customProps.file_url || customProps.output_file_url;
|
||||
const canWatchRecording =
|
||||
(customProps.status === 'completed' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
Boolean(fileUrl);
|
||||
|
||||
const isSeriesGroup = Boolean(
|
||||
safeRecording._group_count && safeRecording._group_count > 1
|
||||
);
|
||||
const upcomingEpisodes = React.useMemo(() => {
|
||||
return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
|
||||
}, [
|
||||
allRecordings,
|
||||
isSeriesGroup,
|
||||
program.tvg_id,
|
||||
program.title,
|
||||
toUserTime,
|
||||
userNow,
|
||||
]);
|
||||
|
||||
const handleOnWatchLive = () => {
|
||||
const rec = childRec;
|
||||
const now = userNow();
|
||||
const s = toUserTime(rec.start_time);
|
||||
const e = toUserTime(rec.end_time);
|
||||
|
||||
if (now.isAfter(s) && now.isBefore(e)) {
|
||||
if (!channelMap[rec.channel]) return;
|
||||
useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnWatchRecording = () => {
|
||||
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode)
|
||||
if (!fileUrl) return;
|
||||
|
||||
useVideoStore.getState().showVideo(fileUrl, 'vod', {
|
||||
name:
|
||||
childRec.custom_properties?.program?.title || 'Recording',
|
||||
logo: {
|
||||
url: getPosterUrl(
|
||||
childRec.custom_properties?.poster_logo_id,
|
||||
undefined,
|
||||
channelMap[childRec.channel]?.logo?.cache_url
|
||||
)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleRunComskip = async (e) => {
|
||||
e.stopPropagation?.();
|
||||
try {
|
||||
await runComSkip(recording)
|
||||
notifications.show({
|
||||
title: 'Removing commercials',
|
||||
message: 'Queued comskip for this recording',
|
||||
color: 'blue.5',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to run comskip', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
const EpisodeRow = ({ rec }) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const start = toUserTime(rec.start_time);
|
||||
const end = toUserTime(rec.end_time);
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, posterUrl);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await deleteRecordingById(rec.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh recordings after delete', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnMainCardClick = () => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={purl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title || recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
title={pr.sub_title || pr.title}
|
||||
>
|
||||
{pr.sub_title || pr.title}
|
||||
</Text>
|
||||
{se && (
|
||||
<Badge color="gray" variant="light">
|
||||
{se}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" color="red" variant="light" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchLive = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchLive();
|
||||
}}
|
||||
>
|
||||
Watch Live
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const WatchRecording = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onWatchRecording();
|
||||
}}
|
||||
disabled={!canWatchRecording}
|
||||
>
|
||||
Watch
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const Edit = () => {
|
||||
return <Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onEdit(recording);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>;
|
||||
}
|
||||
|
||||
const Series = () => {
|
||||
return <Stack gap={10}>
|
||||
{upcomingEpisodes.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No upcoming episodes found
|
||||
</Text>
|
||||
)}
|
||||
{upcomingEpisodes.map((ep) => (
|
||||
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
|
||||
))}
|
||||
{childOpen && childRec && (
|
||||
<RecordingDetailsModal
|
||||
opened={childOpen}
|
||||
onClose={() => setChildOpen(false)}
|
||||
recording={childRec}
|
||||
channel={channelMap[childRec.channel]}
|
||||
posterUrl={getPosterUrl(
|
||||
childRec.custom_properties?.poster_logo_id,
|
||||
childRec.custom_properties,
|
||||
channelMap[childRec.channel]?.logo?.cache_url
|
||||
)}
|
||||
env_mode={env_mode}
|
||||
onWatchLive={handleOnWatchLive}
|
||||
onWatchRecording={handleOnWatchRecording}
|
||||
/>
|
||||
)}
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
const Movie = () => {
|
||||
return <Flex gap="lg" align="flex-start">
|
||||
<Image
|
||||
src={posterUrl}
|
||||
w={180}
|
||||
h={240}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={recordingName}
|
||||
fallbackSrc="/logo.png"
|
||||
/>
|
||||
<Stack gap={8} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
{channel ? `${channel.channel_number} • ${channel.name}` : '—'}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{customProps.status === 'completed' &&
|
||||
(!customProps?.comskip ||
|
||||
customProps?.comskip?.status !== 'completed') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={handleRunComskip}
|
||||
>
|
||||
Remove commercials
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
|
||||
</Text>
|
||||
{rating && (
|
||||
<Group gap={8}>
|
||||
<Badge color="yellow" title={ratingSystem}>
|
||||
{rating}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{statRows.length > 0 && (
|
||||
<Stack gap={4} pt={6}>
|
||||
<Text fw={600} size="sm">
|
||||
Stream Stats
|
||||
</Text>
|
||||
{statRows.map(([k, v]) => (
|
||||
<Group key={k} justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{k}
|
||||
</Text>
|
||||
<Text size="xs">{v}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
isSeriesGroup
|
||||
? `Series: ${recordingName}`
|
||||
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
radius="md"
|
||||
zIndex={9999}
|
||||
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
|
||||
styles={{
|
||||
content: { backgroundColor: '#18181B', color: 'white' },
|
||||
header: { backgroundColor: '#18181B', color: 'white' },
|
||||
title: { color: 'white' },
|
||||
}}
|
||||
>
|
||||
{isSeriesGroup ? <Series /> : <Movie />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingDetailsModal;
|
||||
381
frontend/src/components/forms/RecurringRuleModal.jsx
Normal file
381
frontend/src/components/forms/RecurringRuleModal.jsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import {
|
||||
parseDate,
|
||||
RECURRING_DAY_OPTIONS,
|
||||
toTimeString,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import dayjs from 'dayjs';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Badge, Button, Card, Group, Modal, MultiSelect, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
|
||||
import { DatePickerInput, TimeInput } from '@mantine/dates';
|
||||
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
deleteRecurringRuleById,
|
||||
getChannelOptions,
|
||||
getUpcomingOccurrences,
|
||||
updateRecurringRule,
|
||||
updateRecurringRuleEnabled,
|
||||
} from '../../utils/forms/RecurringRuleModalUtils.js';
|
||||
|
||||
const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const recurringRules = useChannelsStore((s) => s.recurringRules);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [timeformat, dateformat] = useDateTimeFormat();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [busyOccurrence, setBusyOccurrence] = useState(null);
|
||||
|
||||
const rule = recurringRules.find((r) => r.id === ruleId);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
return getChannelOptions(channels);
|
||||
}, [channels]);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: '',
|
||||
days_of_week: [],
|
||||
rule_name: '',
|
||||
start_time: dayjs().startOf('hour').format('HH:mm'),
|
||||
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
|
||||
start_date: dayjs().toDate(),
|
||||
end_date: dayjs().toDate(),
|
||||
enabled: true,
|
||||
},
|
||||
validate: {
|
||||
channel_id: (value) => (value ? null : 'Select a channel'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const startValue = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (
|
||||
startValue.isValid() &&
|
||||
endValue.isValid() &&
|
||||
endValue.diff(startValue, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const endDate = dayjs(value);
|
||||
const startDate = dayjs(values.start_date);
|
||||
if (!value) return 'Select an end date';
|
||||
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
|
||||
return 'End date cannot be before start date';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && rule) {
|
||||
form.setValues({
|
||||
channel_id: `${rule.channel}`,
|
||||
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
|
||||
rule_name: rule.name || '',
|
||||
start_time: toTimeString(rule.start_time),
|
||||
end_time: toTimeString(rule.end_time),
|
||||
start_date: parseDate(rule.start_date) || dayjs().toDate(),
|
||||
end_date: parseDate(rule.end_date),
|
||||
enabled: Boolean(rule.enabled),
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, ruleId, rule]);
|
||||
|
||||
const upcomingOccurrences = useMemo(() => {
|
||||
return getUpcomingOccurrences(recordings, userNow, ruleId, toUserTime);
|
||||
}, [recordings, ruleId, toUserTime, userNow]);
|
||||
|
||||
const handleSave = async (values) => {
|
||||
if (!rule) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateRecurringRule(ruleId, values);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: 'Recurring rule updated',
|
||||
message: 'Schedule adjustments saved',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update recurring rule', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rule) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecurringRuleById(ruleId);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: 'Recurring rule removed',
|
||||
message: 'All future occurrences were cancelled',
|
||||
color: 'red',
|
||||
autoClose: 2500,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete recurring rule', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (checked) => {
|
||||
if (!rule) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateRecurringRuleEnabled(ruleId, checked);
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
|
||||
message: checked
|
||||
? 'Future occurrences will resume'
|
||||
: 'Upcoming occurrences were removed',
|
||||
color: checked ? 'green' : 'yellow',
|
||||
autoClose: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle recurring rule', error);
|
||||
form.setFieldValue('enabled', !checked);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelOccurrence = async (occurrence) => {
|
||||
setBusyOccurrence(occurrence.id);
|
||||
try {
|
||||
await deleteRecordingById(occurrence.id);
|
||||
await fetchRecordings();
|
||||
notifications.show({
|
||||
title: 'Occurrence cancelled',
|
||||
message: 'The selected airing was removed',
|
||||
color: 'yellow',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel occurrence', error);
|
||||
} finally {
|
||||
setBusyOccurrence(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!rule) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Recurring Rule" centered>
|
||||
<Text size="sm">Recurring rule not found.</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const handleEnableChange = (event) => {
|
||||
form.setFieldValue('enabled', event.currentTarget.checked);
|
||||
handleToggleEnabled(event.currentTarget.checked);
|
||||
}
|
||||
|
||||
const handleStartDateChange = (value) => {
|
||||
form.setFieldValue('start_date', value || dayjs().toDate());
|
||||
}
|
||||
|
||||
const handleEndDateChange = (value) => {
|
||||
form.setFieldValue('end_date', value);
|
||||
}
|
||||
|
||||
const handleStartTimeChange = (value) => {
|
||||
form.setFieldValue('start_time', toTimeString(value));
|
||||
}
|
||||
|
||||
const handleEndTimeChange = (value) => {
|
||||
form.setFieldValue('end_time', toTimeString(value));
|
||||
}
|
||||
|
||||
const UpcomingList = () => {
|
||||
return <Stack gap="xs">
|
||||
{upcomingOccurrences.map((occ) => {
|
||||
const occStart = toUserTime(occ.start_time);
|
||||
const occEnd = toUserTime(occ.end_time);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`occ-${occ.id}`}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} flex={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{occStart.format(`${dateformat}, YYYY`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{occStart.format(timeformat)} – {occEnd.format(timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onEditOccurrence?.(occ);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
loading={busyOccurrence === occ.id}
|
||||
onClick={() => handleCancelOccurrence(occ)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={rule.name || 'Recurring Rule'}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>
|
||||
{channels?.[rule.channel]?.name || `Channel ${rule.channel}`}
|
||||
</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={form.values.enabled}
|
||||
onChange={handleEnableChange}
|
||||
label={form.values.enabled ? 'Enabled' : 'Paused'}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Group>
|
||||
<form onSubmit={form.onSubmit(handleSave)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
{...form.getInputProps('channel_id')}
|
||||
label="Channel"
|
||||
data={channelOptions}
|
||||
searchable
|
||||
/>
|
||||
<TextInput
|
||||
{...form.getInputProps('rule_name')}
|
||||
label="Rule name"
|
||||
placeholder="Morning News, Football Sundays, ..."
|
||||
/>
|
||||
<MultiSelect
|
||||
{...form.getInputProps('days_of_week')}
|
||||
label="Every"
|
||||
data={RECURRING_DAY_OPTIONS.map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group grow>
|
||||
<DatePickerInput
|
||||
label="Start date"
|
||||
value={form.values.start_date}
|
||||
onChange={handleStartDateChange}
|
||||
valueFormat="MMM D, YYYY"
|
||||
/>
|
||||
<DatePickerInput
|
||||
label="End date"
|
||||
value={form.values.end_date}
|
||||
onChange={handleEndDateChange}
|
||||
valueFormat="MMM D, YYYY"
|
||||
minDate={form.values.start_date || undefined}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TimeInput
|
||||
label="Start time"
|
||||
value={form.values.start_time}
|
||||
onChange={handleStartTimeChange}
|
||||
withSeconds={false}
|
||||
format="12"
|
||||
amLabel="AM"
|
||||
pmLabel="PM"
|
||||
/>
|
||||
<TimeInput
|
||||
label="End time"
|
||||
value={form.values.end_time}
|
||||
onChange={handleEndTimeChange}
|
||||
withSeconds={false}
|
||||
format="12"
|
||||
amLabel="AM"
|
||||
pmLabel="PM"
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Button type="submit" loading={saving}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
loading={deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete rule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
Upcoming occurrences
|
||||
</Text>
|
||||
<Badge color="blue.6">{upcomingOccurrences.length}</Badge>
|
||||
</Group>
|
||||
{upcomingOccurrences.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No future airings currently scheduled.
|
||||
</Text>
|
||||
) : <UpcomingList />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurringRuleModal;
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useLogosStore from '../../store/logos';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
|
|
@ -219,7 +224,7 @@ const ChannelRowActions = React.memo(
|
|||
}
|
||||
);
|
||||
|
||||
const ChannelsTable = ({}) => {
|
||||
const ChannelsTable = ({ onReady }) => {
|
||||
// EPG data lookup
|
||||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
|
|
@ -229,6 +234,7 @@ const ChannelsTable = ({}) => {
|
|||
const canDeleteChannelGroup = useChannelsStore(
|
||||
(s) => s.canDeleteChannelGroup
|
||||
);
|
||||
const hasSignaledReady = useRef(false);
|
||||
|
||||
/**
|
||||
* STORES
|
||||
|
|
@ -254,7 +260,6 @@ const ChannelsTable = ({}) => {
|
|||
const channels = useChannelsStore((s) => s.channels);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
|
||||
pageSize: 50,
|
||||
});
|
||||
|
|
@ -310,6 +315,8 @@ const ChannelsTable = ({}) => {
|
|||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
const [channelToDelete, setChannelToDelete] = useState(null);
|
||||
|
||||
const hasFetchedData = useRef(false);
|
||||
|
||||
// Column sizing state for resizable columns
|
||||
// Store in localStorage but with empty object as default
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage(
|
||||
|
|
@ -364,10 +371,17 @@ const ChannelsTable = ({}) => {
|
|||
});
|
||||
});
|
||||
|
||||
const channelsTableLength =
|
||||
Object.keys(data).length > 0 || hasFetchedData.current
|
||||
? Object.keys(data).length
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Functions
|
||||
*/
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
|
@ -409,14 +423,25 @@ const ChannelsTable = ({}) => {
|
|||
await API.getAllChannelIds(params),
|
||||
]);
|
||||
|
||||
setIsLoading(false);
|
||||
hasFetchedData.current = true;
|
||||
|
||||
setTablePrefs({
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setAllRowIds(ids);
|
||||
|
||||
// Signal ready after first successful data fetch
|
||||
// EPG data is already loaded in initData before this component mounts
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
}, [
|
||||
pagination,
|
||||
sorting,
|
||||
debouncedFilters,
|
||||
onReady,
|
||||
showDisabled,
|
||||
selectedProfileId,
|
||||
showOnlyStreamlessChannels,
|
||||
|
|
@ -907,8 +932,10 @@ const ChannelsTable = ({}) => {
|
|||
// 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.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedProfileId, channelGroups, logos, theme]
|
||||
[selectedProfileId, channelGroups, theme]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
@ -1353,12 +1380,12 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
{Object.keys(channels).length === 0 && (
|
||||
{channelsTableLength === 0 && (
|
||||
<ChannelsTableOnboarding editChannel={editChannel} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{Object.keys(channels).length > 0 && (
|
||||
{channelsTableLength > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import API from '../../api';
|
||||
import StreamForm from '../forms/Stream';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
|
@ -167,8 +173,9 @@ const StreamRowActions = ({
|
|||
);
|
||||
};
|
||||
|
||||
const StreamsTable = () => {
|
||||
const StreamsTable = ({ onReady }) => {
|
||||
const theme = useMantineTheme();
|
||||
const hasSignaledReady = useRef(false);
|
||||
|
||||
/**
|
||||
* useState
|
||||
|
|
@ -430,6 +437,12 @@ const StreamsTable = () => {
|
|||
|
||||
// Generate the string
|
||||
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
|
||||
|
||||
// Signal that initial data load is complete
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
|
@ -442,6 +455,7 @@ const StreamsTable = () => {
|
|||
groupsLoaded,
|
||||
channelGroups,
|
||||
fetchChannelGroups,
|
||||
onReady,
|
||||
]);
|
||||
|
||||
// Bulk creation: create channels from selected streams asynchronously
|
||||
|
|
|
|||
|
|
@ -1,19 +1,59 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Box } from '@mantine/core';
|
||||
import { Allotment } from 'allotment';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import useAuthStore from '../store/auth';
|
||||
import useLogosStore from '../store/logos';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const PageContent = () => {
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const fetchChannelAssignableLogos = useLogosStore(
|
||||
(s) => s.fetchChannelAssignableLogos
|
||||
);
|
||||
const enableLogoRendering = useLogosStore((s) => s.enableLogoRendering);
|
||||
|
||||
const channelsReady = useRef(false);
|
||||
const streamsReady = useRef(false);
|
||||
const logosTriggered = useRef(false);
|
||||
|
||||
const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
|
||||
'channels-splitter-sizes',
|
||||
[50, 50]
|
||||
);
|
||||
|
||||
// Only load logos when BOTH tables are ready
|
||||
const tryLoadLogos = useCallback(() => {
|
||||
if (
|
||||
channelsReady.current &&
|
||||
streamsReady.current &&
|
||||
!logosTriggered.current
|
||||
) {
|
||||
logosTriggered.current = true;
|
||||
// Use requestAnimationFrame to defer logo loading until after browser paint
|
||||
// This ensures EPG column is fully rendered before logos start loading
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
enableLogoRendering();
|
||||
fetchChannelAssignableLogos();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [fetchChannelAssignableLogos, enableLogoRendering]);
|
||||
|
||||
const handleChannelsReady = useCallback(() => {
|
||||
channelsReady.current = true;
|
||||
tryLoadLogos();
|
||||
}, [tryLoadLogos]);
|
||||
|
||||
const handleStreamsReady = useCallback(() => {
|
||||
streamsReady.current = true;
|
||||
tryLoadLogos();
|
||||
}, [tryLoadLogos]);
|
||||
|
||||
const handleSplitChange = (sizes) => {
|
||||
setAllotmentSizes(sizes);
|
||||
};
|
||||
|
|
@ -22,46 +62,48 @@ const ChannelsPage = () => {
|
|||
setAllotmentSizes(sizes);
|
||||
};
|
||||
|
||||
if (!authUser.id) {
|
||||
return <></>;
|
||||
}
|
||||
if (!authUser.id) return <></>;
|
||||
|
||||
if (authUser.user_level <= USER_LEVELS.STANDARD) {
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<ChannelsTable />
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box h={'100vh'} w={'100%'} display={'flex'} style={{ overflowX: 'auto' }}>
|
||||
<Allotment
|
||||
defaultSizes={allotmentSizes}
|
||||
style={{ height: '100%', width: '100%', minWidth: '600px' }}
|
||||
h={'100%'}
|
||||
w={'100%'}
|
||||
miw={'600px'}
|
||||
className="custom-allotment"
|
||||
minSize={100}
|
||||
onChange={handleSplitChange}
|
||||
onResize={handleResize}
|
||||
>
|
||||
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
|
||||
<div style={{ minWidth: '600px' }}>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
|
||||
<div style={{ minWidth: '600px' }}>
|
||||
<StreamsTable />
|
||||
</div>
|
||||
</div>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<StreamsTable onReady={handleStreamsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Allotment>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ import useUserAgentsStore from '../store/userAgents';
|
|||
import M3UsTable from '../components/tables/M3UsTable';
|
||||
import EPGsTable from '../components/tables/EPGsTable';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import ErrorBoundary from '../components/ErrorBoundary'
|
||||
|
||||
const M3UPage = () => {
|
||||
const PageContent = () => {
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
if (error) throw new Error(error);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
p="10"
|
||||
h="100%" // Set a specific height to ensure proper display
|
||||
miw="1100px" // Prevent tables from becoming too cramped
|
||||
style={{
|
||||
padding: 10,
|
||||
height: '100%', // Set a specific height to ensure proper display
|
||||
minWidth: '1100px', // Prevent tables from becoming too cramped
|
||||
overflowX: 'auto', // Enable horizontal scrolling when needed
|
||||
overflowY: 'auto', // Enable vertical scrolling on the container
|
||||
}}
|
||||
|
|
@ -26,6 +28,14 @@ const M3UPage = () => {
|
|||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const M3UPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default M3UPage;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
|||
// src/components/Dashboard.js
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [newStream, setNewStream] = useState('');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard Page</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={newStream}
|
||||
onChange={(e) => setNewStream(e.target.value)}
|
||||
placeholder="Enter Stream"
|
||||
/>
|
||||
|
||||
<h3>Streams:</h3>
|
||||
<ul>
|
||||
{state.streams.map((stream, index) => (
|
||||
<li key={index}>{stream}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// src/components/Home.js
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Home = () => {
|
||||
const [newChannel, setNewChannel] = useState('');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Home Page</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import React from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import LoginForm from '../components/forms/LoginForm';
|
||||
import SuperuserForm from '../components/forms/SuperuserForm';
|
||||
const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm'));
|
||||
import useAuthStore from '../store/auth';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
const Login = ({}) => {
|
||||
const superuserExists = useAuthStore((s) => s.superuserExists);
|
||||
|
||||
if (!superuserExists) {
|
||||
return <SuperuserForm />;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Text>Loading...</Text>}>
|
||||
<SuperuserForm />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoginForm />;
|
||||
|
|
|
|||
|
|
@ -1,55 +1,25 @@
|
|||
import React, { useState } from 'react';
|
||||
import UsersTable from '../components/tables/UsersTable';
|
||||
import { Box } from '@mantine/core';
|
||||
import useAuthStore from '../store/auth';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
const UsersPage = () => {
|
||||
const PageContent = () => {
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [userToDelete, setUserToDelete] = useState(null);
|
||||
|
||||
if (!authUser.id) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const closeUserModal = () => {
|
||||
setSelectedUser(null);
|
||||
setUserModalOpen(false);
|
||||
};
|
||||
const editUser = (user) => {
|
||||
setSelectedUser(user);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUser = (id) => {
|
||||
// Get user details for the confirmation dialog
|
||||
const user = users.find((u) => u.id === id);
|
||||
setUserToDelete(user);
|
||||
setDeleteTarget(id);
|
||||
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('delete-user')) {
|
||||
return executeDeleteUser(id);
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const executeDeleteUser = async (id) => {
|
||||
await API.deleteUser(id);
|
||||
setConfirmDeleteOpen(false);
|
||||
};
|
||||
if (!authUser.id) throw new Error();
|
||||
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<Box p={10}>
|
||||
<UsersTable />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import useEPGsStore from './epgs';
|
|||
import useStreamProfilesStore from './streamProfiles';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
import useUsersStore from './users';
|
||||
import useLogosStore from './logos';
|
||||
import API from '../api';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
|
||||
|
|
@ -43,6 +42,8 @@ const useAuthStore = create((set, get) => ({
|
|||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
set({ user, isAuthenticated: true });
|
||||
|
||||
// Ensure settings are loaded first
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ const useAuthStore = create((set, get) => ({
|
|||
await Promise.all([useUsersStore.getState().fetchUsers()]);
|
||||
}
|
||||
|
||||
set({ user, isAuthenticated: true });
|
||||
// Note: Logos are loaded after the Channels page tables finish loading
|
||||
// This is handled by the tables themselves signaling completion
|
||||
} catch (error) {
|
||||
console.error('Error initializing data:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,10 @@ const useLogosStore = create((set, get) => ({
|
|||
hasLoadedAll: false, // Track if we've loaded all logos
|
||||
hasLoadedChannelLogos: false, // Track if we've loaded channel logos
|
||||
error: null,
|
||||
allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
|
||||
|
||||
// Basic CRUD operations
|
||||
setLogos: (logos) => {
|
||||
set({
|
||||
logos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
});
|
||||
},
|
||||
// Enable logo rendering (call this after tables have loaded and painted)
|
||||
enableLogoRendering: () => set({ allowLogoRendering: true }),
|
||||
|
||||
addLogo: (newLogo) =>
|
||||
set((state) => {
|
||||
|
|
@ -73,6 +67,9 @@ const useLogosStore = create((set, get) => ({
|
|||
|
||||
// Smart loading methods
|
||||
fetchLogos: async (pageSize = 100) => {
|
||||
// Don't fetch if logo fetching is not allowed yet
|
||||
if (!get().allowLogoFetching) return [];
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.getLogos({ page_size: pageSize });
|
||||
|
|
@ -163,59 +160,28 @@ const useLogosStore = create((set, get) => ({
|
|||
},
|
||||
|
||||
fetchChannelAssignableLogos: async () => {
|
||||
const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get();
|
||||
const { hasLoadedChannelLogos, channelLogos } = get();
|
||||
|
||||
// Prevent concurrent calls
|
||||
if (
|
||||
backgroundLoading ||
|
||||
(hasLoadedChannelLogos && Object.keys(channelLogos).length > 0)
|
||||
) {
|
||||
// Return cached if already loaded
|
||||
if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
|
||||
return Object.values(channelLogos);
|
||||
}
|
||||
|
||||
set({ backgroundLoading: true, error: null });
|
||||
try {
|
||||
// Load all channel logos (no special filtering needed - all Logo entries are for channels)
|
||||
const response = await api.getLogos({
|
||||
no_pagination: 'true', // Get all channel logos
|
||||
});
|
||||
// Fetch all logos and cache them as channel logos
|
||||
const logos = await get().fetchAllLogos();
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logos = Array.isArray(response) ? response : response.results || [];
|
||||
set({
|
||||
channelLogos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
hasLoadedChannelLogos: true,
|
||||
});
|
||||
|
||||
console.log(`Fetched ${logos.length} channel logos`);
|
||||
|
||||
// Store in both places, but this is intentional and only when specifically requested
|
||||
set({
|
||||
logos: {
|
||||
...get().logos, // Keep existing logos
|
||||
...logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
channelLogos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
hasLoadedChannelLogos: true,
|
||||
backgroundLoading: false,
|
||||
});
|
||||
|
||||
return logos;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel logos:', error);
|
||||
set({
|
||||
error: 'Failed to load channel logos.',
|
||||
backgroundLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return logos;
|
||||
},
|
||||
|
||||
fetchLogosByIds: async (logoIds) => {
|
||||
if (!logoIds || logoIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
// Filter out logos we already have
|
||||
const missingIds = logoIds.filter((id) => !get().logos[id]);
|
||||
|
|
|
|||
92
frontend/src/utils/cards/RecordingCardUtils.js
Normal file
92
frontend/src/utils/cards/RecordingCardUtils.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import API from '../../api.js';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
|
||||
export const removeRecording = (id) => {
|
||||
// Optimistically remove immediately from UI
|
||||
try {
|
||||
useChannelsStore.getState().removeRecording(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to optimistically remove recording', error);
|
||||
}
|
||||
// Fire-and-forget server delete; websocket will keep others in sync
|
||||
API.deleteRecording(id).catch(() => {
|
||||
// On failure, fallback to refetch to restore state
|
||||
try {
|
||||
useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh recordings after delete', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getPosterUrl = (posterLogoId, customProperties, posterUrl) => {
|
||||
let purl = posterLogoId
|
||||
? `/api/channels/logos/${posterLogoId}/cache/`
|
||||
: customProperties?.poster_url || posterUrl || '/logo.png';
|
||||
if (
|
||||
typeof import.meta !== 'undefined' &&
|
||||
import.meta.env &&
|
||||
import.meta.env.DEV &&
|
||||
purl &&
|
||||
purl.startsWith('/')
|
||||
) {
|
||||
purl = `${window.location.protocol}//${window.location.hostname}:5656${purl}`;
|
||||
}
|
||||
return purl;
|
||||
};
|
||||
|
||||
export const getShowVideoUrl = (channel, env_mode) => {
|
||||
let url = `/proxy/ts/stream/${channel.uuid}`;
|
||||
if (env_mode === 'dev') {
|
||||
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const runComSkip = async (recording) => {
|
||||
await API.runComskip(recording.id);
|
||||
};
|
||||
|
||||
export const deleteRecordingById = async (recordingId) => {
|
||||
await API.deleteRecording(recordingId);
|
||||
};
|
||||
|
||||
export const deleteSeriesAndRule = async (seriesInfo) => {
|
||||
const { tvg_id, title } = seriesInfo;
|
||||
if (tvg_id) {
|
||||
try {
|
||||
await API.bulkRemoveSeriesRecordings({
|
||||
tvg_id,
|
||||
title,
|
||||
scope: 'title',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to remove series recordings', error);
|
||||
}
|
||||
try {
|
||||
await API.deleteSeriesRule(tvg_id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete series rule', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getRecordingUrl = (customProps, env_mode) => {
|
||||
let fileUrl = customProps?.file_url || customProps?.output_file_url;
|
||||
if (fileUrl && env_mode === 'dev' && fileUrl.startsWith('/')) {
|
||||
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
|
||||
}
|
||||
return fileUrl;
|
||||
};
|
||||
|
||||
export const getSeasonLabel = (season, episode, onscreen) => {
|
||||
return season && episode
|
||||
? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`
|
||||
: onscreen || null;
|
||||
};
|
||||
|
||||
export const getSeriesInfo = (customProps) => {
|
||||
const cp = customProps || {};
|
||||
const pr = cp.program || {};
|
||||
return { tvg_id: pr.tvg_id, title: pr.title };
|
||||
};
|
||||
89
frontend/src/utils/dateTimeUtils.js
Normal file
89
frontend/src/utils/dateTimeUtils.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const useUserTimeZone = () => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const [timeZone, setTimeZone] = useLocalStorage(
|
||||
'time-zone',
|
||||
dayjs.tz?.guess
|
||||
? dayjs.tz.guess()
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const tz = settings?.['system-time-zone']?.value;
|
||||
if (tz && tz !== timeZone) {
|
||||
setTimeZone(tz);
|
||||
}
|
||||
}, [settings, timeZone, setTimeZone]);
|
||||
|
||||
return timeZone;
|
||||
};
|
||||
|
||||
export const useTimeHelpers = () => {
|
||||
const timeZone = useUserTimeZone();
|
||||
|
||||
const toUserTime = useCallback(
|
||||
(value) => {
|
||||
if (!value) return dayjs.invalid();
|
||||
try {
|
||||
return dayjs(value).tz(timeZone);
|
||||
} catch (error) {
|
||||
return dayjs(value);
|
||||
}
|
||||
},
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]);
|
||||
|
||||
return { timeZone, toUserTime, userNow };
|
||||
};
|
||||
|
||||
export const RECURRING_DAY_OPTIONS = [
|
||||
{ value: 6, label: 'Sun' },
|
||||
{ value: 0, label: 'Mon' },
|
||||
{ value: 1, label: 'Tue' },
|
||||
{ value: 2, label: 'Wed' },
|
||||
{ value: 3, label: 'Thu' },
|
||||
{ value: 4, label: 'Fri' },
|
||||
{ value: 5, label: 'Sat' },
|
||||
];
|
||||
|
||||
export const useDateTimeFormat = () => {
|
||||
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
|
||||
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
||||
// Use user preference for time format
|
||||
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
|
||||
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
|
||||
|
||||
return [timeFormat, dateFormat]
|
||||
};
|
||||
|
||||
export const toTimeString = (value) => {
|
||||
if (!value) return '00:00';
|
||||
if (typeof value === 'string') {
|
||||
const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true);
|
||||
if (parsed.isValid()) return parsed.format('HH:mm');
|
||||
return value;
|
||||
}
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
|
||||
};
|
||||
|
||||
export const parseDate = (value) => {
|
||||
if (!value) return null;
|
||||
const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
|
||||
return parsed.isValid() ? parsed.toDate() : null;
|
||||
};
|
||||
87
frontend/src/utils/forms/RecordingDetailsModalUtils.js
Normal file
87
frontend/src/utils/forms/RecordingDetailsModalUtils.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
export const getStatRows = (stats) => {
|
||||
return [
|
||||
['Video Codec', stats.video_codec],
|
||||
[
|
||||
'Resolution',
|
||||
stats.resolution ||
|
||||
(stats.width && stats.height ? `${stats.width}x${stats.height}` : null),
|
||||
],
|
||||
['FPS', stats.source_fps],
|
||||
['Video Bitrate', stats.video_bitrate && `${stats.video_bitrate} kb/s`],
|
||||
['Audio Codec', stats.audio_codec],
|
||||
['Audio Channels', stats.audio_channels],
|
||||
['Sample Rate', stats.sample_rate && `${stats.sample_rate} Hz`],
|
||||
['Audio Bitrate', stats.audio_bitrate && `${stats.audio_bitrate} kb/s`],
|
||||
].filter(([, v]) => v !== null && v !== undefined && v !== '');
|
||||
};
|
||||
|
||||
export const getRating = (customProps, program) => {
|
||||
return (
|
||||
customProps.rating ||
|
||||
customProps.rating_value ||
|
||||
(program && program.custom_properties && program.custom_properties.rating)
|
||||
);
|
||||
};
|
||||
|
||||
const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
|
||||
return arr.filter((r) => {
|
||||
const cp = r.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
|
||||
if ((pr.tvg_id || '') !== tvid) return false;
|
||||
if ((pr.title || '').toLowerCase() !== titleKey) return false;
|
||||
const st = toUserTime(r.start_time);
|
||||
return st.isAfter(userNow());
|
||||
});
|
||||
}
|
||||
|
||||
const dedupeByProgram = (filtered) => {
|
||||
// Deduplicate by program.id if present, else by time+title
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
|
||||
for (const r of filtered) {
|
||||
const cp = r.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
// Prefer season/episode or onscreen code; else fall back to sub_title; else program id/slot
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
|
||||
let key = null;
|
||||
if (season != null && episode != null) key = `se:${season}:${episode}`;
|
||||
else if (onscreen) key = `onscreen:${String(onscreen).toLowerCase()}`;
|
||||
else if (pr.sub_title) key = `sub:${(pr.sub_title || '').toLowerCase()}`;
|
||||
else if (pr.id != null) key = `id:${pr.id}`;
|
||||
else
|
||||
key = `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
|
||||
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(r);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export const getUpcomingEpisodes = (
|
||||
isSeriesGroup,
|
||||
allRecordings,
|
||||
program,
|
||||
toUserTime,
|
||||
userNow
|
||||
) => {
|
||||
if (!isSeriesGroup) return [];
|
||||
|
||||
const arr = Array.isArray(allRecordings)
|
||||
? allRecordings
|
||||
: Object.values(allRecordings || {});
|
||||
const tvid = program.tvg_id || '';
|
||||
const titleKey = (program.title || '').toLowerCase();
|
||||
|
||||
const filtered = filterByUpcoming(arr, tvid, titleKey, toUserTime, userNow);
|
||||
|
||||
return dedupeByProgram(filtered).sort(
|
||||
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
|
||||
);
|
||||
};
|
||||
66
frontend/src/utils/forms/RecurringRuleModalUtils.js
Normal file
66
frontend/src/utils/forms/RecurringRuleModalUtils.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import API from '../../api.js';
|
||||
import { toTimeString } from '../dateTimeUtils.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const getChannelOptions = (channels) => {
|
||||
return Object.values(channels || {})
|
||||
.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum === bNum) {
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
}
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUpcomingOccurrences = (
|
||||
recordings,
|
||||
userNow,
|
||||
ruleId,
|
||||
toUserTime
|
||||
) => {
|
||||
const list = Array.isArray(recordings)
|
||||
? recordings
|
||||
: Object.values(recordings || {});
|
||||
const now = userNow();
|
||||
return list
|
||||
.filter(
|
||||
(rec) =>
|
||||
rec?.custom_properties?.rule?.id === ruleId &&
|
||||
toUserTime(rec.start_time).isAfter(now)
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
toUserTime(a.start_time).valueOf() - toUserTime(b.start_time).valueOf()
|
||||
);
|
||||
};
|
||||
|
||||
export const updateRecurringRule = async (ruleId, values) => {
|
||||
await API.updateRecurringRule(ruleId, {
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
start_date: values.start_date
|
||||
? dayjs(values.start_date).format('YYYY-MM-DD')
|
||||
: null,
|
||||
end_date: values.end_date
|
||||
? dayjs(values.end_date).format('YYYY-MM-DD')
|
||||
: null,
|
||||
name: values.rule_name?.trim() || '',
|
||||
enabled: Boolean(values.enabled),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteRecurringRuleById = async (ruleId) => {
|
||||
await API.deleteRecurringRule(ruleId);
|
||||
};
|
||||
|
||||
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
|
||||
await API.updateRecurringRule(ruleId, { enabled: checked });
|
||||
};
|
||||
90
frontend/src/utils/pages/DVRUtils.js
Normal file
90
frontend/src/utils/pages/DVRUtils.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Deduplicate in-progress and upcoming by program id or channel+slot
|
||||
const dedupeByProgramOrSlot = (arr) => {
|
||||
const out = [];
|
||||
const sigs = new Set();
|
||||
|
||||
for (const r of arr) {
|
||||
const cp = r.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const sig =
|
||||
pr?.id != null
|
||||
? `id:${pr.id}`
|
||||
: `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`;
|
||||
|
||||
if (sigs.has(sig)) continue;
|
||||
sigs.add(sig);
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
|
||||
// ID-based dedupe guard in case store returns duplicates
|
||||
const seenIds = new Set();
|
||||
for (const rec of list) {
|
||||
if (rec && rec.id != null) {
|
||||
const k = String(rec.id);
|
||||
if (seenIds.has(k)) continue;
|
||||
seenIds.add(k);
|
||||
}
|
||||
|
||||
const s = toUserTime(rec.start_time);
|
||||
const e = toUserTime(rec.end_time);
|
||||
const status = rec.custom_properties?.status;
|
||||
|
||||
if (status === 'interrupted' || status === 'completed') {
|
||||
completed.push(rec);
|
||||
} else {
|
||||
if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec);
|
||||
else if (now.isBefore(s)) upcoming.push(rec);
|
||||
else completed.push(rec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const categorizeRecordings = (recordings, toUserTime, now) => {
|
||||
const inProgress = [];
|
||||
const upcoming = [];
|
||||
const completed = [];
|
||||
const list = Array.isArray(recordings)
|
||||
? recordings
|
||||
: Object.values(recordings || {});
|
||||
|
||||
dedupeById(list, toUserTime, completed, now, inProgress, upcoming);
|
||||
|
||||
const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort(
|
||||
(a, b) => toUserTime(b.start_time) - toUserTime(a.start_time)
|
||||
);
|
||||
|
||||
// Group upcoming by series title+tvg_id (keep only next episode)
|
||||
const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort(
|
||||
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
|
||||
);
|
||||
const grouped = new Map();
|
||||
|
||||
for (const rec of upcomingDedup) {
|
||||
const cp = rec.custom_properties || {};
|
||||
const prog = cp.program || {};
|
||||
const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { rec, count: 1 });
|
||||
} else {
|
||||
const entry = grouped.get(key);
|
||||
entry.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const upcomingGrouped = Array.from(grouped.values()).map((e) => {
|
||||
const item = { ...e.rec };
|
||||
item._group_count = e.count;
|
||||
return item;
|
||||
});
|
||||
|
||||
completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time));
|
||||
|
||||
return {
|
||||
inProgress: inProgressDedup,
|
||||
upcoming: upcomingGrouped,
|
||||
completed,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue