diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 7d648dc1..4b324a5f 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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() diff --git a/apps/epg/migrations/0002_remove_epgdata_channel_epgdata_tvg_id_and_more.py b/apps/epg/migrations/0002_remove_epgdata_channel_epgdata_tvg_id_and_more.py deleted file mode 100644 index f6b31564..00000000 --- a/apps/epg/migrations/0002_remove_epgdata_channel_epgdata_tvg_id_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0a8b867..9d31e38d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index de62f131..d4ad6a43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.js b/frontend/src/App.js index 0220e64b..f63c44fa 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 = () => { - {/* theme.zIndex.drawer + 1, - width: `calc(100% - ${open ? drawerWidth : miniDrawerWidth}px)`, - ml: `${open ? drawerWidth : miniDrawerWidth}px`, - transition: 'width 0.3s, margin-left 0.3s', - }} - > - - */} - { }, }} > - {/* Drawer Toggle Button */} { pb: 0, }} > - + {open && ( )} @@ -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 */} - {/* - - */} - - {/* Main Content Area between Header and Footer */} { {isAuthenticated ? ( <> - } /> - } /> - } /> + } /> + } /> + } /> } /> - } /> + } /> > ) : ( } /> )} - {/* Redirect if no match */} { + + {/* Always-available floating video; remains visible across page changes */} + ); }; diff --git a/frontend/src/components/FloatingVideo.js b/frontend/src/components/FloatingVideo.js new file mode 100644 index 00000000..0284c745 --- /dev/null +++ b/frontend/src/components/FloatingVideo.js @@ -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 ( + + + {/* Simple header row with a close button */} + + + X + + + + {/* The element used by mpegts.js */} + + + + ); +} diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js index 7692527e..16ea182d 100644 --- a/frontend/src/pages/Guide.js +++ b/frontend/src/pages/Guide.js @@ -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 ; }); -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 }) => { ); - }; + } return ( { - {/* Now-position line */} + {/* Now line */} {nowPosition >= 0 && ( { /> )} - {/* 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 }) => { }} > - {channelPrograms.map((program) => - renderProgram(program, start) - )} + {channelPrograms.map((prog) => renderProgram(prog, start))} ); @@ -447,13 +463,24 @@ const TVChannelGuide = ({ startDate, endDate }) => { - {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')} {selectedProgram.description || 'No description available.'} + {/* Only show the Watch button if currently live */} + {now.isAfter(dayjs(selectedProgram.start_time)) && + now.isBefore(dayjs(selectedProgram.end_time)) && ( + handleWatchStream(selectedProgram)} + sx={{ color: '#38b2ac' }} + > + Watch Now + + )} Close @@ -463,6 +490,4 @@ const TVChannelGuide = ({ startDate, endDate }) => { ); -}; - -export default TVChannelGuide; +} diff --git a/frontend/src/store/useVideoStore.js b/frontend/src/store/useVideoStore.js new file mode 100644 index 00000000..5667922d --- /dev/null +++ b/frontend/src/store/useVideoStore.js @@ -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;