refactore channels store, added new UX for adding streams to a channel

This commit is contained in:
dekzter 2025-03-07 09:23:50 -05:00
parent 1dcbf8875f
commit c7347c568a
14 changed files with 360 additions and 132 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ node_modules/
.history/
staticfiles/
static/
data/

View file

@ -17,7 +17,7 @@ server {
}
# Serve FFmpeg streams efficiently
location /stream/ {
location /output/stream/ {
proxy_pass http://127.0.0.1:5656;
proxy_buffering off;
proxy_set_header Connection keep-alive;

View file

@ -15,6 +15,7 @@ export const WebsocketProvider = ({ children }) => {
const [val, setVal] = useState(null);
const { showAlert } = useAlertStore();
const { fetchStreams } = useStreamsStore();
const ws = useRef(null);
@ -51,7 +52,7 @@ export const WebsocketProvider = ({ children }) => {
switch (event.type) {
case 'm3u_refresh':
if (event.message?.success) {
useStreamsStore.getState().fetchStreams();
fetchStreams();
showAlert(event.message.message, 'success');
}
break;

View file

@ -12,7 +12,7 @@ const AlertPopup = () => {
return (
<Snackbar
open={open}
autoHideDuration={3000}
autoHideDuration={5000}
onClose={handleClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>

View file

@ -1,7 +1,7 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useRef } from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import useVideoStore from '../store/video';
import mpegts from 'mpegts.js';
export default function FloatingVideo() {
@ -63,7 +63,13 @@ export default function FloatingVideo() {
}}
>
{/* Simple header row with a close button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '4px',
}}
>
<button
onClick={hideVideo}
style={{

View file

@ -118,7 +118,9 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
</ListItem>
</List>
{open && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}
>
{/* Public IP + optional flag */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField

View file

@ -31,10 +31,126 @@ import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useVideoStore from '../../store/video';
import useSettingsStore from '../../store/settings';
import useStreamsStore from '../../store/streams';
import usePlaylistsStore from '../../store/playlists';
const ChannelsTable = () => {
const ChannelStreams = ({ channel, isExpanded }) => {
const [channelStreams, setChannelStreams] = useState([]);
const channelStreamIds = useChannelsStore(
(state) => state.channels[channel.id]?.stream_ids
);
const { playlists } = usePlaylistsStore();
const { streams } = useStreamsStore();
useEffect(
() =>
setChannelStreams(
streams
.filter((stream) => channelStreamIds.includes(stream.id))
.sort(
(a, b) =>
channelStreamIds.indexOf(a.id) - channelStreamIds.indexOf(b.id)
)
),
[streams, channelStreamIds]
);
const removeStream = async (stream) => {
let streamSet = new Set(channelStreams);
streamSet.delete(stream);
streamSet = Array.from(streamSet);
await API.updateChannel({
...channel,
streams: streamSet.map((stream) => stream.id),
});
};
const channelStreamsTable = useMaterialReactTable({
...TableHelper.defaultProperties,
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[playlists]
),
enableKeyboardShortcuts: false,
enableColumnActions: false,
enableColumnFilters: false,
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableColumnHeaders: false,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
enableRowOrdering: true,
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: async () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
// setChannelStreams([...channelStreams]);
API.updateChannel({
...channel,
streams: channelStreams.map((stream) => stream.id),
});
}
},
}),
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => removeStream(row.original)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
});
if (!isExpanded) {
return <></>;
}
return (
<Box
sx={{
backgroundColor: 'primary.main',
pt: 1,
pb: 1,
width: '100%',
}}
>
<MaterialReactTable table={channelStreamsTable} />
</Box>
);
};
const ChannelsTable = ({ setSelectedChannels }) => {
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
@ -43,9 +159,16 @@ const ChannelsTable = () => {
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
const { channels, isLoading: channelsLoading } = useChannelsStore();
const { showVideo } = useVideoStore(); // or useVideoStore()
const {
channels,
isLoading: channelsLoading,
fetchChannels,
} = useChannelsStore();
const outputUrlRef = useRef(null);
const {
environment: { env_mode },
} = useSettingsStore();
@ -104,9 +227,7 @@ const ChannelsTable = () => {
};
const deleteChannel = async (id) => {
setIsLoading(true);
await API.deleteChannel(id);
setIsLoading(false);
};
function handleWatchStream(channelNumber) {
@ -150,7 +271,7 @@ const ChannelsTable = () => {
setSnackbarOpen(true);
// Refresh the channel list
await useChannelsStore.getState().fetchChannels();
await fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
@ -215,7 +336,16 @@ const ChannelsTable = () => {
await navigator.clipboard.writeText(textToCopy);
setSnackbarMessage('Copied!');
} catch (err) {
setSnackbarMessage('Failed to copy');
const inputElement = outputUrlRef.current.querySelector('input'); // Get the actual input
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
setSnackbarMessage('Copied!');
}
}
setSnackbarOpen(true);
};
@ -240,11 +370,18 @@ const ChannelsTable = () => {
);
};
useEffect(() => {
const selectedRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
setSelectedChannels(selectedRows);
}, [rowSelection]);
// Configure the MaterialReactTable
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: channels,
data: Object.values(channels),
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
@ -261,6 +398,34 @@ const ChannelsTable = () => {
density: 'compact',
},
enableRowActions: true,
enableExpandAll: false,
displayColumnDefOptions: {
'mrt-row-expand': {
size: 10, // Set custom width (default is ~40px)
header: '',
muiTableHeadCellProps: {
sx: { width: 30, minWidth: 30, maxWidth: 30 },
},
muiTableBodyCellProps: {
sx: { width: 30, minWidth: 30, maxWidth: 30 },
},
},
'mrt-row-actions': {
size: 50, // Set custom width (default is ~40px)
},
},
muiExpandButtonProps: ({ row, table }) => ({
onClick: () => {
table.setExpanded({ [row.id]: !row.getIsExpanded() }); //only 1 detail panel open at a time
},
sx: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
renderDetailPanel: ({ row }) => (
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<IconButton
@ -300,65 +465,71 @@ const ChannelsTable = () => {
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
{/* Our brand-new button for EPG matching */}
<Tooltip title="Auto-match EPG with fuzzy logic">
<IconButton
size="small"
color="success"
variant="contained"
onClick={matchEpg}
>
<TvIcon fontSize="small" />
</IconButton>
</Tooltip>
return (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
disabled={selectedRowCount == 0}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
disabled={selectedRowCount == 0}
>
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" size="small" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" size="small" onClick={copyEPGUrl}>
EPG
</Button>
</ButtonGroup>
</Stack>
),
{/* Our brand-new button for EPG matching */}
<Tooltip title="Auto-match EPG with fuzzy logic">
<IconButton
size="small"
color="success"
variant="contained"
onClick={matchEpg}
>
<TvIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" size="small" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" size="small" onClick={copyEPGUrl}>
EPG
</Button>
</ButtonGroup>
</Stack>
);
},
});
return (
@ -384,11 +555,13 @@ const ChannelsTable = () => {
>
<div style={{ padding: 16, display: 'flex', alignItems: 'center' }}>
<TextField
id="output-url"
value={textToCopy}
variant="standard"
disabled
// disabled
size="small"
sx={{ marginRight: 1 }}
ref={outputUrlRef}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />

View file

@ -25,6 +25,7 @@ import {
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
import useStreamsStore from '../../store/streams';
const Example = () => {
const [playlist, setPlaylist] = useState(null);
@ -33,6 +34,7 @@ const Example = () => {
const [activeFilterValue, setActiveFilterValue] = useState('all');
const playlists = usePlaylistsStore((state) => state.playlists);
const { fetchStreams } = useStreamsStore();
const columns = useMemo(
//column definitions...
@ -114,6 +116,7 @@ const Example = () => {
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
fetchStreams();
};
const closeModal = () => {
@ -149,7 +152,7 @@ const Example = () => {
data: playlists,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
// enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
@ -197,6 +200,8 @@ const Example = () => {
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
pr: 1,
pl: 1,
},
},
renderTopToolbarCustomActions: ({ table }) => (

View file

@ -12,11 +12,7 @@ import {
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels'; // NEW: Import channels store
import API from '../../api';
// Make sure your api.js exports getAuthToken as a named export:
// e.g. export const getAuthToken = async () => { ... }
import { getAuthToken } from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
@ -26,7 +22,7 @@ import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const StreamsTable = () => {
const StreamsTable = ({ selectedChannels }) => {
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
@ -53,13 +49,11 @@ const StreamsTable = () => {
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
setIsLoading(true);
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
setIsLoading(false);
};
// Bulk creation: create channels from selected streams in one API call
@ -85,9 +79,7 @@ const StreamsTable = () => {
};
const deleteStream = async (id) => {
setIsLoading(true);
await API.deleteStream(id);
setIsLoading(false);
};
const deleteStreams = async () => {
@ -118,6 +110,19 @@ const StreamsTable = () => {
}
}, [sorting]);
const addStreamsToChannel = async (stream) => {
const channel = selectedChannels[0];
const selectedRows = table.getSelectedRowModel().rows;
await API.updateChannel({
...channel,
streams: [
...new Set(
channel.stream_ids.concat(selectedRows.map((row) => row.original.id))
),
],
});
};
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
@ -170,39 +175,54 @@ const StreamsTable = () => {
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small"
color="success"
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
return (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
disabled={selectedRowCount == 0}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
onClick={createChannelsFromStreams}
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
sx={{ marginLeft: 1 }}
disabled={selectedRowCount == 0}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
size="small"
sx={{ marginLeft: 1 }}
>
Create Channels
</Button>
</Stack>
),
Create Channels
</Button>
<Button
variant="contained"
onClick={addStreamsToChannel}
size="small"
sx={{ marginLeft: 1 }}
disabled={selectedChannels.length != 1 || selectedRowCount == 0}
>
Add to Channel
</Button>
</Stack>
);
},
});
return (

View file

@ -13,12 +13,14 @@ export default {
},
muiTableBodyCellProps: {
sx: {
padding: 0,
pt: 0,
pb: 0,
},
},
muiTableHeadCellProps: {
sx: {
padding: 0,
pt: 0,
pb: 0,
},
},
muiTableBodyProps: {

View file

@ -1,9 +1,11 @@
import React from 'react';
import React, { useState } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Grid2, Box } from '@mui/material';
const ChannelsPage = () => {
const [selectedChannels, setSelectedChannels] = useState([]);
return (
<Grid2 container>
<Grid2 size={6}>
@ -18,7 +20,7 @@ const ChannelsPage = () => {
overflow: 'hidden', // Prevent parent scrolling
}}
>
<ChannelsTable />
<ChannelsTable setSelectedChannels={setSelectedChannels} />
</Box>
</Grid2>
<Grid2 size={6}>
@ -33,7 +35,7 @@ const ChannelsPage = () => {
overflow: 'hidden', // Prevent parent scrolling
}}
>
<StreamsTable />
<StreamsTable selectedChannels={selectedChannels} />
</Box>
</Grid2>
</Grid2>

View file

@ -18,7 +18,7 @@ import dayjs from 'dayjs';
import API from '../api';
import useChannelsStore from '../store/channels';
import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
import useVideoStore from '../store/video'; // NEW import
import useAlertStore from '../store/alerts';
import useSettingsStore from '../store/settings';
@ -168,7 +168,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
// The “Watch Now” click => show floating video
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
const { showVideo } = useVideoStore(); // or useVideoStore()
function handleWatchStream(program) {
const matched = findChannelByTvgId(program.tvg_id);
if (!matched) {

View file

@ -11,7 +11,13 @@ const useChannelsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const channels = await api.getChannels();
set({ channels: channels, isLoading: false });
set({
channels: channels.reduce((acc, channel) => {
acc[channel.id] = channel;
return acc;
}, {}),
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch channels:', error);
set({ error: 'Failed to load channels.', isLoading: false });
@ -31,27 +37,37 @@ const useChannelsStore = create((set) => ({
addChannel: (newChannel) =>
set((state) => ({
channels: [...state.channels, newChannel],
channels: {
...state.channels,
[newChannel.id]: newChannel,
},
})),
addChannels: (newChannels) =>
set((state) => ({
channels: state.channels.concat(newChannels),
channels: {
...state.channels,
...newChannels,
},
})),
updateChannel: (userAgent) =>
updateChannel: (channel) =>
set((state) => ({
channels: state.channels.map((chan) =>
chan.id === userAgent.id ? userAgent : chan
),
channels: {
...state.channels,
[channel.id]: channel,
},
})),
removeChannels: (channelIds) =>
set((state) => ({
channels: state.channels.filter(
(channel) => !channelIds.includes(channel.id)
),
})),
set((state) => {
const updatedChannels = { ...state.channels };
for (const id of channelIds) {
delete updatedChannels[id];
}
return { channels: updatedChannels };
}),
addChannelGroup: (newChannelGroup) =>
set((state) => ({