mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev
This commit is contained in:
commit
9c2ccb9c7e
27 changed files with 1486 additions and 1210 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -99,4 +99,4 @@ def version(request):
|
|||
return Response({
|
||||
'version': __version__,
|
||||
'build': __build__,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
1607
frontend/src/api.js
1607
frontend/src/api.js
File diff suppressed because it is too large
Load diff
|
|
@ -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 <></>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ const EPGsTable = () => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(40vh - 0px)',
|
||||
height: 'calc(40vh - 10px)',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})),
|
||||
|
||||
|
|
|
|||
|
|
@ -51,3 +51,9 @@ export function useDebounce(value, delay = 500) {
|
|||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue