This commit is contained in:
SergeantPanda 2025-04-11 12:27:45 -05:00
commit 9c2ccb9c7e
27 changed files with 1486 additions and 1210 deletions

View file

@ -122,10 +122,54 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 3) Channel Management (CRUD)
# ─────────────────────────────────────────────────────────
class ChannelPagination(PageNumberPagination):
page_size = 25 # Default page size
page_size_query_param = 'page_size' # Allow clients to specify page size
max_page_size = 10000 # Prevent excessive page sizes
class ChannelFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
channel_group_name = OrInFilter(field_name="channel_group__name", lookup_expr="icontains")
class Meta:
model = Channel
fields = ['name', 'channel_group_name',]
class ChannelViewSet(viewsets.ModelViewSet):
queryset = Channel.objects.all()
serializer_class = ChannelSerializer
permission_classes = [IsAuthenticated]
# pagination_class = ChannelPagination
# filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
# filterset_class = ChannelFilter
# search_fields = ['name', 'channel_group__name']
# ordering_fields = ['channel_number', 'name', 'channel_group__name']
# ordering = ['-channel_number']
def get_queryset(self):
qs = super().get_queryset()
channel_group = self.request.query_params.get('channel_group')
if channel_group:
group_names = channel_group.split(',')
qs = qs.filter(channel_group__name__in=group_names)
return qs
@action(detail=False, methods=['get'], url_path='ids')
def get_ids(self, request, *args, **kwargs):
# Get the filtered queryset
queryset = self.get_queryset()
# Apply filtering, search, and ordering
queryset = self.filter_queryset(queryset)
# Return only the IDs from the queryset
channel_ids = queryset.values_list('id', flat=True)
# Return the response with the list of IDs
return Response(list(channel_ids))
@swagger_auto_schema(
method='post',

View file

@ -40,10 +40,36 @@ def fetch_m3u_lines(account, use_cache=False):
try:
response = requests.get(account.server_url, headers=headers, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('Content-Length', 0))
downloaded = 0
start_time = time.time()
last_update_time = start_time
with open(file_path, 'wb') as file:
send_m3u_update(account.id, "downloading", 0)
for chunk in response.iter_content(chunk_size=8192):
if chunk:
file.write(chunk)
downloaded += len(chunk)
elapsed_time = time.time() - start_time
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 # in KB/s
# Calculate progress percentage
progress = (downloaded / total_size) * 100
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024)
current_time = time.time()
if current_time - last_update_time >= 0.5:
last_update_time = current_time
send_m3u_update(account.id, "downloading", progress, speed=speed, elapsed_time=elapsed_time, time_remaining=time_remaining)
send_m3u_update(account.id, "downloading", 100)
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching M3U from URL {account.server_url}: {e}")
return []
@ -317,6 +343,8 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
# Associate URL with the last EXTINF line
extinf_data[-1]["url"] = line
send_m3u_update(account_id, "processing_groups", 0)
groups = list(groups)
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
with open(cache_path, 'w', encoding='utf-8') as f:
@ -329,6 +357,8 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
release_task_lock('refresh_m3u_account_groups', account_id)
send_m3u_update(account_id, "processing_groups", 100)
if not full_refresh:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
@ -350,7 +380,6 @@ def refresh_single_m3u_account(account_id):
# redis_client = RedisClient.get_client()
# Record start time
start_time = time.time()
send_progress_update(0, account_id)
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
@ -415,7 +444,7 @@ def refresh_single_m3u_account(account_id):
if progress == 100:
progress = 99
send_progress_update(progress, account_id)
send_m3u_update(account_id, "parsing", progress)
# Optionally remove completed task from the group to prevent processing it again
result.remove(async_result)
@ -424,7 +453,7 @@ def refresh_single_m3u_account(account_id):
# Run cleanup
cleanup_streams(account_id)
send_progress_update(100, account_id)
send_m3u_update(account_id, "parsing", 100)
end_time = time.time()
@ -454,12 +483,24 @@ def refresh_single_m3u_account(account_id):
return f"Dispatched jobs complete."
def send_progress_update(progress, account_id):
def send_m3u_update(account_id, action, progress, **kwargs):
# Start with the base data dictionary
data = {
"progress": progress,
"type": "m3u_refresh",
"account": account_id,
"action": action,
}
# Add the additional key-value pairs from kwargs
data.update(kwargs)
# Now, send the updated data dictionary
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{
'type': 'update',
"data": {"progress": progress, "type": "m3u_refresh", "account": account_id}
'data': data
}
)

View file

@ -99,4 +99,4 @@ def version(request):
return Response({
'version': __version__,
'build': __build__,
})
})

View file

@ -1,7 +1,7 @@
from django.contrib import admin
from django.urls import path, include, re_path
from django.conf import settings
from django.conf.urls.static import static
from django.conf.urls.static import static, serve
from django.views.generic import TemplateView, RedirectView
from rest_framework import permissions
from drf_yasg.views import get_schema_view
@ -53,15 +53,12 @@ urlpatterns = [
# Optionally, serve the raw Swagger JSON
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
# Catch-all routes should always be last
# Catch-all React route (this is for the frontend routing to index.html)
path('', TemplateView.as_view(template_name='index.html')), # React entry point
path('<path:unused_path>', TemplateView.as_view(template_name='index.html')),
# path('<path:unused_path>', TemplateView.as_view(template_name='index.html')), # Handle all non-API routes
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Serve static files dynamically
path('static/<path:path>', serve, {'document_root': settings.STATIC_ROOT}), # This serves static files
urlpatterns += websocket_urlpatterns
# Serve static files for development (React's JS, CSS, etc.)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # Serve static files in development
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -11,7 +11,30 @@ services:
- 8001:8001
volumes:
- ../:/app
# - ./data/db:/data
environment:
- DISPATCHARR_ENV=dev
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
volumes:
- dispatcharr_dev_pgadmin:/var/lib/pgadmin
ports:
- 8082:80
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=dispatcharr:dispatcharr:6379:0
- TRUST_PROXY=true
- ADDRESS=0.0.0.0
ports:
- 8081:8081
volumes:
dispatcharr_dev_pgadmin:

View file

@ -25,9 +25,8 @@ die-on-term = true
static-map = /static=/app/static
# Worker management (Optimize for I/O bound tasks)
workers = 4
threads = 2
enable-threads = true
workers = 2
enable-threads = false
# Optimize for streaming
http = 0.0.0.0:5656

View file

@ -9,13 +9,11 @@ import {
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import EPG from './pages/EPG';
import ContentSources from './pages/ContentSources';
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';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
@ -87,73 +85,67 @@ const App = () => {
withGlobalStyles
withNormalizeCSS
>
<WebsocketProvider>
<Notifications containerWidth={350} />
<Router>
<AppShell
header={{
height: 0,
}}
navbar={{
width: open ? drawerWidth : miniDrawerWidth,
}}
>
<Sidebar
drawerWidth
miniDrawerWidth
collapsed={!open}
toggleDrawer={toggleDrawer}
/>
<Router>
<AppShell
header={{
height: 0,
}}
navbar={{
width: open ? drawerWidth : miniDrawerWidth,
}}
>
<Sidebar
drawerWidth
miniDrawerWidth
collapsed={!open}
toggleDrawer={toggleDrawer}
/>
<AppShell.Main>
<Box
style={{
display: 'flex',
flexDirection: 'column',
// transition: 'margin-left 0.3s',
backgroundColor: '#18181b',
height: '100vh',
color: 'white',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
<AppShell.Main>
<Box
style={{
display: 'flex',
flexDirection: 'column',
// transition: 'margin-left 0.3s',
backgroundColor: '#18181b',
height: '100vh',
color: 'white',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/sources" element={<ContentSources />} />
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</AppShell.Main>
</AppShell>
<M3URefreshNotification />
</Router>
</Box>
</AppShell.Main>
</AppShell>
<M3URefreshNotification />
<Notifications containerWidth={350} />
<WebsocketProvider />
</Router>
<FloatingVideo />
</WebsocketProvider>
<FloatingVideo />
</MantineProvider>
);
};

View file

@ -4,12 +4,15 @@ import React, {
useRef,
createContext,
useContext,
useMemo,
} from 'react';
import useStreamsStore from './store/streams';
import { notifications } from '@mantine/notifications';
import useChannelsStore from './store/channels';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import { Box, Button, Stack } from '@mantine/core';
import API from './api';
export const WebsocketContext = createContext(false, null, () => {});
@ -23,6 +26,7 @@ export const WebsocketProvider = ({ children }) => {
const { fetchPlaylists, setRefreshProgress, setProfilePreview } =
usePlaylistsStore();
const { fetchEPGData, fetchEPGs } = useEPGsStore();
const { playlists } = usePlaylistsStore();
const ws = useRef(null);
@ -79,27 +83,28 @@ export const WebsocketProvider = ({ children }) => {
notifications.show({
title: 'Group processing finished!',
message: 'Refresh M3U or filter out groups to pull in streams.',
autoClose: 5000,
message: (
<Stack>
Refresh M3U or filter out groups to pull in streams.
<Button
size="xs"
variant="default"
onClick={() => {
API.refreshPlaylist(event.data.account);
setRefreshProgress(event.data.account, 0);
}}
>
Refresh Now
</Button>
</Stack>
),
color: 'green.5',
});
break;
case 'm3u_refresh':
if (event.data.success) {
fetchStreams();
notifications.show({
message: event.data.message,
color: 'green.5',
});
} else if (event.data.progress !== undefined) {
if (event.data.progress == 100) {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
}
setRefreshProgress(event.data.account, event.data.progress);
}
setRefreshProgress(event.data);
break;
case 'channel_stats':
@ -154,7 +159,9 @@ export const WebsocketProvider = ({ children }) => {
};
}, []);
const ret = [isReady, ws.current?.send.bind(ws.current), val];
const ret = useMemo(() => {
return [isReady, ws.current?.send.bind(ws.current), val];
}, [isReady, val]);
return (
<WebsocketContext.Provider value={ret}>

File diff suppressed because it is too large Load diff

View file

@ -1,81 +1,85 @@
// frontend/src/components/FloatingVideo.js
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../store/playlists';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import useStreamsStore from '../store/streams';
import useChannelsStore from '../store/channels';
import useEPGsStore from '../store/epgs';
export default function M3URefreshNotification() {
const { playlists, refreshProgress, removeRefreshProgress } =
usePlaylistsStore();
const [progress, setProgress] = useState({});
const { playlists, refreshProgress } = usePlaylistsStore();
const { fetchStreams } = useStreamsStore();
const { fetchChannelGroups } = useChannelsStore();
const { fetchPlaylists } = usePlaylistsStore();
const { fetchEPGData } = useEPGsStore();
const clearAccountNotification = (id) => {
removeRefreshProgress(id);
setProgress({
...progress,
[id]: null,
const [notificationStatus, setNotificationStatus] = useState({});
const handleM3UUpdate = (data) => {
if (
JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)
) {
return;
}
console.log(data);
const playlist = playlists.find((pl) => pl.id == data.account);
setNotificationStatus({
...notificationStatus,
[data.account]: data,
});
const taskProgress = data.progress;
if (data.progress != 0 && data.progress != 100) {
console.log('not 0 or 100');
return;
}
let message = '';
switch (data.action) {
case 'downloading':
message = 'Downloading';
break;
case 'parsing':
message = 'Stream parsing';
break;
case 'processing_groups':
message = 'Group parsing';
break;
}
if (taskProgress == 0) {
message = `${message} starting...`;
} else if (taskProgress == 100) {
message = `${message} complete!`;
if (data.action == 'parsing') {
fetchStreams();
} else if (data.action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
}
}
notifications.show({
title: `M3U Processing: ${playlist.name}`,
message,
loading: taskProgress == 0,
autoClose: 2000,
icon: taskProgress == 100 ? <IconCheck /> : null,
});
};
for (const id in refreshProgress) {
const playlist = playlists.find((pl) => pl.id == id);
if (!progress[id]) {
if (refreshProgress[id] == 100) {
// This situation is if it refreshes so fast we only get the 100% complete notification
const notificationId = notifications.show({
loading: false,
title: `M3U Refresh: ${playlist.name}`,
message: `Refresh complete!`,
icon: <IconCheck />,
});
setProgress({
...progress,
[id]: notificationId,
});
setTimeout(() => clearAccountNotification(id), 2000);
return;
}
const notificationId = notifications.show({
loading: true,
title: `M3U Refresh`,
message: `Starting...`,
autoClose: false,
withCloseButton: false,
});
setProgress({
...progress,
...(playlist && {
title: `M3U Refresh: ${playlist.name}`,
}),
[id]: notificationId,
});
} else {
if (refreshProgress[id] == 0) {
notifications.update({
id: progress[id],
message: `Starting...`,
});
} else if (refreshProgress[id] == 100) {
notifications.update({
id: progress[id],
message: `Refresh complete!`,
loading: false,
autoClose: 2000,
icon: <IconCheck />,
});
setTimeout(() => clearAccountNotification(id), 2000);
} else {
notifications.update({
id: progress[id],
message: `Updating M3U: ${refreshProgress[id]}%`,
});
}
}
}
useEffect(() => {
Object.values(refreshProgress).map((data) => handleM3UUpdate(data));
}, [playlists, refreshProgress]);
return <></>;
}

View file

@ -71,16 +71,17 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
// Fetch environment settings including version on component mount
useEffect(() => {
if (!isAuthenticated) {
return;
}
const fetchEnvironment = async () => {
try {
const envData = await API.getEnvironmentSettings();
} catch (error) {
console.error('Failed to fetch environment settings:', error);
}
API.getEnvironmentSettings();
};
fetchEnvironment();
}, []);
}, [isAuthenticated]);
// Fetch version information on component mount (regardless of authentication)
useEffect(() => {
const fetchVersion = async () => {
@ -88,7 +89,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const versionData = await API.getVersion();
setAppVersion({
version: versionData.version || '',
build: versionData.build || ''
build: versionData.build || '',
});
} catch (error) {
console.error('Failed to fetch version information:', error);
@ -106,8 +107,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
{ label: 'M3U & EPG Manager', icon: <Play size={20} />, path: '/sources' },
{
label: 'Stream Profiles',
icon: <SlidersHorizontal size={20} />,
@ -223,7 +223,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={environment.country_name || environment.country_code}
title={environment.country_name || environment.country_code}
title={
environment.country_name || environment.country_code
}
/>
)
}
@ -263,7 +265,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
{!collapsed && (
<Text size="xs" style={{ padding: '0 16px 16px' }} c="dimmed">
v{appVersion?.version || '0.0.0'}{appVersion?.build !== '0' ? `-${appVersion?.build}` : ''}
v{appVersion?.version || '0.0.0'}
{appVersion?.build !== '0' ? `-${appVersion?.build}` : ''}
</Text>
)}
</AppShell.Navbar>

View file

@ -487,7 +487,13 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
label={
<Group style={{ width: '100%' }}>
<Box>EPG</Box>
<Button size="xs" variant="transparent">
<Button
size="xs"
variant="transparent"
onClick={() =>
formik.setFieldValue('epg_data_id', null)
}
>
Use Dummy
</Button>
</Group>

View file

@ -1,17 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Paper,
Title,
TextInput,
Button,
Checkbox,
Modal,
Box,
Center,
Stack,
} from '@mantine/core';
import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext

View file

@ -399,6 +399,12 @@ const ChannelsTable = ({}) => {
size: 50,
maxSize: 50,
accessorKey: 'channel_number',
sortingFn: (a, b, columnId) => {
return (
parseInt(a.original.channel_number) -
parseInt(b.original.channel_number)
);
},
mantineTableHeadCellProps: {
align: 'right',
// // style: {
@ -469,11 +475,11 @@ const ChannelsTable = ({}) => {
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
// onChange={(e, value) => {
// e.stopPropagation();
// handleGroupChange(value);
// }}
nothingFoundMessage="No options"
onChange={(value) => {
// e.stopPropagation();
handleFilterChange(column.id, value);
}}
data={channelGroupOptions}
variant="unstyled"
className="table-input-header"
@ -485,16 +491,15 @@ const ChannelsTable = ({}) => {
header: '',
accessorKey: 'logo',
enableSorting: false,
size: 55,
size: 75,
mantineTableBodyCellProps: {
align: 'center',
style: {
maxWidth: '55px',
maxWidth: '75px',
},
},
Cell: ({ cell }) => (
<Grid
container
direction="row"
sx={{
justifyContent: 'center',
@ -541,6 +546,7 @@ const ChannelsTable = ({}) => {
};
const deleteChannel = async (id) => {
setRowSelection([]);
if (channelsPageSelection.length > 0) {
return deleteChannels();
}
@ -747,10 +753,6 @@ const ChannelsTable = ({}) => {
id: 'channel_number',
desc: false,
},
{
id: 'name',
desc: false,
},
],
},
enableRowActions: true,

View file

@ -149,7 +149,7 @@ const EPGsTable = () => {
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
height: 'calc(40vh - 10px)',
},
},
displayColumnDefOptions: {

View file

@ -26,10 +26,64 @@ const M3UTable = () => {
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [playlistCreated, setPlaylistCreated] = useState(false);
const { playlists, setRefreshProgress } = usePlaylistsStore();
const { playlists, refreshProgress, setRefreshProgress } =
usePlaylistsStore();
const theme = useMantineTheme();
const generateStatusString = (data) => {
if (data.progress == 100) {
return 'Idle';
}
switch (data.action) {
case 'downloading':
return buildDownloadingStats(data);
case 'processing_groups':
return 'Processing groups...';
default:
return buildParsingStats(data);
}
};
const buildDownloadingStats = (data) => {
if (data.progress == 100) {
// fetchChannelGroups();
// fetchPlaylists();
return 'Download complete!';
}
if (data.progress == 0) {
return 'Downloading...';
}
return (
<Box>
<Text size="xs">Downloading: {parseInt(data.progress)}%</Text>
{/* <Text size="xs">Speed: {parseInt(data.speed)} KB/s</Text>
<Text size="xs">Time Remaining: {parseInt(data.time_remaining)}</Text> */}
</Box>
);
};
const buildParsingStats = (data) => {
if (data.progress == 100) {
// fetchStreams();
// fetchChannelGroups();
// fetchEPGData();
// fetchPlaylists();
return 'Parsing complete!';
}
if (data.progress == 0) {
return 'Parsing...';
}
return `Parsing: ${data.progress}%`;
};
const columns = useMemo(
//column definitions...
() => [
@ -57,6 +111,20 @@ const M3UTable = () => {
accessorKey: 'max_streams',
size: 200,
},
{
header: 'Status',
accessorFn: (row) => {
if (!row.id) {
return '';
}
if (!refreshProgress[row.id]) {
return 'Idle';
}
return generateStatusString(refreshProgress[row.id]);
},
size: 200,
},
{
header: 'Active',
accessorKey: 'is_active',
@ -77,7 +145,7 @@ const M3UTable = () => {
enableSorting: false,
},
],
[]
[refreshProgress]
);
//optionally access the underlying virtualizer instance
@ -190,7 +258,7 @@ const M3UTable = () => {
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
height: 'calc(40vh - 10px)',
},
},
});

View file

@ -17,6 +17,7 @@ import {
useMantineTheme,
Center,
Switch,
Stack,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react';
@ -212,19 +213,19 @@ const StreamProfiles = () => {
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 120px)',
height: 'calc(60vh - 100px)',
overflowY: 'auto',
},
},
});
return (
<Box>
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingBottom: 10,
// paddingBottom: 10,
}}
gap={15}
>
@ -305,7 +306,7 @@ const StreamProfiles = () => {
isOpen={profileModalOpen}
onClose={closeStreamProfileForm}
/>
</Box>
</Stack>
);
};

View file

@ -140,16 +140,13 @@ const StreamsTable = ({}) => {
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
valueComponent={({ value }) => {
return <div>foo</div>; // Override to display custom text
}}
/>
</Box>
),
@ -176,7 +173,7 @@ const StreamsTable = ({}) => {
placeholder="M3U"
searchable
size="xs"
nothingFound="No options"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
@ -568,24 +565,47 @@ const StreamsTable = ({}) => {
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 30,
mantineTableHeadCellProps: {
align: 'left',
style: {
paddingLeft: 4,
backgroundColor: '#3F3F46',
minWidth: '65px',
maxWidth: '65px',
paddingLeft: 10,
fontWeight: 'normal',
color: 'rgb(207,207,207)',
backgroundColor: '#3F3F46',
},
},
mantineTableBodyCellProps: {
style: {
paddingLeft: 0,
paddingRight: 0,
minWidth: '65px',
maxWidth: '65px',
// paddingLeft: 0,
// paddingRight: 10,
},
},
},
'mrt-row-select': {
size: 20,
size: 10,
maxSize: 10,
mantineTableHeadCellProps: {
align: 'right',
style: {
paddding: 0,
// paddingLeft: 7,
width: '20px',
minWidth: '20px',
backgroundColor: '#3F3F46',
},
},
mantineTableBodyCellProps: {
align: 'right',
style: {
paddingLeft: 0,
width: '20px',
minWidth: '20px',
},
},
},
},
});

View file

@ -16,6 +16,7 @@ import {
Paper,
Box,
Button,
Stack,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X } from 'lucide-react';
@ -35,6 +36,7 @@ const UserAgentsTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 100,
},
{
header: 'User-Agent',
@ -219,7 +221,9 @@ const UserAgentsTable = () => {
),
mantineTableContainerProps: {
style: {
height: 'calc(43vh - 55px)',
height: 'calc(60vh - 100px)',
overflowY: 'auto',
// margin: 5,
},
},
displayColumnDefOptions: {
@ -230,15 +234,17 @@ const UserAgentsTable = () => {
});
return (
<>
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 10,
}}
gap={15}
style={
{
// display: 'flex',
// alignItems: 'center',
// paddingTop: 10,
// paddingBottom: 10,
}
}
// gap={15}
>
<Text
h={24}
@ -249,7 +255,7 @@ const UserAgentsTable = () => {
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
// marginBottom: 0,
}}
>
User-Agents
@ -307,7 +313,7 @@ const UserAgentsTable = () => {
isOpen={userAgentModalOpen}
onClose={closeUserAgentForm}
/>
</>
</Stack>
);
};

View file

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import { Box } from '@mantine/core';
import EPGsTable from '../components/tables/EPGsTable';
import { Box, Stack } from '@mantine/core';
const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
@ -12,13 +12,9 @@ const M3UPage = () => {
if (error) return <div>Error: {error}</div>;
return (
<Box
<Stack
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
padding: 16,
padding: 10,
}}
>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
@ -26,9 +22,9 @@ const M3UPage = () => {
</Box>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<UserAgentsTable />
<EPGsTable />
</Box>
</Box>
</Stack>
);
};

View file

@ -80,7 +80,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
const filteredChannels = Object.values(channels)
.filter((ch) => programIds.includes(ch.epg_data?.tvg_id))
// Add sorting by channel_number
.sort((a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity));
.sort(
(a, b) =>
(a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(
`found ${filteredChannels.length} channels with matching tvg_ids`
@ -105,15 +108,15 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(channel =>
result = result.filter((channel) =>
channel.name.toLowerCase().includes(query)
);
}
// Apply channel group filter
if (selectedGroupId !== 'all') {
result = result.filter(channel =>
channel.channel_group?.id === parseInt(selectedGroupId)
result = result.filter(
(channel) => channel.channel_group?.id === parseInt(selectedGroupId)
);
}
@ -122,16 +125,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Get the profile's enabled channels
const profileChannels = profiles[selectedProfileId]?.channels || [];
const enabledChannelIds = profileChannels
.filter(pc => pc.enabled)
.map(pc => pc.id);
.filter((pc) => pc.enabled)
.map((pc) => pc.id);
result = result.filter(channel =>
result = result.filter((channel) =>
enabledChannelIds.includes(channel.id)
);
}
setFilteredChannels(result);
}, [searchQuery, selectedGroupId, selectedProfileId, guideChannels, profiles]);
}, [
searchQuery,
selectedGroupId,
selectedProfileId,
guideChannels,
profiles,
]);
// Use start/end from props or default to "today at midnight" +24h
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
@ -213,7 +222,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
hours.push({
time: current,
isNewDay,
dayLabel: formatDayLabel(current)
dayLabel: formatDayLabel(current),
});
current = current.add(1, 'hour');
@ -223,12 +232,21 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Scroll to the nearest half-hour mark ONLY on initial load
useEffect(() => {
if (guideRef.current && timelineRef.current && programs.length > 0 && !initialScrollComplete) {
if (
guideRef.current &&
timelineRef.current &&
programs.length > 0 &&
!initialScrollComplete
) {
// Round the current time to the nearest half-hour mark
const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute');
const roundedNow =
now.minute() < 30
? now.startOf('hour')
: now.startOf('hour').add(30, 'minute');
const nowOffset = roundedNow.diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
const scrollPos = Math.max(scrollPosition, 0);
guideRef.current.scrollLeft = scrollPos;
@ -345,19 +363,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
const currentScrollPosition = guideRef.current.scrollLeft;
// Check if we need to scroll (if program start is before current view or too close to edge)
if (desiredScrollPosition < currentScrollPosition ||
leftPx - currentScrollPosition < 100) { // 100px from left edge
if (
desiredScrollPosition < currentScrollPosition ||
leftPx - currentScrollPosition < 100
) {
// 100px from left edge
// Smooth scroll to the program's start
guideRef.current.scrollTo({
left: desiredScrollPosition,
behavior: 'smooth'
behavior: 'smooth',
});
// Also sync the timeline scroll
timelineRef.current.scrollTo({
left: desiredScrollPosition,
behavior: 'smooth'
behavior: 'smooth',
});
}
}
@ -375,10 +396,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
const scrollToNow = () => {
if (guideRef.current && timelineRef.current && nowPosition >= 0) {
// Round the current time to the nearest half-hour mark
const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute');
const roundedNow =
now.minute() < 30
? now.startOf('hour')
: now.startOf('hour').add(30, 'minute');
const nowOffset = roundedNow.diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
const scrollPos = Math.max(scrollPosition, 0);
guideRef.current.scrollLeft = scrollPos;
@ -410,7 +435,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
const scrollAmount = e.shiftKey ? 250 : 125;
// Scroll horizontally based on wheel direction
timelineRef.current.scrollLeft += e.deltaY > 0 ? scrollAmount : -scrollAmount;
timelineRef.current.scrollLeft +=
e.deltaY > 0 ? scrollAmount : -scrollAmount;
// Sync the main content scroll position
if (guideRef.current) {
@ -457,7 +483,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
const snappedOffset = snappedTime.diff(start, 'minute');
// Convert to pixels
const scrollPosition = (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const scrollPosition =
(snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
// Scroll both containers to the snapped position
timelineRef.current.scrollLeft = scrollPosition;
@ -476,7 +503,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Calculate width with a small gap (2px on each side)
const gapSize = 2;
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - (gapSize * 2);
const widthPx =
(durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2;
// Check if we have a recording for this program
const recording = recordings.find((recording) => {
@ -499,7 +527,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
const isExpanded = expandedProgramId === program.id;
// Calculate how much of the program is cut off
const cutOffMinutes = Math.max(0, channelStart.diff(programStart, 'minute'));
const cutOffMinutes = Math.max(
0,
channelStart.diff(programStart, 'minute')
);
const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
// Set the height based on expanded state
@ -522,7 +553,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
height: rowHeight - 4, // Adjust for the parent row padding
cursor: 'pointer',
zIndex: isExpanded ? 25 : 5, // Increase z-index when expanded
transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease',
transition: isExpanded
? 'height 0.2s ease, width 0.2s ease'
: 'height 0.2s ease',
}}
onClick={(e) => handleProgramClick(program, e)}
>
@ -530,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
elevation={isExpanded ? 4 : 2}
className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'} ${isExpanded ? 'expanded' : ''}`}
style={{
width: "100%", // Fill container width (which may be expanded)
width: '100%', // Fill container width (which may be expanded)
height: '100%',
overflow: 'hidden',
position: 'relative',
@ -542,12 +575,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
? isLive
? '#1a365d' // Darker blue when expanded and live
: isPast
? '#2d3748' // Darker gray when expanded and past
? '#18181B' // Darker gray when expanded and past
: '#1e40af' // Darker blue when expanded and upcoming
: isLive
? '#2d3748' // Default live program color
? '#18181B' // Default live program color
: isPast
? '#4a5568' // Slightly darker color for past programs
? '#27272A' // Slightly darker color for past programs
: '#2c5282', // Default color for upcoming programs
color: isPast ? '#a0aec0' : '#fff', // Dim text color for past programs
boxShadow: isExpanded ? '0 4px 8px rgba(0,0,0,0.4)' : 'none',
@ -556,7 +589,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
>
<Box>
<Text
size={isExpanded ? "lg" : "md"}
size={isExpanded ? 'lg' : 'md'}
style={{
fontWeight: 'bold',
whiteSpace: 'nowrap',
@ -657,7 +690,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
if (channelGroups && guideChannels.length > 0) {
// Get unique channel group IDs from the channels that have program data
const usedGroupIds = new Set();
guideChannels.forEach(channel => {
guideChannels.forEach((channel) => {
if (channel.channel_group?.id) {
usedGroupIds.add(channel.channel_group.id);
}
@ -665,12 +698,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Only add groups that are actually used by channels in the guide
Object.values(channelGroups)
.filter(group => usedGroupIds.has(group.id))
.filter((group) => usedGroupIds.has(group.id))
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
.forEach(group => {
.forEach((group) => {
options.push({
value: group.id.toString(),
label: group.name
label: group.name,
});
});
}
@ -683,11 +716,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
const options = [{ value: 'all', label: 'All Profiles' }];
if (profiles) {
Object.values(profiles).forEach(profile => {
if (profile.id !== '0') { // Skip the 'All' default profile
Object.values(profiles).forEach((profile) => {
if (profile.id !== '0') {
// Skip the 'All' default profile
options.push({
value: profile.id.toString(),
label: profile.name
label: profile.name,
});
}
});
@ -720,7 +754,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflow: 'hidden',
width: '100%',
height: '100%',
backgroundColor: '#1a202c',
// backgroundColor: 'rgb(39, 39, 42)',
color: '#fff',
fontFamily: 'Roboto, sans-serif',
}}
@ -730,7 +764,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
<Flex
direction="column"
style={{
backgroundColor: '#2d3748',
// backgroundColor: '#424242',
color: '#fff',
padding: '12px 20px',
position: 'sticky',
@ -769,7 +803,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
leftSection={<Search size={16} />}
rightSection={
searchQuery ? (
<ActionIcon onClick={() => setSearchQuery('')} variant="subtle" color="gray" size="sm">
<ActionIcon
onClick={() => setSearchQuery('')}
variant="subtle"
color="gray"
size="sm"
>
<X size={14} />
</ActionIcon>
) : null
@ -794,20 +833,29 @@ export default function TVChannelGuide({ startDate, endDate }) {
clearable={true} // Allow clearing the selection
/>
{(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && (
{(searchQuery !== '' ||
selectedGroupId !== 'all' ||
selectedProfileId !== 'all') && (
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
Clear Filters
</Button>
)}
<Text size="sm" color="dimmed">
{filteredChannels.length} {filteredChannels.length === 1 ? 'channel' : 'channels'}
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
</Text>
</Flex>
</Flex>
{/* Guide container with headers and scrollable content */}
<Box style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 120px)',
}}
>
{/* Logo header - Sticky, non-scrollable */}
<Box
style={{
@ -824,9 +872,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
minWidth: CHANNEL_WIDTH,
flexShrink: 0,
height: '40px',
backgroundColor: '#2d3748',
borderBottom: '1px solid #4a5568',
borderRight: '1px solid #4a5568', // Increased border width
backgroundColor: '#18181B',
borderBottom: '1px solid #27272A',
borderRight: '1px solid #27272A', // Increased border width
position: 'sticky',
left: 0,
zIndex: 200,
@ -854,8 +902,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
<Box
style={{
display: 'flex',
backgroundColor: '#171923',
borderBottom: '1px solid #4a5568',
backgroundColor: '#1E2A27',
borderBottom: '1px solid #27272A',
width: hourTimeline.length * HOUR_WIDTH,
}}
>
@ -870,10 +918,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
height: '40px',
position: 'relative',
color: '#a0aec0',
borderRight: '1px solid #4a5568',
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #4299e1' : 'none', // Highlight day boundaries
backgroundColor: isNewDay ? 'rgba(66, 153, 225, 0.05)' : '#171923', // Subtle background for new days
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
}}
onClick={(e) => handleTimeClick(time, e)}
>
@ -900,10 +948,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
display: 'block',
opacity: 0.7,
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
color: isNewDay ? '#4299e1' : undefined,
color: isNewDay ? '#3BA882' : undefined,
}}
>
{formatDayLabel(time)} {/* Use same formatDayLabel function for all hours */}
{formatDayLabel(time)}{' '}
{/* Use same formatDayLabel function for all hours */}
</Text>
{time.format('h:mm')}
<Text span size="xs" ml={1} opacity={0.7}>
@ -919,7 +968,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
top: 0,
bottom: 0,
width: '1px',
backgroundColor: '#4a5568',
backgroundColor: '#27272A',
zIndex: 10,
}}
/>
@ -969,12 +1018,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
onScroll={handleGuideScroll}
>
{/* Content wrapper with min-width to ensure scroll range */}
<Box style={{
width: hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH,
position: 'relative',
display: 'flex',
flexDirection: 'column'
}}>
<Box
style={{
width: hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH,
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Now line - positioned absolutely within content */}
{nowPosition >= 0 && (
<Box
@ -998,8 +1049,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
(p) => p.tvg_id === channel.epg_data?.tvg_id
);
// Check if any program in this channel is expanded
const hasExpandedProgram = channelPrograms.some(prog => prog.id === expandedProgramId);
const rowHeight = hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
const hasExpandedProgram = channelPrograms.some(
(prog) => prog.id === expandedProgramId
);
const rowHeight = hasExpandedProgram
? EXPANDED_PROGRAM_HEIGHT
: PROGRAM_HEIGHT;
return (
<Box
@ -1007,7 +1062,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
style={{
display: 'flex',
height: rowHeight,
borderBottom: '0px solid #4a5568', // Increased border width for better visibility
borderBottom: '0px solid #27272A', // Increased border width for better visibility
transition: 'height 0.2s ease',
position: 'relative', // Added for proper stacking
overflow: 'visible', // Changed from 'hidden' to 'visible' to allow expanded programs to overflow
@ -1023,9 +1078,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2d3748',
borderRight: '1px solid #4a5568', // Increased border width for visibility
borderBottom: '1px solid #4a5568', // Match the row border
backgroundColor: '#18181B',
borderRight: '1px solid #27272A', // Increased border width for visibility
borderBottom: '1px solid #27272A', // Match the row border
boxShadow: '2px 0 5px rgba(0,0,0,0.2)', // Added shadow for depth
position: 'sticky',
left: 0,
@ -1057,7 +1112,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
animation: 'fadeIn 0.2s',
}}
>
<Play size={32} color="#fff" fill="#fff" /> {/* Changed from Video to Play and increased size */}
<Play size={32} color="#fff" fill="#fff" />{' '}
{/* Changed from Video to Play and increased size */}
</Flex>
)}
@ -1108,11 +1164,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
bottom: '4px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: '#2d3748',
backgroundColor: '#18181B',
padding: '2px 8px',
borderRadius: 4,
fontSize: '0.85em',
border: '1px solid #4a5568',
border: '1px solid #27272A',
height: '24px',
display: 'flex',
alignItems: 'center',
@ -1126,14 +1182,18 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Box>
{/* Programs for this channel */}
<Box style={{
flex: 1,
position: 'relative',
height: rowHeight,
transition: 'height 0.2s ease',
paddingLeft: 0, // Remove any padding that might push content
}}>
{channelPrograms.map((prog) => renderProgram(prog, start))}
<Box
style={{
flex: 1,
position: 'relative',
height: rowHeight,
transition: 'height 0.2s ease',
paddingLeft: 0, // Remove any padding that might push content
}}
>
{channelPrograms.map((prog) =>
renderProgram(prog, start)
)}
</Box>
</Box>
);
@ -1160,4 +1220,3 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Box>
);
}

View file

@ -3,8 +3,20 @@ import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Flex,
Group,
Paper,
Select,
Stack,
Title,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
const SettingsPage = () => {
const { settings } = useSettingsStore();
@ -309,65 +321,81 @@ const SettingsPage = () => {
};
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
<Stack>
<Center
style={{
height: '40vh',
}}
>
<Title order={4} align="center">
Settings
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Select
searchable
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Paper
elevation={3}
style={{ padding: 20, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Settings
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Select
searchable
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" disabled={form.submitting} size="sm">
Submit
</Button>
</Flex>
</form>
</Paper>
</Center>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</form>
</Paper>
</Center>
<Group
justify="space-around"
align="top"
style={{ width: '100%' }}
gap={0}
>
<StreamProfilesTable />
<UserAgentsTable />
</Group>
</Stack>
);
};

View file

@ -33,6 +33,7 @@ import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { Sparkline } from '@mantine/charts';
import useStreamProfilesStore from '../store/streamProfiles';
import { useLocation } from 'react-router-dom';
dayjs.extend(duration);
dayjs.extend(relativeTime);
@ -75,6 +76,8 @@ const getStartDate = (uptime) => {
// Create a separate component for each channel card to properly handle the hook
const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => {
const location = useLocation();
const clientsColumns = useMemo(
() => [
{
@ -90,7 +93,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => {
const channelClientsTable = useMantineReactTable({
...TableHelper.defaultProperties,
columns: clientsColumns,
data: clients.filter(client => client.channel.channel_id === channel.channel_id),
data: clients.filter(
(client) => client.channel.channel_id === channel.channel_id
),
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
@ -140,6 +145,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => {
},
});
if (location.pathname != '/stats') {
return <></>;
}
return (
<Card
key={channel.channel_id}
@ -154,11 +163,7 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => {
>
<Stack style={{ position: 'relative' }}>
<Group justify="space-between">
<img
src={channel.logo_url || logo}
width="30"
alt="channel logo"
/>
<img src={channel.logo_url || logo} width="30" alt="channel logo" />
<Group>
<Box>

View file

@ -7,22 +7,22 @@
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
background: linear-gradient(to right, #2d3748, #2d3748);
background: linear-gradient(to right, #2D2D2F, #1F1F20);
/* Default background */
color: #fff;
transition: all 0.2s ease-out;
}
.tv-guide .guide-program-container .guide-program.live {
background: linear-gradient(to right, #1e3a8a, #2c5282);
background: linear-gradient(to right, #3BA882, #245043);
}
.tv-guide .guide-program-container .guide-program.live:hover {
background: linear-gradient(to right, #1e3a8a, #2a4365);
background: linear-gradient(to right, #2E9E80, #206E5E);
}
.tv-guide .guide-program-container .guide-program.not-live:hover {
background: linear-gradient(to right, #2d3748, #1a202c);
background: linear-gradient(to right, #2F3F3A, #1E2926);
}
/* New styles for expanded programs */
@ -33,15 +33,15 @@
}
.tv-guide .guide-program-container .guide-program.expanded.live {
background: linear-gradient(to right, #1a365d, #1e40af);
background: linear-gradient(to right, #226F5D, #3BA882);
}
.tv-guide .guide-program-container .guide-program.expanded.not-live {
background: linear-gradient(to right, #1a365d, #1e3a8a);
background: linear-gradient(to right, #2C3F3A, #206E5E);
}
.tv-guide .guide-program-container .guide-program.expanded.past {
background: linear-gradient(to right, #2d3748, #4a5568);
background: linear-gradient(to right, #1F2423, #2F3A37);
}
/* Ensure channel logo is always on top */
@ -66,4 +66,4 @@
/* Make sure the main scrolling container establishes the right stacking context */
.tv-guide {
position: relative;
}
}

View file

@ -65,11 +65,11 @@ const usePlaylistsStore = create((set) => ({
// @TODO: remove playlist profiles here
})),
setRefreshProgress: (id, progress) =>
setRefreshProgress: (data) =>
set((state) => ({
refreshProgress: {
...state.refreshProgress,
[id]: progress,
[data.account]: data,
},
})),

View file

@ -51,3 +51,9 @@ export function useDebounce(value, delay = 500) {
return debouncedValue;
}
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View file

@ -2,4 +2,4 @@
Dispatcharr version information.
"""
__version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__build__ = '12' # Auto-incremented on builds
__build__ = '15' # Auto-incremented on builds