diff --git a/backend/server/api.py b/backend/server/api.py index f0479ee..5e2a880 100644 --- a/backend/server/api.py +++ b/backend/server/api.py @@ -1,11 +1,10 @@ import logging import uvicorn -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from server import config from server.lib.streamer import Streamer from server.lib.xtream import XTream @@ -21,8 +20,6 @@ origins = [ "https://streams.fergl.ie", "http://127.0.0.1:35729", "http://localhost:35729", - "https://bitmovin.com", - "https://players.akamai.com", ] app.add_middleware( @@ -47,6 +44,22 @@ def __get_provider(request: Request): ) +@app.get("/validate") +async def validate_crendentials(request: Request, response: Response): + try: + provider = __get_provider(request) + categories = provider.get_categories().json() + if type(categories) is list: + return {"status": "accepted"} + except ValueError as e: + logger.error(e) + except Exception as e: + logger.error(e) + + response.status_code = 401 + return {"status": "denied"} + + @app.get("/channels") async def channels(request: Request): provider = __get_provider(request) diff --git a/backend/server/lib/xtream.py b/backend/server/lib/xtream.py index aaeaced..1b18ab5 100644 --- a/backend/server/lib/xtream.py +++ b/backend/server/lib/xtream.py @@ -18,6 +18,9 @@ class XTream: _cache = Cache() def __init__(self, server, username, password): + if not (server and username and password): + raise ValueError("XTream: must specify server, username and password") + self._server = server self._username = username self._password = password diff --git a/frontend/package.json b/frontend/package.json index b72d0cd..0a9b8a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "react-dom": "^17.0.2", "react-focus-lock": "^2.8.1", "react-helmet": "^6.1.0", + "react-hook-form": "^7.29.0", "react-icons": "^4.3.1", "react-router-dom": "6", "react-scripts": "5.0.0", diff --git a/frontend/public/images/unknown-stream.svg b/frontend/public/images/unknown-stream.svg new file mode 100644 index 0000000..1adf606 --- /dev/null +++ b/frontend/public/images/unknown-stream.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70bc1f3..746801e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,9 +8,12 @@ function App() { {localStorage.getItem("server") ? ( - } /> + <> + } /> + } /> + ) : ( - } /> + } /> )} diff --git a/frontend/src/assets/images/tv.png b/frontend/src/assets/images/tv.png new file mode 100644 index 0000000..4a75dc8 Binary files /dev/null and b/frontend/src/assets/images/tv.png differ diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 056bd5a..841ac6e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -3,5 +3,13 @@ import Navbar from "./navbar.component"; import Sidebar from "./sidebar/sidebar-content.component"; import EPGComponent from "./epg.component"; import ThemedSuspence from "./themed-suspence.component"; +import ServerDetails from "./server-details.component"; -export { Navbar, Sidebar, HLSPlayer, EPGComponent, ThemedSuspence }; +export { + Navbar, + Sidebar, + HLSPlayer, + EPGComponent, + ThemedSuspence, + ServerDetails, +}; diff --git a/frontend/src/components/server-details.component.tsx b/frontend/src/components/server-details.component.tsx new file mode 100644 index 0000000..0d3114e --- /dev/null +++ b/frontend/src/components/server-details.component.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Input, Label, Button, HelperText } from "./widgets"; +import { useForm } from "react-hook-form"; +import { BiRocket } from "react-icons/bi"; +import { ApiService } from "../services"; +import { toast } from "react-toastify"; + +const ServerDetails = () => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + const onSubmit = async (data: any) => { + console.log("server-details.component", "onSubmit", errors); + const validated = await ApiService.validateCredentials( + data.server, + data.username, + data.password + ); + if (validated) { + localStorage.setItem("server", JSON.stringify(data)); + window.location.reload(); + } else { + toast.error("FUCK YOU INVALID CREDENTIALS"); + } + }; + return ( +
+

+ XTream Codes Details +

+
+ + + + + + + +
+
+
+ ); +}; + +export default ServerDetails; diff --git a/frontend/src/components/sidebar/mobile-sidebar.component.tsx b/frontend/src/components/sidebar/mobile-sidebar.component.tsx index 5bacf8e..f06629a 100644 --- a/frontend/src/components/sidebar/mobile-sidebar.component.tsx +++ b/frontend/src/components/sidebar/mobile-sidebar.component.tsx @@ -1,14 +1,25 @@ +import { Transition } from "@headlessui/react"; import React from "react"; import { SidebarContext } from "../../context"; import SidebarContent from "./sidebar-content.component"; const MobileSidebar = () => { const { isSidebarOpen } = React.useContext(SidebarContext); - return isSidebarOpen ? ( - - ) : null; + return ( + + + + ); }; export default MobileSidebar; diff --git a/frontend/src/components/sidebar/sidebar-content.component.tsx b/frontend/src/components/sidebar/sidebar-content.component.tsx index 1f05483..eca672b 100644 --- a/frontend/src/components/sidebar/sidebar-content.component.tsx +++ b/frontend/src/components/sidebar/sidebar-content.component.tsx @@ -1,79 +1,80 @@ import React from "react"; -import { NavLink, Route } from "react-router-dom"; +import { NavLink } from "react-router-dom"; import { Channel } from "../../models/channel"; import { ApiService } from "../../services"; const SidebarContent = () => { const [channels, setChannels] = React.useState([]); - const [filteredChannels, setFilteredChannels] = React.useState([]); + // const [filteredChannels, setFilteredChannels] = React.useState([]); React.useEffect(() => { const fetchChannels = async () => { const res = await ApiService.getChannels(); if (res) { setChannels(res); - setFilteredChannels(res); } }; - fetchChannels().catch(console.error); + fetchChannels(); }, []); - const _searchChannels = ($event: React.ChangeEvent) => { - const searchString = $event.target.value; - if (searchString) { - const filteredChannels = channels.filter((c) => { - const result = c.category_name - .toLowerCase() - .includes(searchString.toLowerCase()); - console.log( - "sidebar.component", - `Category Name: ${c.category_name}`, - `Search String: ${searchString}` - ); - console.log("sidebar.component", "Result", result); - return result; - }); - setFilteredChannels(filteredChannels); - } else { - setFilteredChannels(channels); - } - }; + // const _searchChannels = ($event: React.ChangeEvent) => { + // const searchString = $event.target.value; + // if (searchString) { + // const filteredChannels = channels.filter((c) => { + // const result = c.category_name + // .toLowerCase() + // .includes(searchString.toLowerCase()); + // console.log( + // "sidebar.component", + // `Category Name: ${c.category_name}`, + // `Search String: ${searchString}` + // ); + // console.log("sidebar.component", "Result", result); + // return result; + // }); + // setFilteredChannels(filteredChannels); + // } else { + // setFilteredChannels(channels); + // } + // }; return ( -
- - Xtreamium - -
    - {filteredChannels.map((channel: Channel) => ( -
  • - - `inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200 ${ - isActive && "text-gray-800 dark:text-gray-100" - }` - } - children={({ isActive }) => { - return ( - <> - {isActive && ( - - )} - {/*
  • - ))} -
-
+ channels && ( +
+ + Xtreamium + +
    + {channels.map((channel: Channel) => ( +
  • + + `inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200 ${ + isActive && "text-gray-800 dark:text-gray-100" + }` + } + children={({ isActive }) => { + return ( + <> + {isActive && ( + + )} + {/*
  • + ))} +
+
+ ) ); }; diff --git a/frontend/src/components/widgets/button.component.tsx b/frontend/src/components/widgets/button.component.tsx index b5a6177..38a382f 100644 --- a/frontend/src/components/widgets/button.component.tsx +++ b/frontend/src/components/widgets/button.component.tsx @@ -65,12 +65,12 @@ const Button = React.forwardRef(function Button(props, ref) { return !!icon || !!iconLeft || !!iconRight; } - console.warn( - hasIcon() && !other["aria-label"] && !children, - "Button", - 'You are using an icon button, but no "aria-label" attribute was found. Add an "aria-label" attribute to work as a label for screen readers.' - ); - + if (hasIcon() && !other["aria-label"] && !children) { + console.warn( + "Button", + 'You are using an icon button, but no "aria-label" attribute was found. Add an "aria-label" attribute to work as a label for screen readers.' + ); + } const IconLeft = iconLeft || icon; const IconRight = iconRight; diff --git a/frontend/src/components/widgets/helper-text.component.tsx b/frontend/src/components/widgets/helper-text.component.tsx new file mode 100644 index 0000000..ad225af --- /dev/null +++ b/frontend/src/components/widgets/helper-text.component.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import classNames from "classnames"; +import { defaultTheme } from "../../constants"; + +export interface HelperTextProps extends React.HTMLAttributes { + /** + * Defines the color of the helper text (the same as with Input, Select, etc.) + */ + valid?: boolean; +} + +const HelperText = React.forwardRef( + function HelperText(props, ref) { + const { children, valid, className, ...other } = props; + const { helperText } = defaultTheme; + const baseStyle = helperText.base; + const validStyle = helperText.valid; + const invalidStyle = helperText.invalid; + + const validationStyle = (valid: boolean | undefined): string => { + switch (valid) { + case true: + return validStyle; + case false: + return invalidStyle; + default: + return ""; + } + }; + + const cls = classNames(baseStyle, validationStyle(valid), className); + + return ( + + {children} + + ); + } +); + +export default HelperText; diff --git a/frontend/src/components/widgets/image-fallback.component.tsx b/frontend/src/components/widgets/image-fallback.component.tsx new file mode 100644 index 0000000..32efd95 --- /dev/null +++ b/frontend/src/components/widgets/image-fallback.component.tsx @@ -0,0 +1,18 @@ +import React, { SyntheticEvent } from "react"; + +interface ImageWithFallbackProps + extends React.ImgHTMLAttributes { + src: string; + fallback: string; +} +const ImageWithFallback = ({ + fallback, + src, + ...props +}: ImageWithFallbackProps) => { + return ( + (e.target.src = fallback)} /> + ); +}; + +export default ImageWithFallback; diff --git a/frontend/src/components/widgets/index.ts b/frontend/src/components/widgets/index.ts index dd974bf..fd6ec66 100644 --- a/frontend/src/components/widgets/index.ts +++ b/frontend/src/components/widgets/index.ts @@ -2,7 +2,19 @@ import Avatar from "./avatar.component"; import Badge from "./badge.component"; import Button from "./button.component"; import { Dropdown, DropdownItem } from "./dropdown.component"; +import HelperText from "./helper-text.component"; +import ImageWithFallback from "./image-fallback.component"; import Input from "./input.component"; import Label from "./label.component"; -export { Avatar, Badge, Button, Input, Dropdown, DropdownItem, Label }; +export { + Avatar, + Badge, + Button, + Input, + Dropdown, + DropdownItem, + Label, + HelperText, + ImageWithFallback, +}; diff --git a/frontend/src/components/widgets/input.component.tsx b/frontend/src/components/widgets/input.component.tsx index 9d6aff4..3c02a9d 100644 --- a/frontend/src/components/widgets/input.component.tsx +++ b/frontend/src/components/widgets/input.component.tsx @@ -6,13 +6,23 @@ export interface InputProps extends React.ComponentPropsWithRef<"input"> { valid?: boolean; disabled?: boolean; type?: string; + formControlName?: string; + register?: any; } const Input = React.forwardRef(function Input( props, ref ) { - const { valid, disabled, className, type = "text", ...other } = props; + const { + valid, + disabled, + className, + type = "text", + formControlName, + register, + ...other + } = props; const { input } = defaultTheme; const baseStyle = input.base; diff --git a/frontend/src/containers/layout.container.tsx b/frontend/src/containers/layout.container.tsx index a09bfec..40a44db 100644 --- a/frontend/src/containers/layout.container.tsx +++ b/frontend/src/containers/layout.container.tsx @@ -2,7 +2,7 @@ import React, { Suspense } from "react"; import { useLocation, Routes, Route } from "react-router-dom"; import Header from "../components/header.component"; import Main from "./main.container"; -import { ChannelPage, PlayerPage } from "../pages"; +import { ChannelPage, HomePage, PlayerPage } from "../pages"; import ThemedSuspence from "../components/themed-suspence.component"; import { SidebarContext } from "../context"; import Sidebar from "../components/sidebar"; @@ -28,6 +28,7 @@ const Layout = () => { } /> } /> + } /> diff --git a/frontend/src/containers/main.container.tsx b/frontend/src/containers/main.container.tsx index 2e35f2d..04f386c 100644 --- a/frontend/src/containers/main.container.tsx +++ b/frontend/src/containers/main.container.tsx @@ -5,7 +5,7 @@ interface IMainProps { } const Main = ({ children }: IMainProps) => { return ( -
+
{children}
); diff --git a/frontend/src/index.css b/frontend/src/index.css index 9a1a29d..5ed3df8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,3 +3,50 @@ @tailwind base; @tailwind components; @tailwind utilities; + +html { + scrollbar-width: thin; +} + +.scroller:hover::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.scroller:hover::-webkit-scrollbar-thumb, +#aside.scroller:hover::-webkit-scrollbar-thumb { + @apply rounded; +} + +html { + @apply overflow-auto; + scrollbar-color: #9ca3af #e5e7eb; +} + +.scroller:hover::-webkit-scrollbar-track { + @apply bg-gray-200; +} + +.scroller:hover::-webkit-scrollbar-thumb { + @apply bg-gray-400; +} + +.scroller:hover::-webkit-scrollbar-thumb:hover { + @apply bg-gray-500; +} + +html.dark { + scrollbar-color: #374151 #111827; +} + +html.dark.scroller:hover::-webkit-scrollbar-track { + @apply bg-gray-900; +} + +html.dark.scroller:hover::-webkit-scrollbar-thumb { + @apply bg-gray-700; +} + +html.dark.scroller:hover::-webkit-scrollbar-thumb:hover { + @apply bg-gray-600; +} diff --git a/frontend/src/pages/channel.page.tsx b/frontend/src/pages/channel.page.tsx index 8d2fac7..6624a35 100644 --- a/frontend/src/pages/channel.page.tsx +++ b/frontend/src/pages/channel.page.tsx @@ -4,12 +4,7 @@ import { AiOutlinePlayCircle } from "react-icons/ai"; import { Stream } from "../models/stream"; import { convertEpochToSpecificTimezone } from "../utils/date-utils"; import { EPGComponent } from "../components"; -import { - CastButton, - CastProvider, - useCast, - useMedia, -} from "../utils/chromecast"; + import { toast } from "react-toastify"; import { Table, @@ -19,16 +14,13 @@ import { TableHeader, TableRow, } from "../components/widgets/table"; -import { Avatar, Badge, Button } from "../components/widgets"; +import { Badge, Button, ImageWithFallback } from "../components/widgets"; import { ApiService } from "../services"; const ChannelPage = () => { let params = useParams(); - const cast = useCast(); - const media = useMedia(); const [streams, setStreams] = React.useState([]); - const [currentVideoUrl, setCurrentVideoUrl] = React.useState(""); React.useEffect(() => { const fetchChannels = async () => { @@ -41,21 +33,6 @@ const ChannelPage = () => { fetchChannels().catch(console.error); }, [params.channelId]); - - - const _cast = React.useCallback( - async (streamId: number) => { - const streamUrl = await ApiService.getStreamUrl(streamId); - if (streamUrl) { - await media.playMedia(streamUrl); - } - }, - [media] - ); - - const handleXHR = (...args: any[]) => { - console.log("channel.page", "handleXHR", args); - }; const playStream = async (streamId: number) => { const url = await ApiService.getStreamUrl(streamId); if (url) { @@ -125,63 +102,61 @@ const ChannelPage = () => { } }; return ( - - - - - - Channel - Type - - - - - {streams.map((stream: Stream) => [ - - -
- -
-

{stream.name}

-

- Added: {convertEpochToSpecificTimezone(stream.added)} -

-
+ +
+ + + Channel + Type + + + + + {streams.map((stream: Stream) => [ + + +
+ +
+

{stream.name}

+

+ Added: {convertEpochToSpecificTimezone(stream.added)} +

- - - {stream.stream_type} - - -
- - -
-
- , -
- {false && ( - - )} - , - ])} - -
- Loading epg}> - - -
-
-
+ + + + {stream.stream_type} + + +
+ +
+
+ , + + {false && ( + + Loading epg}> + + + + )} + , + ])} + + + ); }; diff --git a/frontend/src/pages/onboarding.page.tsx b/frontend/src/pages/onboarding.page.tsx index 50ddb63..0d03144 100644 --- a/frontend/src/pages/onboarding.page.tsx +++ b/frontend/src/pages/onboarding.page.tsx @@ -1,8 +1,6 @@ -import React from "react"; import ImageLight from "../assets/images/love-tv.jpg"; -import { Button, Input, Label } from "../components/widgets"; +import ServerDetails from "../components/server-details.component"; const OnboardingPage = () => { - const _setupOnboarding = () => {}; return (
@@ -22,34 +20,7 @@ const OnboardingPage = () => { />
-
-

- Login -

- - - - - - -
-
+
diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index e42ef68..3fbc4ab 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -1,8 +1,31 @@ import http from "./http.service"; import { Channel } from "../models/channel"; import { Stream } from "../models/stream"; +import axios from "axios"; class ApiService { + public validateCredentials = async ( + server: string, + username: string, + password: string + ): Promise => { + const client = axios.create({ + baseURL: process.env.REACT_APP_API_URL, + headers: { + "Content-type": "application/json", + "x-xtream-server": server, + "x-xtream-username": username, + "x-xtream-password": password, + }, + }); + try { + const res = await client.get("/validate"); + return res.status === 200; + } catch { + return false; + } + }; + public getChannels = async (): Promise => { const response = await http.get("/channels"); return response.data; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index dbca9d6..ac64163 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7149,6 +7149,11 @@ react-helmet@^6.1.0: react-fast-compare "^3.1.1" react-side-effect "^2.1.0" +react-hook-form@^7.29.0: + version "7.29.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.29.0.tgz#5e7e41a483b70731720966ed8be52163ea1fecf1" + integrity sha512-NcJqWRF6el5HMW30fqZRt27s+lorvlCCDbTpAyHoodQeYWXgQCvZJJQLC1kRMKdrJknVH0NIg3At6TUzlZJFOQ== + react-icons@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca"