Merge pull request #761 from nick4810/enhancement/component-cleanup

Enhancement/component cleanup
This commit is contained in:
SergeantPanda 2025-12-26 16:08:49 -06:00 committed by GitHub
commit af88756197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1924 additions and 1717 deletions

View file

@ -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

View 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;

View file

@ -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}

View 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;

View 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;

View 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;

View 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;

View file

@ -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',

View file

@ -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

View file

@ -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>
);
};

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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 />;

View file

@ -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;

View file

@ -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);
}

View file

@ -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]);

View 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 };
};

View 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;
};

View 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)
);
};

View 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 });
};

View 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,
};
}