mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
basic DVR UI, custom properties for recordings
This commit is contained in:
parent
e507c6f23c
commit
e2850441ae
8 changed files with 235 additions and 11 deletions
|
|
@ -410,6 +410,7 @@ class Recording(models.Model):
|
|||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
task_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.channel.name} - {self.start_time} to {self.end_time}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import M3U from './pages/M3U';
|
|||
import EPG from './pages/EPG';
|
||||
import Guide from './pages/Guide';
|
||||
import Stats from './pages/Stats';
|
||||
import DVR from './pages/DVR';
|
||||
import Settings from './pages/Settings';
|
||||
import StreamProfiles from './pages/StreamProfiles';
|
||||
import useAuthStore from './store/auth';
|
||||
|
|
@ -127,6 +128,7 @@ const App = () => {
|
|||
element={<StreamProfiles />}
|
||||
/>
|
||||
<Route path="/guide" element={<Guide />} />
|
||||
<Route path="/dvr" element={<DVR />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1035,6 +1035,19 @@ export default class API {
|
|||
.updateProfileChannels(channelIds, profileId, enabled);
|
||||
}
|
||||
|
||||
static async getRecordings() {
|
||||
const response = await fetch(`${host}/api/channels/recordings/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async createRecording(values) {
|
||||
const response = await fetch(`${host}/api/channels/recordings/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -1046,7 +1059,20 @@ export default class API {
|
|||
});
|
||||
|
||||
const retval = await response.json();
|
||||
useChannelsStore.getState().fetchRecordings();
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteRecording(id) {
|
||||
const response = await fetch(`${host}/api/channels/recordings/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useChannelsStore.getState().fetchRecordings();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Settings as LucideSettings,
|
||||
Copy,
|
||||
ChartLine,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -80,6 +81,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
path: '/stream-profiles',
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
|
||||
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
|
||||
{
|
||||
label: 'Settings',
|
||||
|
|
|
|||
135
frontend/src/pages/DVR.jsx
Normal file
135
frontend/src/pages/DVR.jsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Flex,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Gauge,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
SquarePlus,
|
||||
SquareX,
|
||||
Timer,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import RecordingForm from '../components/forms/Recording';
|
||||
import API from '../api';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const RecordingCard = ({ recording }) => {
|
||||
const { channels } = useChannelsStore();
|
||||
|
||||
const deleteRecording = (id) => {
|
||||
API.deleteRecording(id);
|
||||
};
|
||||
|
||||
const customProps = JSON.parse(recording.custom_properties);
|
||||
let recordingName = 'Custom Recording';
|
||||
if (customProps.program) {
|
||||
recordingName = customProps.program.title;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
color: '#fff',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Text fw={500}>{recordingName}</Text>
|
||||
</Group>
|
||||
|
||||
<Center>
|
||||
<Tooltip label="Delete / Cancel">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => deleteRecording(recording.id)}
|
||||
>
|
||||
<SquareX size="24" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Flex>
|
||||
|
||||
<Text size="sm">Channel: {channels[recording.channel].name}</Text>
|
||||
<Text size="sm">
|
||||
Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')}
|
||||
End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DVRPage = () => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const { recordings } = useChannelsStore();
|
||||
|
||||
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
|
||||
|
||||
const openRecordingModal = () => {
|
||||
setRecordingModalOpen(true);
|
||||
};
|
||||
|
||||
const closeRecordingModal = () => {
|
||||
setRecordingModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={openRecordingModal}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
New Recording
|
||||
</Button>
|
||||
<SimpleGrid cols={5} spacing="md" style={{ paddingTop: 10 }}>
|
||||
{Object.values(recordings).map((recording) => (
|
||||
<RecordingCard recording={recording} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<RecordingForm
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingModal}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DVRPage;
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Text,
|
||||
Paper,
|
||||
Grid,
|
||||
Group,
|
||||
} from '@mantine/core';
|
||||
import './guide.css';
|
||||
|
||||
|
|
@ -31,12 +32,13 @@ const MODAL_WIDTH = 600;
|
|||
const MODAL_HEIGHT = 400;
|
||||
|
||||
export default function TVChannelGuide({ startDate, endDate }) {
|
||||
const { channels } = useChannelsStore();
|
||||
const { channels, recordings } = useChannelsStore();
|
||||
|
||||
const [programs, setPrograms] = useState([]);
|
||||
const [guideChannels, setGuideChannels] = useState([]);
|
||||
const [now, setNow] = useState(dayjs());
|
||||
const [selectedProgram, setSelectedProgram] = useState(null);
|
||||
const [recording, setRecording] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
environment: { env_mode },
|
||||
|
|
@ -70,6 +72,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
);
|
||||
|
||||
setGuideChannels(filteredChannels);
|
||||
console.log(fetched);
|
||||
setPrograms(fetched);
|
||||
setLoading(false);
|
||||
};
|
||||
|
|
@ -158,13 +161,17 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
return guideChannels.find((ch) => ch.epg_data?.tvg_id === tvgId);
|
||||
}
|
||||
|
||||
const record = (program) => {
|
||||
const record = async (program) => {
|
||||
const channel = findChannelByTvgId(program.tvg_id);
|
||||
API.createRecording({
|
||||
await API.createRecording({
|
||||
channel: `${channel.id}`,
|
||||
start_time: program.start_time,
|
||||
end_time: program.end_time,
|
||||
custom_properties: JSON.stringify({
|
||||
program,
|
||||
}),
|
||||
});
|
||||
notifications.show({ title: 'Recording scheduled' });
|
||||
};
|
||||
|
||||
// The “Watch Now” click => show floating video
|
||||
|
|
@ -190,6 +197,18 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
// On program click, open the details modal
|
||||
function handleProgramClick(program, event) {
|
||||
setSelectedProgram(program);
|
||||
setRecording(
|
||||
recordings.find((recording) => {
|
||||
if (recording.custom_properties) {
|
||||
const customProps = JSON.parse(recording.custom_properties);
|
||||
if (customProps.program && customProps.program.id == program.id) {
|
||||
return recording;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
|
|
@ -206,6 +225,16 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const durationMinutes = programEnd.diff(programStart, 'minute');
|
||||
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
const recording = recordings.find((recording) => {
|
||||
if (recording.custom_properties) {
|
||||
const customProps = JSON.parse(recording.custom_properties);
|
||||
if (customProps.program && customProps.program.id == program.id) {
|
||||
return recording;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// Highlight if currently live
|
||||
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
|
||||
|
|
@ -250,7 +279,20 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
<Text size="md" style={{ fontWeight: 'bold' }}>
|
||||
{program.title}
|
||||
<Group gap="xs">
|
||||
{recording && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
display: 'flex',
|
||||
backgroundColor: 'red',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
{program.title}
|
||||
</Group>
|
||||
</Text>
|
||||
<Text size="sm" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
|
|
@ -464,13 +506,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
</Text>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => record(selectedProgram)}
|
||||
>
|
||||
Record
|
||||
</Button>
|
||||
{!recording && (
|
||||
<Button
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => record(selectedProgram)}
|
||||
>
|
||||
Record
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const useAuthStore = create((set, get) => ({
|
|||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
useChannelsStore.getState().fetchRecordings(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
activeChannels: {},
|
||||
activeClients: {},
|
||||
logos: {},
|
||||
recordings: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -371,6 +372,18 @@ const useChannelsStore = create((set, get) => ({
|
|||
};
|
||||
});
|
||||
},
|
||||
|
||||
fetchRecordings: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
set({
|
||||
recordings: await api.getRecordings(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recordings:', error);
|
||||
set({ error: 'Failed to load recordings.', isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useChannelsStore;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue