Guide Update

Updated guide style
Added Floating Video
Added mpegts.js
This commit is contained in:
Dispatcharr 2025-02-28 16:02:52 -06:00
parent a4cd184a36
commit 31d26fddee
9 changed files with 351 additions and 192 deletions

View file

@ -7,7 +7,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData # Updated: use ProgramData instead of Program
from .models import EPGSource, ProgramData # Using ProgramData
from .serializers import ProgramDataSerializer, EPGSourceSerializer # Updated serializer
from .tasks import refresh_epg_data
@ -31,8 +31,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
# ─────────────────────────────
class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs"""
queryset = ProgramData.objects.all() # Updated to ProgramData
serializer_class = ProgramDataSerializer # Updated serializer
queryset = ProgramData.objects.all()
serializer_class = ProgramDataSerializer
permission_classes = [IsAuthenticated]
def list(self, request, *args, **kwargs):
@ -52,11 +52,10 @@ class EPGGridAPIView(APIView):
def get(self, request, format=None):
# Get current date and reset time to midnight (00:00)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
twelve_hours_later = now + timedelta(hours=24)
logger.debug(f"EPGGridAPIView: Querying programs between {now} and {twelve_hours_later}.")
# Use select_related to prefetch EPGData and Channel data
programs = ProgramData.objects.select_related('epg__channel').filter(
# Use select_related to prefetch EPGData (no channel relation now)
programs = ProgramData.objects.select_related('epg').filter(
start_time__gte=now, start_time__lte=twelve_hours_later
)
count = programs.count()

View file

@ -1,27 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-27 20:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='epgdata',
name='channel',
),
migrations.AddField(
model_name='epgdata',
name='tvg_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='programdata',
name='tvg_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

View file

@ -18,10 +18,12 @@
"eslint": "^8.57.1",
"formik": "^2.4.6",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-draggable": "4.4.6",
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
@ -3776,6 +3778,66 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.11.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@ -3866,7 +3928,8 @@
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "8.56.12",
@ -5761,6 +5824,15 @@
"wrap-ansi": "^7.0.0"
}
},
"node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -6740,6 +6812,7 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
@ -7123,6 +7196,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -11099,66 +11178,6 @@
"react-dom": ">=18.0"
}
},
"node_modules/material-react-table/node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/material-react-table/node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.11.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/material-react-table/node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/material-react-table/node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -11338,6 +11357,16 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mpegts.js": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz",
"integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==",
"license": "Apache-2.0",
"dependencies": {
"es6-promise": "^4.2.5",
"webworkify-webpack": "github:xqq/webworkify-webpack"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -13503,9 +13532,13 @@
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@ -13631,14 +13664,30 @@
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^19.0.0"
"react": "^18.2.0"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-error-overlay": {
@ -13668,6 +13717,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.2.0.tgz",
"integrity": "sha512-fXyqzPgCPZbqhrk7k3hPcCpYIlQ2ugIXDboHUzhJISFVy2DEPsmHgN588MyGmkIOv3jDgNfUE3kJi83L28s/LQ==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
@ -13706,6 +13756,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
@ -13786,6 +13837,7 @@
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
@ -14364,9 +14416,13 @@
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/schema-utils": {
"version": "4.3.0",
@ -14584,7 +14640,8 @@
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
@ -15870,7 +15927,8 @@
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
@ -16533,6 +16591,11 @@
"node": ">=0.8.0"
}
},
"node_modules/webworkify-webpack": {
"version": "2.1.5",
"resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef",
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",

View file

@ -13,10 +13,12 @@
"eslint": "^8.57.1",
"formik": "^2.4.6",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-draggable": "4.4.6",
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",

View file

@ -1,3 +1,4 @@
// frontend/src/App.js
import React, { useEffect, useState } from 'react';
import {
BrowserRouter as Router,
@ -9,7 +10,7 @@ import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import { ThemeProvider } from '@mui/material/styles'; // Import theme tools
import { ThemeProvider } from '@mui/material/styles';
import {
Box,
CssBaseline,
@ -27,6 +28,9 @@ import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import logo from './images/logo.png';
// NEW: import the floating PiP component
import FloatingVideo from './components/FloatingVideo';
const drawerWidth = 240;
const miniDrawerWidth = 60;
@ -49,7 +53,6 @@ const App = () => {
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
@ -57,7 +60,6 @@ const App = () => {
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
@ -65,18 +67,6 @@ const App = () => {
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
{/* <AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
width: `calc(100% - ${open ? drawerWidth : miniDrawerWidth}px)`,
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
}}
>
<Toolbar variant="dense"></Toolbar>
</AppBar> */}
<Drawer
variant="permanent"
open={open}
@ -90,7 +80,6 @@ const App = () => {
},
}}
>
{/* Drawer Toggle Button */}
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton
@ -101,7 +90,7 @@ const App = () => {
pb: 0,
}}
>
<img src={logo} width="33x" />
<img src={logo} width="33x" alt="logo" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
@ -122,15 +111,8 @@ const App = () => {
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
// pt: '64px',
}}
>
{/* Fixed Header */}
{/* <Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
</Box> */}
{/* Main Content Area between Header and Footer */}
<Box
sx={{
flex: 1,
@ -142,20 +124,18 @@ const App = () => {
<Routes>
{isAuthenticated ? (
<>
<Route exact path="/channels" element={<Channels />} />
<Route exact path="/m3u" element={<M3U />} />
<Route exact path="/epg" element={<EPG />} />
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
exact
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route exact path="/guide" element={<Guide />} />
<Route path="/guide" element={<Guide />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
{/* Redirect if no match */}
<Route
path="*"
element={
@ -169,6 +149,9 @@ const App = () => {
</Box>
</Box>
</Router>
{/* Always-available floating video; remains visible across page changes */}
<FloatingVideo />
</ThemeProvider>
);
};

View file

@ -0,0 +1,92 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useRef } from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
export default function FloatingVideo() {
const { isVisible, streamUrl, hideVideo } = useVideoStore();
const videoRef = useRef(null);
const playerRef = useRef(null);
useEffect(() => {
if (!isVisible || !streamUrl) {
return;
}
// If the browser supports MSE for live playback, initialize mpegts.js
if (mpegts.getFeatureList().mseLivePlayback) {
const player = mpegts.createPlayer({
type: 'mpegts',
url: streamUrl,
isLive: true,
// You can include other custom MPEGTS.js config fields here, e.g.:
// cors: true,
// withCredentials: false,
});
player.attachMediaElement(videoRef.current);
player.load();
player.play();
// Store player instance so we can clean up later
playerRef.current = player;
}
// Cleanup when component unmounts or streamUrl changes
return () => {
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
};
}, [isVisible, streamUrl]);
// If the floating video is hidden or no URL is selected, do not render
if (!isVisible || !streamUrl) {
return null;
}
return (
<Draggable>
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
width: '320px',
zIndex: 9999,
backgroundColor: '#333',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
}}
>
{/* Simple header row with a close button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
<button
onClick={hideVideo}
style={{
background: 'red',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
padding: '2px 8px',
}}
>
X
</button>
</div>
{/* The <video> element used by mpegts.js */}
<video
ref={videoRef}
controls
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
/>
</div>
</Draggable>
);
}

View file

@ -1,3 +1,4 @@
// frontend/src/pages/Guide.js
import React, { useMemo, useState, useEffect, useRef } from 'react';
import {
Box,
@ -15,16 +16,16 @@ import dayjs from 'dayjs';
import API from '../api';
import useChannelsStore from '../store/channels';
import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
const HOUR_WIDTH = 300; // The width for a 1-hour block
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
// => 300 / 4 = 75px for each 15-minute block
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
// Modal size constants (all modals will be the same size)
// Modal size constants
const MODAL_WIDTH = 600;
const MODAL_HEIGHT = 400;
@ -33,17 +34,17 @@ const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
const TVChannelGuide = ({ startDate, endDate }) => {
export default function TVChannelGuide({ startDate, endDate }) {
const { channels } = useChannelsStore();
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
const [now, setNow] = useState(dayjs());
// State for selected program to display in modal
const [selectedProgram, setSelectedProgram] = useState(null);
const guideRef = useRef(null);
// Load program data once
useEffect(() => {
if (!channels || channels.length === 0) {
console.warn('No channels provided or empty channels array');
@ -52,47 +53,48 @@ const TVChannelGuide = ({ startDate, endDate }) => {
const fetchPrograms = async () => {
console.log('Fetching program grid...');
const fetchedPrograms = await API.getGrid();
console.log(`Received ${fetchedPrograms.length} programs`);
const fetched = await API.getGrid(); // GETs your EPG grid
console.log(`Received ${fetched.length} programs`);
// Get unique tvg_ids from the returned programs
const programIds = [...new Set(fetchedPrograms.map((prog) => prog.tvg_id))];
// Unique tvg_ids from returned programs
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
// Filter channels to only those that appear in the program list
// Filter your Redux/Zustand channels by matching tvg_id
const filteredChannels = channels.filter((ch) =>
programIds.includes(ch.tvg_id)
);
console.log(`found ${filteredChannels.length} channels with matching tvg-ids`);
console.log(
`found ${filteredChannels.length} channels with matching tvg_ids`
);
setGuideChannels(filteredChannels);
setPrograms(fetchedPrograms);
setPrograms(fetched);
};
fetchPrograms();
}, [channels]);
// Default to "today at midnight" -> +24h if not provided
// Use start/end from props or default to "today at midnight" +24h
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
// Find earliest program start and latest program end to expand timeline if needed.
// Expand timeline if needed based on actual earliest/ latest program
const earliestProgramStart = useMemo(() => {
if (!programs.length) return defaultStart;
return programs.reduce((acc, p) => {
const progStart = dayjs(p.start_time);
return progStart.isBefore(acc) ? progStart : acc;
const s = dayjs(p.start_time);
return s.isBefore(acc) ? s : acc;
}, defaultStart);
}, [programs, defaultStart]);
const latestProgramEnd = useMemo(() => {
if (!programs.length) return defaultEnd;
return programs.reduce((acc, p) => {
const progEnd = dayjs(p.end_time);
return progEnd.isAfter(acc) ? progEnd : acc;
const e = dayjs(p.end_time);
return e.isAfter(acc) ? e : acc;
}, defaultEnd);
}, [programs, defaultEnd]);
// Timeline boundaries: use expanded timeline if needed
const start = earliestProgramStart.isBefore(defaultStart)
? earliestProgramStart
: defaultStart;
@ -100,9 +102,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
? latestProgramEnd
: defaultEnd;
/**
* For program positioning calculations: we step in 15-min increments.
*/
// Time increments in 15-min steps (for placing programs)
const programTimeline = useMemo(() => {
const times = [];
let current = start;
@ -113,9 +113,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
return times;
}, [start, end]);
/**
* For the visible timeline at the top: hourly blocks with 4 sub-lines.
*/
// Hourly marks
const hourTimeline = useMemo(() => {
const hours = [];
let current = start;
@ -126,7 +124,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
return hours;
}, [start, end]);
// Scroll to "now" position on load
// Scroll to "now" on load
useEffect(() => {
if (guideRef.current) {
const nowOffset = dayjs().diff(start, 'minute');
@ -136,7 +134,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
}
}, [programs, start]);
// Update "now" every minute
// Update “now” every 60s
useEffect(() => {
const interval = setInterval(() => {
setNow(dayjs());
@ -144,37 +142,57 @@ const TVChannelGuide = ({ startDate, endDate }) => {
return () => clearInterval(interval);
}, []);
// Calculate pixel offset for the "now" line
// Pixel offset for the “now” vertical line
const nowPosition = useMemo(() => {
if (now.isBefore(start) || now.isAfter(end)) return -1;
const minutesSinceStart = now.diff(start, 'minute');
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
}, [now, start, end]);
/** Handle program click: scroll program into view and open modal */
const handleProgramClick = (program, event) => {
// Scroll clicked element into center view
event.currentTarget.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
setSelectedProgram(program);
};
// Helper: find channel by tvg_id
function findChannelByTvgId(tvgId) {
return guideChannels.find((ch) => ch.tvg_id === tvgId);
}
/** Close modal */
const handleCloseModal = () => {
// The “Watch Now” click => show floating video
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
function handleWatchStream(program) {
const matched = findChannelByTvgId(program.tvg_id);
if (!matched) {
console.warn(`No channel found for tvg_id=${program.tvg_id}`);
return;
}
// Build a playable stream URL for that channel
const url = window.location.origin + '/output/stream/' + matched.id;
showVideo(url);
// Optionally close the modal
setSelectedProgram(null);
};
}
/** Render each program block as clickable, opening modal on click */
const renderProgram = (program, channelStart) => {
// On program click, open the details modal
function handleProgramClick(program, event) {
// Optionally scroll that element into view or do something else
event.currentTarget.scrollIntoView({ behavior: 'smooth', inline: 'center' });
setSelectedProgram(program);
}
// Close the modal
function handleCloseModal() {
setSelectedProgram(null);
}
// Renders each program block
function renderProgram(program, channelStart) {
const programKey = `${program.tvg_id}-${program.start_time}`;
const programStart = dayjs(program.start_time);
const programEnd = dayjs(program.end_time);
const startOffsetMinutes = programStart.diff(channelStart, 'minute');
const durationMinutes = programEnd.diff(programStart, 'minute');
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
// Highlight if currently live
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
return (
@ -223,7 +241,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
</Paper>
</Box>
);
};
}
return (
<Box
@ -375,7 +393,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
</Box>
</Box>
{/* Now-position line */}
{/* Now line */}
<Box sx={{ position: 'relative' }}>
{nowPosition >= 0 && (
<Box
@ -391,7 +409,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
/>
)}
{/* Channel rows with program blocks */}
{/* Channel rows */}
{guideChannels.map((channel) => {
const channelPrograms = programs.filter(
(p) => p.tvg_id === channel.tvg_id
@ -407,9 +425,7 @@ const TVChannelGuide = ({ startDate, endDate }) => {
}}
>
<Box sx={{ flex: 1, position: 'relative' }}>
{channelPrograms.map((program) =>
renderProgram(program, start)
)}
{channelPrograms.map((prog) => renderProgram(prog, start))}
</Box>
</Box>
);
@ -447,13 +463,24 @@ const TVChannelGuide = ({ startDate, endDate }) => {
</DialogTitle>
<DialogContent sx={{ color: '#a0aec0' }}>
<Typography variant="caption" display="block">
{dayjs(selectedProgram.start_time).format('h:mma')} - {dayjs(selectedProgram.end_time).format('h:mma')}
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
{dayjs(selectedProgram.end_time).format('h:mma')}
</Typography>
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
{selectedProgram.description || 'No description available.'}
</Typography>
</DialogContent>
<DialogActions>
{/* Only show the Watch button if currently live */}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Button
onClick={() => handleWatchStream(selectedProgram)}
sx={{ color: '#38b2ac' }}
>
Watch Now
</Button>
)}
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
Close
</Button>
@ -463,6 +490,4 @@ const TVChannelGuide = ({ startDate, endDate }) => {
</Dialog>
</Box>
);
};
export default TVChannelGuide;
}

View file

@ -0,0 +1,22 @@
// frontend/src/store/useVideoStore.js
import { create } from 'zustand';
/**
* Global store to track whether a floating video is visible and which URL is playing.
*/
const useVideoStore = create((set) => ({
isVisible: false,
streamUrl: null,
showVideo: (url) => set({
isVisible: true,
streamUrl: url,
}),
hideVideo: () => set({
isVisible: false,
streamUrl: null,
}),
}));
export default useVideoStore;