Initial commit

This commit is contained in:
Fergal Moran
2022-04-05 11:49:57 +01:00
parent 424cdbc02f
commit f2cde38a0f
55 changed files with 10255 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
files
certs
frontend/.env.production

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.idea
app/config.py

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,6 @@
provider = dict(
name="<name>",
server="http://<server>",
username="<username>",
password="<password>"
)

View File

2
backend/app/lib/cache.py Normal file
View File

@@ -0,0 +1,2 @@
class Cache:
authData = {}

3
backend/app/lib/mpv.py Normal file
View File

@@ -0,0 +1,3 @@
class MPVPlayer:
pass

View File

@@ -0,0 +1,19 @@
from httpx import AsyncClient
import ffmpeg_streaming
class Streamer:
@staticmethod
async def receive_file(url):
async with AsyncClient(follow_redirects=True) as client:
response = await client.get(url)
async for dataBytes in response.aiter_bytes():
yield dataBytes
@staticmethod
async def receive_stream(url):
async with AsyncClient(follow_redirects=True) as client:
async with client.stream('GET', url) as response:
async for dataBytes in response.aiter_bytes():
yield dataBytes

84
backend/app/lib/xtream.py Normal file
View File

@@ -0,0 +1,84 @@
from enum import Enum
from app.lib.cache import Cache
import requests
class UrlNotCreatedException(Exception):
pass
class StreamType(Enum):
LIVE = "LIVE",
VOD = "VOD",
SERIES = "SERIES"
class XTream:
_cache = Cache()
def __init__(self, server, username, password):
self._server = server
self._username = username
self._password = password
self._interface = self._authenticate()
def _authenticate(self):
r = requests.get(self.__get_authenticate_url())
self._cache.authData = r.json()
return r
# TODO: use f strings
def __get_authenticate_url(self):
url = '%s/player_api.php?username=%s&password=%s' % (self._server, self._username, self._password)
return url
def __get_live_categories_url(self):
url = '%s/player_api.php?username=%s&password=%s&action=%s' % (
self._server, self._username, self._password, 'get_live_categories')
return url
def __get_live_streams_url(self):
url = '%s/player_api.php?username=%s&password=%s&action=%s' % (
self._server, self._username, self._password, 'get_live_categories')
return url
def __get_live_streams_by_category_url(self, category_id):
url = '%s/player_api.php?username=%s&password=%s&action=%s&category_id=%s' % (
self._server, self._username, self._password, 'get_live_streams', category_id)
return url
def get_categories(self, stream_type=StreamType.LIVE):
url = ""
if stream_type == StreamType.LIVE:
url = self.__get_live_categories_url()
# elif stream_type == StreamType.VOD:
# url = get_vod_cat_url()
# elif stream_type == StreamType.SERIES:
# url = get_series_cat_url()
if url == "":
raise UrlNotCreatedException("Unable to create URL")
r = requests.get(url)
return r
def get_streams_for_category(self, category_id, stream_type=StreamType.LIVE):
url = ""
if stream_type == StreamType.LIVE:
url = self.__get_live_streams_by_category_url(category_id)
if url == "":
raise UrlNotCreatedException("Unable to create URL")
r = requests.get(url)
return r
def get_live_stream_url(self, stream_id):
return f"{self._server}/live/{self._username}/{self._password}/{stream_id}.ts"
def get_vod_stream_url(self, stream_id):
return f"{self._server}/movie/{self._username}/{self._password}/{stream_id}.ts"
def get_series_stream_url(self, stream_id):
return f"{self._server}/series/{self._username}/{self._password}/{stream_id}.ts"

68
backend/app/main.py Normal file
View File

@@ -0,0 +1,68 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from . import config
from .lib.streamer import Streamer
from .lib.xtream import XTream
provider = XTream(
config.provider['server'],
config.provider['username'],
config.provider['password']
)
app = FastAPI()
origins = [
"https://dev-streams.fergl.ie:3000",
"https://streams.fergl.ie",
"http://127.0.0.1:35729",
"http://localhost:35729",
"https://bitmovin.com",
"https://players.akamai.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/channels")
async def channels():
categories = provider.get_categories()
return categories.json()
@app.get("/streams/{category_id}")
async def read_item(category_id):
streams = provider.get_streams_for_category(category_id)
return streams.json()
@app.get("/live/stream/{stream_id}")
async def get_live_stream(stream_id: str):
url = provider.get_live_stream_url(stream_id)
return StreamingResponse(Streamer.receive_stream(url), media_type="video/mp2t")
@app.get("/live/stream/url/{stream_id}")
async def get_live_stream(stream_id: str):
url = provider.get_live_stream_url(stream_id)
return {
"url": url
}
if __name__ == '__main__':
uvicorn.run("api:app",
host="0.0.0.0",
port=8000,
reload=True,
ssl_keyfile="/etc/letsencrypt/live/fergl.ie/privkey.pem",
ssl_certfile="/etc/letsencrypt/live/fergl.ie/fullchain.pem"
)

View File

@@ -0,0 +1,7 @@
FROM python:3.9
WORKDIR /code
COPY ../requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ../app /code/app
CMD ["uvicorn", "app.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"]

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
requests~=2.27.1
fastapi~=0.75.0
httpx~=0.22.0
python-ffmpeg-video-streaming
uvicorn~=0.17.6

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
docker context use default
docker build \
-t fergalmoran/xtream-backend \
-f docker/Dockerfile .
docker push fergalmoran/xtream-backend

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
./node_modules
./dist
./vite.config.ts

9
frontend/.env Normal file
View File

@@ -0,0 +1,9 @@
BROWSER=none
HTTPS=true
SSL_CRT_FILE=/etc/letsencrypt/live/fergl.ie/cert.pem
SSL_KEY_FILE=/etc/letsencrypt/live/fergl.ie/privkey.pem
_REACT_APP_API_URL=https://streams.fergl.ie:8000
REACT_APP_API_URL=https://api.streams.fergl.ie
REACT_APP_SERVER_URL=http://localhost:9531

25
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
env.production
.env.production

19
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# pull official base image
FROM node:14-alpine
# set working directory
WORKDIR /app
# add `/app/node_modules/.bin` to $PATH
ENV PATH /app/node_modules/.bin:$PATH
# install app dependencies
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
# add app
COPY . ./
# start app
CMD ["yarn", "start"]

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

View File

@@ -0,0 +1,22 @@
FROM node:17-buster as builder
ARG RUN_MODE=production
WORKDIR /app
COPY package.json package.json
COPY yarn.lock yarn.lock
COPY .env.${RUN_MODE} .env
RUN yarn install
COPY . .
ENV NODE_ENV=$RUN_MODE
RUN yarn build --mode $RUN_MODE
FROM nginx:alpine
ENV NODE_ENV=$RUN_MODE
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /usr/share/nginx/html
COPY --from=builder /app/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD [ "/bin/sh", "-c", "nginx -g 'daemon off;'" ]

View File

@@ -0,0 +1,12 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

51
frontend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "xtream-player",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.2.1",
"autoprefixer": "^10.4.4",
"hls.js": "^1.1.5",
"postcss": "^8.4.12",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "6",
"react-scripts": "5.0.0",
"tailwindcss": "^3.0.23",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
},
"devDependencies": {
"@types/hls.js": "^1.0.0",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
style="enable-background:new 0 0 512 512;"
xml:space="preserve">
<circle style="fill:#1FCFC1;"
cx="256"
cy="256"
r="245.801"/>
<polygon style="fill:#F2F2F2;"
points="195.825,391.629 376.351,256 195.825,120.371 "/>
<g>
<path style="fill:#4D4D4D;"
d="M256,512c-68.381,0-132.667-26.628-181.019-74.98C26.628,388.667,0,324.38,0,256
S26.628,123.333,74.981,74.98C123.333,26.628,187.619,0,256,0s132.667,26.628,181.019,74.98C485.372,123.333,512,187.62,512,256
s-26.628,132.667-74.981,181.02C388.667,485.372,324.381,512,256,512z M256,20.398C126.089,20.398,20.398,126.089,20.398,256
S126.089,491.602,256,491.602S491.602,385.911,491.602,256S385.911,20.398,256,20.398z"/>
<path style="fill:#4D4D4D;"
d="M195.824,401.828c-1.553,0-3.115-0.355-4.557-1.075c-3.458-1.727-5.641-5.26-5.641-9.124V120.371
c0-3.864,2.185-7.397,5.641-9.124c3.458-1.726,7.593-1.351,10.685,0.97l180.526,135.629c2.564,1.927,4.073,4.948,4.073,8.154
s-1.508,6.228-4.073,8.154L201.951,399.783C200.15,401.137,197.994,401.828,195.824,401.828z M206.024,140.791v230.418L359.371,256
L206.024,140.791z"/>
<path style="fill:#4D4D4D;"
d="M256,473.243c-5.632,0-10.199-4.566-10.199-10.199c0-5.633,4.567-10.199,10.199-10.199
c52.815,0,102.404-20.633,139.633-58.1c3.973-3.996,10.429-4.015,14.425-0.045c3.995,3.971,4.016,10.428,0.046,14.424
C369.016,450.471,314.287,473.243,256,473.243z"/>
<path style="fill:#4D4D4D;"
d="M430.396,377.825c-1.886,0-3.793-0.522-5.498-1.617c-4.741-3.041-6.118-9.351-3.076-14.092
c1.514-2.36,2.998-4.788,4.411-7.216c2.834-4.867,9.077-6.516,13.945-3.684c4.868,2.833,6.518,9.077,3.684,13.945
c-1.56,2.681-3.201,5.363-4.873,7.97C437.043,376.168,433.754,377.825,430.396,377.825z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon"
href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport"
content="width=device-width, initial-scale=1" />
<meta name="theme-color"
content="#000000" />
<meta name="description"
content="Web site created using create-react-app" />
<link rel="apple-touch-icon"
href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest"
href="%PUBLIC_URL%/manifest.json" />
<title>Xtream Player</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

137
frontend/public/test.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Title</title>
<!-- #region CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
crossorigin="anonymous">
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
rel="stylesheet"
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
crossorigin="anonymous">
<style type="text/css">
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}
body>.container-fluid,
body>.container {
padding: 60px 15px 0;
}
.footer>.container-fluid,
.footer>.container {
padding-right: 15px;
padding-left: 15px;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: 60px;
line-height: 60px;
background-color: #f5f5f5;
}
</style>
<!-- #endregion -->
</head>
<body>
<!-- #region Header -->
<header>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand"
href="#">Title</a>
<button class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarCollapse"
aria-controls="navbarCollapse"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse"
id="navbarCollapse">
<ul class="mr-auto navbar-nav">
<li class="nav-item active">
<a class="nav-link"
href="#">Home
<span class="sr-only">(current)</span>
</a>
</li>
</ul>
</div>
</nav>
</header>
<!-- #endregion -->
<!-- #region Main Content -->
<main role="main"
class="container">
<h1 class="mt-5">Title</h1>
<p class="lead">
<video height="600"
src="https://streams.fergl.ie:8000/live/stream/175541"
id="video"
controls></video>
</p>
</main>
<!-- #endregion -->
<!-- #region Footer -->
<footer class="footer">
<div class="container">
<span class="text-muted">Place sticky footer content here.</span>
</div>
</footer>
<!-- #endregion -->
<!-- #region Scripts -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.bundle.min.js"
integrity="sha384-u/bQvRA/1bobcXlcEYpsEdFVK/vJs3+T+nXLsBYJthmdBuavHvAW6UsmqO2Gd/F9"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/holder/2.9.4/holder.js"
integrity="sha256-crfkMD0BL2TtiwpbIlXF/SVmGSvOtgbMM8GBkibVKyc="
crossorigin="anonymous"></script>
<!-- #endregion -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.1.5/hls.min.js"
integrity="sha512-O83G0C/Ypje2c3LTYElrDXQaqtKKxtu8WKlMLEMoIFs9HDeI4rMlpnn9AX5xvR3PgJpwSEBrZpxSzfE1usZqiQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script>
var video = document.getElementById('video');
if (Hls.isSupported()) {
var hls = new Hls({
debug: true,
});
hls.loadSource('https://streams.fergl.ie:8000/live/stream/175541');
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, function () {
video.muted = true;
video.play();
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<symbol id="w" overflow="visible">
<path d="m18.766-1.125c-0.96875 0.5-1.9805 0.875-3.0312 1.125-1.043 0.25781-2.1367 0.39062-3.2812 0.39062-3.3984 0-6.0898-0.94531-8.0781-2.8438-1.9922-1.9062-2.9844-4.4844-2.9844-7.7344 0-3.2578 0.99219-5.8359 2.9844-7.7344 1.9883-1.9062 4.6797-2.8594 8.0781-2.8594 1.1445 0 2.2383 0.13281 3.2812 0.39062 1.0508 0.25 2.0625 0.625 3.0312 1.125v4.2188c-0.98047-0.65625-1.9453-1.1406-2.8906-1.4531-0.94922-0.3125-1.9492-0.46875-3-0.46875-1.875 0-3.3516 0.60547-4.4219 1.8125-1.0742 1.1992-1.6094 2.8555-1.6094 4.9688 0 2.1055 0.53516 3.7617 1.6094 4.9688 1.0703 1.1992 2.5469 1.7969 4.4219 1.7969 1.0508 0 2.0508-0.14844 3-0.45312 0.94531-0.3125 1.9102-0.80078 2.8906-1.4688z"/>
</symbol>
<symbol id="c" overflow="visible">
<path d="m13.734-11.141c-0.4375-0.19531-0.87109-0.34375-1.2969-0.4375-0.41797-0.10156-0.83984-0.15625-1.2656-0.15625-1.2617 0-2.2305 0.40625-2.9062 1.2188-0.67969 0.80469-1.0156 1.9531-1.0156 3.4531v7.0625h-4.8906v-15.312h4.8906v2.5156c0.625-1 1.3438-1.7266 2.1562-2.1875 0.82031-0.46875 1.8008-0.70312 2.9375-0.70312 0.16406 0 0.34375 0.011719 0.53125 0.03125 0.19531 0.011719 0.47656 0.039062 0.84375 0.078125z"/>
</symbol>
<symbol id="a" overflow="visible">
<path d="m17.641-7.7031v1.4062h-11.453c0.125 1.1484 0.53906 2.0078 1.25 2.5781 0.70703 0.57422 1.7031 0.85938 2.9844 0.85938 1.0312 0 2.082-0.14844 3.1562-0.45312 1.082-0.3125 2.1914-0.77344 3.3281-1.3906v3.7656c-1.1562 0.4375-2.3125 0.76562-3.4688 0.98438-1.1562 0.22656-2.3125 0.34375-3.4688 0.34375-2.7734 0-4.9297-0.70312-6.4688-2.1094-1.5312-1.4062-2.2969-3.3789-2.2969-5.9219 0-2.5 0.75391-4.4609 2.2656-5.8906 1.5078-1.4375 3.582-2.1562 6.2188-2.1562 2.4062 0 4.332 0.73047 5.7812 2.1875 1.4453 1.4492 2.1719 3.3828 2.1719 5.7969zm-5.0312-1.625c0-0.92578-0.27344-1.6719-0.8125-2.2344-0.54297-0.57031-1.25-0.85938-2.125-0.85938-0.94922 0-1.7188 0.26562-2.3125 0.79688s-0.96484 1.2969-1.1094 2.2969z"/>
</symbol>
<symbol id="k" overflow="visible">
<path d="m9.2188-6.8906c-1.0234 0-1.793 0.17188-2.3125 0.51562-0.51172 0.34375-0.76562 0.85547-0.76562 1.5312 0 0.625 0.20703 1.1172 0.625 1.4688 0.41406 0.34375 0.98828 0.51562 1.7188 0.51562 0.92578 0 1.7031-0.32812 2.3281-0.98438 0.63281-0.66406 0.95312-1.4922 0.95312-2.4844v-0.5625zm7.4688-1.8438v8.7344h-4.9219v-2.2656c-0.65625 0.92969-1.3984 1.6055-2.2188 2.0312-0.82422 0.41406-1.8242 0.625-3 0.625-1.5859 0-2.8711-0.45703-3.8594-1.375-0.99219-0.92578-1.4844-2.1289-1.4844-3.6094 0-1.7891 0.61328-3.1016 1.8438-3.9375 1.2383-0.84375 3.1797-1.2656 5.8281-1.2656h2.8906v-0.39062c0-0.76953-0.30859-1.332-0.92188-1.6875-0.61719-0.36328-1.5703-0.54688-2.8594-0.54688-1.0547 0-2.0312 0.10547-2.9375 0.3125-0.89844 0.21094-1.7305 0.52344-2.5 0.9375v-3.7344c1.0391-0.25 2.0859-0.44141 3.1406-0.57812 1.0625-0.13281 2.125-0.20312 3.1875-0.20312 2.7578 0 4.75 0.54688 5.9688 1.6406 1.2266 1.0859 1.8438 2.8555 1.8438 5.3125z"/>
</symbol>
<symbol id="b" overflow="visible">
<path d="m7.7031-19.656v4.3438h5.0469v3.5h-5.0469v6.5c0 0.71094 0.14062 1.1875 0.42188 1.4375s0.83594 0.375 1.6719 0.375h2.5156v3.5h-4.1875c-1.9375 0-3.3125-0.39844-4.125-1.2031-0.80469-0.8125-1.2031-2.1797-1.2031-4.1094v-6.5h-2.4219v-3.5h2.4219v-4.3438z"/>
</symbol>
<symbol id="j" overflow="visible">
<path d="m12.766-13.078v-8.2031h4.9219v21.281h-4.9219v-2.2188c-0.66797 0.90625-1.4062 1.5703-2.2188 1.9844s-1.7578 0.625-2.8281 0.625c-1.8867 0-3.4336-0.75-4.6406-2.25-1.2109-1.5-1.8125-3.4258-1.8125-5.7812 0-2.3633 0.60156-4.2969 1.8125-5.7969 1.207-1.5 2.7539-2.25 4.6406-2.25 1.0625 0 2 0.21484 2.8125 0.64062 0.82031 0.42969 1.5664 1.0859 2.2344 1.9688zm-3.2188 9.9219c1.0391 0 1.8359-0.37891 2.3906-1.1406 0.55078-0.76953 0.82812-1.8828 0.82812-3.3438 0-1.457-0.27734-2.5664-0.82812-3.3281-0.55469-0.76953-1.3516-1.1562-2.3906-1.1562-1.043 0-1.8398 0.38672-2.3906 1.1562-0.55469 0.76172-0.82812 1.8711-0.82812 3.3281 0 1.4609 0.27344 2.5742 0.82812 3.3438 0.55078 0.76172 1.3477 1.1406 2.3906 1.1406z"/>
</symbol>
<symbol id="i" overflow="visible">
<path d="m10.5-3.1562c1.0508 0 1.8516-0.37891 2.4062-1.1406 0.55078-0.76953 0.82812-1.8828 0.82812-3.3438 0-1.457-0.27734-2.5664-0.82812-3.3281-0.55469-0.76953-1.3555-1.1562-2.4062-1.1562-1.0547 0-1.8594 0.38672-2.4219 1.1562-0.55469 0.77344-0.82812 1.8828-0.82812 3.3281 0 1.4492 0.27344 2.5586 0.82812 3.3281 0.5625 0.77344 1.3672 1.1562 2.4219 1.1562zm-3.25-9.9219c0.67578-0.88281 1.4219-1.5391 2.2344-1.9688 0.82031-0.42578 1.7656-0.64062 2.8281-0.64062 1.8945 0 3.4453 0.75 4.6562 2.25 1.207 1.5 1.8125 3.4336 1.8125 5.7969 0 2.3555-0.60547 4.2812-1.8125 5.7812-1.2109 1.5-2.7617 2.25-4.6562 2.25-1.0625 0-2.0078-0.21094-2.8281-0.625-0.8125-0.42578-1.5586-1.0859-2.2344-1.9844v2.2188h-4.8906v-21.281h4.8906z"/>
</symbol>
<symbol id="h" overflow="visible">
<path d="m0.34375-15.312h4.8906l4.125 10.391 3.5-10.391h4.8906l-6.4375 16.766c-0.64844 1.6953-1.4023 2.8828-2.2656 3.5625-0.86719 0.6875-2 1.0312-3.4062 1.0312h-2.8438v-3.2188h1.5312c0.83203 0 1.4375-0.13672 1.8125-0.40625 0.38281-0.26172 0.67969-0.73047 0.89062-1.4062l0.14062-0.42188z"/>
</symbol>
<symbol id="e" overflow="visible">
<path d="m2.5781-20.406h5.875l7.4219 14v-14h4.9844v20.406h-5.875l-7.4219-14v14h-4.9844z"/>
</symbol>
<symbol id="g" overflow="visible">
<path d="m2.3594-15.312h4.8906v15.312h-4.8906zm0-5.9688h4.8906v4h-4.8906z"/>
</symbol>
<symbol id="v" overflow="visible">
<path d="m2.3594-21.281h4.8906v11.594l5.625-5.625h5.6875l-7.4688 7.0312 8.0625 8.2812h-5.9375l-5.9688-6.3906v6.3906h-4.8906z"/>
</symbol>
<symbol id="f" overflow="visible">
<path d="m17.75-9.3281v9.3281h-4.9219v-7.1094c0-1.3438-0.03125-2.2656-0.09375-2.7656s-0.16797-0.86719-0.3125-1.1094c-0.1875-0.3125-0.44922-0.55469-0.78125-0.73438-0.32422-0.17578-0.69531-0.26562-1.1094-0.26562-1.0234 0-1.8242 0.39844-2.4062 1.1875-0.58594 0.78125-0.875 1.8711-0.875 3.2656v7.5312h-4.8906v-21.281h4.8906v8.2031c0.73828-0.88281 1.5195-1.5391 2.3438-1.9688 0.83203-0.42578 1.75-0.64062 2.75-0.64062 1.7695 0 3.1133 0.54688 4.0312 1.6406 0.91406 1.0859 1.375 2.6562 1.375 4.7188z"/>
</symbol>
<symbol id="u" overflow="visible">
<path d="m2.3594-21.281h4.8906v21.281h-4.8906z"/>
</symbol>
<symbol id="t" overflow="visible">
<path d="m7.8281-16.438v12.453h1.8906c2.1562 0 3.8008-0.53125 4.9375-1.5938 1.1328-1.0625 1.7031-2.6133 1.7031-4.6562 0-2.0195-0.57031-3.5547-1.7031-4.6094-1.125-1.0625-2.7734-1.5938-4.9375-1.5938zm-5.25-3.9688h5.5469c3.0938 0 5.3984 0.21875 6.9219 0.65625 1.5195 0.4375 2.8203 1.1875 3.9062 2.25 0.95703 0.91797 1.6641 1.9805 2.125 3.1875 0.46875 1.1992 0.70312 2.5586 0.70312 4.0781 0 1.543-0.23438 2.918-0.70312 4.125-0.46094 1.2109-1.168 2.2773-2.125 3.2031-1.0938 1.0547-2.4062 1.8047-3.9375 2.25-1.5312 0.4375-3.8281 0.65625-6.8906 0.65625h-5.5469z"/>
</symbol>
<symbol id="s" overflow="visible">
<path d="m0.42188-15.312h4.8906l3.8281 10.578 3.7969-10.578h4.9062l-6.0312 15.312h-5.375z"/>
</symbol>
<symbol id="r" overflow="visible">
<path d="m12.422-21.281v3.2188h-2.7031c-0.6875 0-1.1719 0.125-1.4531 0.375-0.27344 0.25-0.40625 0.6875-0.40625 1.3125v1.0625h4.1875v3.5h-4.1875v11.812h-4.8906v-11.812h-2.4375v-3.5h2.4375v-1.0625c0-1.6641 0.46094-2.8984 1.3906-3.7031 0.92578-0.80078 2.3672-1.2031 4.3281-1.2031z"/>
</symbol>
<symbol id="d" overflow="visible">
<path d="m9.6406-12.188c-1.0859 0-1.9141 0.39062-2.4844 1.1719-0.57422 0.78125-0.85938 1.9062-0.85938 3.375s0.28516 2.5938 0.85938 3.375c0.57031 0.77344 1.3984 1.1562 2.4844 1.1562 1.0625 0 1.875-0.38281 2.4375-1.1562 0.57031-0.78125 0.85938-1.9062 0.85938-3.375s-0.28906-2.5938-0.85938-3.375c-0.5625-0.78125-1.375-1.1719-2.4375-1.1719zm0-3.5c2.6328 0 4.6914 0.71484 6.1719 2.1406 1.4766 1.418 2.2188 3.3867 2.2188 5.9062 0 2.5117-0.74219 4.4805-2.2188 5.9062-1.4805 1.418-3.5391 2.125-6.1719 2.125-2.6484 0-4.7148-0.70703-6.2031-2.125-1.4922-1.4258-2.2344-3.3945-2.2344-5.9062 0-2.5195 0.74219-4.4883 2.2344-5.9062 1.4883-1.4258 3.5547-2.1406 6.2031-2.1406z"/>
</symbol>
<symbol id="q" overflow="visible">
<path d="m16.547-12.766c0.61328-0.94531 1.3477-1.6719 2.2031-2.1719 0.85156-0.5 1.7891-0.75 2.8125-0.75 1.7578 0 3.0977 0.54688 4.0156 1.6406 0.92578 1.0859 1.3906 2.6562 1.3906 4.7188v9.3281h-4.9219v-7.9844-0.35938c0.007813-0.13281 0.015625-0.32031 0.015625-0.5625 0-1.082-0.16406-1.8633-0.48438-2.3438-0.3125-0.48828-0.82422-0.73438-1.5312-0.73438-0.92969 0-1.6484 0.38672-2.1562 1.1562-0.51172 0.76172-0.77344 1.8672-0.78125 3.3125v7.5156h-4.9219v-7.9844c0-1.6953-0.14844-2.7852-0.4375-3.2656-0.29297-0.48828-0.8125-0.73438-1.5625-0.73438-0.9375 0-1.6641 0.38672-2.1719 1.1562-0.51172 0.76172-0.76562 1.8594-0.76562 3.2969v7.5312h-4.9219v-15.312h4.9219v2.2344c0.60156-0.86328 1.2891-1.5156 2.0625-1.9531 0.78125-0.4375 1.6406-0.65625 2.5781-0.65625 1.0625 0 2 0.25781 2.8125 0.76562 0.8125 0.51172 1.4258 1.2305 1.8438 2.1562z"/>
</symbol>
<symbol id="p" overflow="visible">
<path d="m2.1875-5.9688v-9.3438h4.9219v1.5312c0 0.83594-0.007813 1.875-0.015625 3.125-0.011719 1.25-0.015625 2.0859-0.015625 2.5 0 1.2422 0.03125 2.1328 0.09375 2.6719 0.070313 0.54297 0.17969 0.93359 0.32812 1.1719 0.20703 0.32422 0.47266 0.57422 0.79688 0.75 0.32031 0.16797 0.69141 0.25 1.1094 0.25 1.0195 0 1.8203-0.39062 2.4062-1.1719 0.58203-0.78125 0.875-1.8672 0.875-3.2656v-7.5625h4.8906v15.312h-4.8906v-2.2188c-0.74219 0.89844-1.5234 1.5586-2.3438 1.9844-0.82422 0.41406-1.7344 0.625-2.7344 0.625-1.7617 0-3.1055-0.53906-4.0312-1.625-0.92969-1.082-1.3906-2.6602-1.3906-4.7344z"/>
</symbol>
<symbol id="o" overflow="visible">
<path d="m17.75-9.3281v9.3281h-4.9219v-7.1406c0-1.3203-0.03125-2.2344-0.09375-2.7344s-0.16797-0.86719-0.3125-1.1094c-0.1875-0.3125-0.44922-0.55469-0.78125-0.73438-0.32422-0.17578-0.69531-0.26562-1.1094-0.26562-1.0234 0-1.8242 0.39844-2.4062 1.1875-0.58594 0.78125-0.875 1.8711-0.875 3.2656v7.5312h-4.8906v-15.312h4.8906v2.2344c0.73828-0.88281 1.5195-1.5391 2.3438-1.9688 0.83203-0.42578 1.75-0.64062 2.75-0.64062 1.7695 0 3.1133 0.54688 4.0312 1.6406 0.91406 1.0859 1.375 2.6562 1.375 4.7188z"/>
</symbol>
<symbol id="n" overflow="visible">
<path d="m2.5781-20.406h8.7344c2.5938 0 4.582 0.57812 5.9688 1.7344 1.3945 1.1484 2.0938 2.7891 2.0938 4.9219 0 2.1367-0.69922 3.7812-2.0938 4.9375-1.3867 1.1562-3.375 1.7344-5.9688 1.7344h-3.4844v7.0781h-5.25zm5.25 3.8125v5.7031h2.9219c1.0195 0 1.8047-0.25 2.3594-0.75 0.5625-0.5 0.84375-1.2031 0.84375-2.1094 0-0.91406-0.28125-1.6172-0.84375-2.1094-0.55469-0.48828-1.3398-0.73438-2.3594-0.73438z"/>
</symbol>
<symbol id="m" overflow="visible">
<path d="m2.3594-15.312h4.8906v15.031c0 2.0508-0.49609 3.6172-1.4844 4.7031-0.98047 1.082-2.4062 1.625-4.2812 1.625h-2.4219v-3.2188h0.85938c0.92578 0 1.5625-0.21094 1.9062-0.625 0.35156-0.41797 0.53125-1.2461 0.53125-2.4844zm0-5.9688h4.8906v4h-4.8906z"/>
</symbol>
<symbol id="l" overflow="visible">
<path d="m14.719-14.828v3.9844c-0.65625-0.45703-1.3242-0.79688-2-1.0156-0.66797-0.21875-1.3594-0.32812-2.0781-0.32812-1.3672 0-2.4336 0.40234-3.2031 1.2031-0.76172 0.79297-1.1406 1.9062-1.1406 3.3438 0 1.4297 0.37891 2.543 1.1406 3.3438 0.76953 0.79297 1.8359 1.1875 3.2031 1.1875 0.75781 0 1.4844-0.10938 2.1719-0.32812 0.6875-0.22656 1.3203-0.56641 1.9062-1.0156v4c-0.76172 0.28125-1.5391 0.48828-2.3281 0.625-0.78125 0.14453-1.5742 0.21875-2.375 0.21875-2.7617 0-4.9219-0.70703-6.4844-2.125-1.5547-1.4141-2.3281-3.3828-2.3281-5.9062 0-2.5312 0.77344-4.5039 2.3281-5.9219 1.5625-1.4141 3.7227-2.125 6.4844-2.125 0.80078 0 1.5938 0.074219 2.375 0.21875 0.78125 0.13672 1.5547 0.35156 2.3281 0.64062z"/>
</symbol>
</defs>
<g>
<path d="m-1005.2 0h1036v554.4h-1036z"/>
<path d="m588.68 103.72h-477.37c-18.531 0-33.59 18.039-33.59 40.27v272.02c0 22.266 15.059 40.27 33.59 40.27h477.37c18.535 0 33.594-18.008 33.594-40.27v-272.01c0-22.238-15.059-40.273-33.594-40.273zm-104.38 278.76c0 16.762-11.328 30.32-25.277 30.32l-311.24-0.003906c-13.945 0-25.277-13.559-25.277-30.32v-204.93c0-16.762 11.328-30.352 25.277-30.352h311.24c13.945 0 25.277 13.59 25.277 30.352v204.93zm69.148-51.992c-23.375 0-42.363-18.996-42.363-42.438 0-23.375 18.996-42.363 42.363-42.363 23.445 0 42.363 18.996 42.363 42.363 0 23.445-18.914 42.438-42.363 42.438z" fill-rule="evenodd"/>
<path d="m241.23 188.95 157.68 91.055-157.68 91.086z"/>
<use x="70" y="644" xlink:href="#w"/>
<use x="90.550781" y="644" xlink:href="#c"/>
<use x="104.359375" y="644" xlink:href="#a"/>
<use x="123.347656" y="644" xlink:href="#k"/>
<use x="142.242188" y="644" xlink:href="#b"/>
<use x="155.628906" y="644" xlink:href="#a"/>
<use x="174.617188" y="644" xlink:href="#j"/>
<use x="204.410156" y="644" xlink:href="#i"/>
<use x="224.453125" y="644" xlink:href="#h"/>
<use x="252.453125" y="644" xlink:href="#e"/>
<use x="275.886719" y="644" xlink:href="#g"/>
<use x="285.484375" y="644" xlink:href="#v"/>
<use x="304.105469" y="644" xlink:href="#f"/>
<use x="324.039062" y="644" xlink:href="#g"/>
<use x="333.632812" y="644" xlink:href="#u"/>
<use x="352.980469" y="644" xlink:href="#t"/>
<use x="376.222656" y="644" xlink:href="#a"/>
<use x="395.210938" y="644" xlink:href="#s"/>
<use x="70" y="672" xlink:href="#r"/>
<use x="82.183594" y="672" xlink:href="#c"/>
<use x="95.992188" y="672" xlink:href="#d"/>
<use x="115.226562" y="672" xlink:href="#q"/>
<use x="154.152344" y="672" xlink:href="#b"/>
<use x="167.535156" y="672" xlink:href="#f"/>
<use x="187.46875" y="672" xlink:href="#a"/>
<use x="216.207031" y="672" xlink:href="#e"/>
<use x="239.640625" y="672" xlink:href="#d"/>
<use x="258.878906" y="672" xlink:href="#p"/>
<use x="278.8125" y="672" xlink:href="#o"/>
<use x="308.492188" y="672" xlink:href="#n"/>
<use x="329.015625" y="672" xlink:href="#c"/>
<use x="342.820312" y="672" xlink:href="#d"/>
<use x="362.058594" y="672" xlink:href="#m"/>
<use x="371.65625" y="672" xlink:href="#a"/>
<use x="390.648438" y="672" xlink:href="#l"/>
<use x="407.242188" y="672" xlink:href="#b"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
docker context use default
docker build \
-t fergalmoran/xtream-frontend \
-f docker/Dockerfile .
docker push fergalmoran/xtream-frontend

26
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import React from "react";
import { Sidebar } from "./components";
import { Route, Routes } from "react-router-dom";
import { HomePage, ChannelPage, PlayerPage } from "./pages";
function App() {
return (
<div className="flex flex-col h-screen font-Rampart">
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<div className="flex flex-col flex-1">
<div className="flex h-16 p-4 bg-gray-100">Header</div>
<main className="flex flex-1 px-4 pt-5 overflow-y-auto">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="live/channel/:channelId" element={<ChannelPage />} />
<Route path="live/play/:streamId" element={<PlayerPage />} />
</Routes>
</main>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,88 @@
import React, { useEffect, RefObject } from "react";
import Hls, { HlsConfig } from "hls.js";
export interface HlsPlayerProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
hlsConfig?: Partial<HlsConfig>;
playerRef?: RefObject<HTMLVideoElement>;
src: string;
}
function HLSPlayer({
hlsConfig,
playerRef = React.createRef<HTMLVideoElement>(),
src,
autoPlay,
...props
}: HlsPlayerProps) {
useEffect(() => {
let hls: Hls;
function _initPlayer() {
if (hls != null) {
hls.destroy();
}
const newHls = new Hls({
enableWorker: false,
...hlsConfig,
});
if (playerRef.current != null) {
newHls.attachMedia(playerRef.current);
}
newHls.on(Hls.Events.MEDIA_ATTACHED, () => {
newHls.loadSource(src);
newHls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) {
playerRef?.current
?.play()
.catch(() =>
console.log(
"Unable to autoplay prior to user interaction with the dom."
)
);
}
});
});
newHls.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
newHls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
newHls.recoverMediaError();
break;
default:
_initPlayer();
break;
}
}
});
hls = newHls;
}
// Check for Media Source support
if (Hls.isSupported()) {
_initPlayer();
}
return () => {
if (hls != null) {
hls.destroy();
}
};
}, [autoPlay, hlsConfig, playerRef, src]);
// If Media Source is supported, use HLS.js to play video
if (Hls.isSupported()) return <video ref={playerRef} {...props} />;
// Fallback to using a regular video player if HLS is supported by default in the user's browser
return <video ref={playerRef} src={src} autoPlay={autoPlay} {...props} />;
}
export default HLSPlayer;

View File

@@ -0,0 +1,5 @@
import HLSPlayer from "./hls-player.component";
import Navbar from "./navbar.component";
import Sidebar from "./sidebar.component";
export { Navbar, Sidebar, HLSPlayer };

View File

@@ -0,0 +1,100 @@
import React from "react";
import { Link } from "react-router-dom";
const Navbar = () => {
return (
<nav className="fixed z-30 w-full bg-white border-b-2 border-indigo-600">
<div className="px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center justify-start">
<button className="p-2 text-gray-600 rounded cursor-pointer lg:hidden ">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<Link to="/" className="flex items-center text-xl font-bold">
<span className="text-blue-800">Xtream Player</span>
</Link>
</div>
<div className="flex items-center">
<div className="hidden mr-6 lg:block">
<form action="#">
<label className="sr-only">Search</label>
<div className="relative mt-1 lg:w-64">
<div className="absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="w-5 h-5 text-gray-500"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<input
type="text"
name="name"
className=" border text-gray-900 sm:text-sm rounded-lg focus:outline-none focus:ring-1 block w-full pl-10 p-2.5"
placeholder="Search"
/>
</div>
</form>
</div>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</div>
<div className="relative inline-block ">
{/* Dropdown toggle button */}
<button className="relative flex items-center p-2 text-sm text-gray-600 bg-white border border-transparent rounded-md focus:border-blue-500 focus:ring-opacity-40 dark:focus:ring-opacity-40 focus:ring-blue-300 dark:focus:ring-blue-400 focus:ring dark:text-white dark:bg-gray-800 focus:outline-none">
<span className="mx-1">Jane Doe</span>
<svg
className="w-5 h-5 mx-1"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.713L18.01 9.70299L16.597 8.28799L12 12.888L7.40399 8.28799L5.98999 9.70199L12 15.713Z"
fill="currentColor"
/>
</svg>
</button>
{/* Dropdown menu */}
<div className="absolute right-0 z-20 w-56 mt-2 overflow-hidden bg-white rounded-md"></div>
</div>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,87 @@
import React from "react";
import { Link } from "react-router-dom";
import { Channel } from "../models/channel";
const Sidebar = () => {
const [channels, setChannels] = React.useState<Channel[]>([]);
const [filteredChannels, setFilteredChannels] = React.useState<Channel[]>([]);
React.useEffect(() => {
const fetchChannels = async () => {
const res = await fetch(`${process.env.REACT_APP_API_URL}/channels`);
const data = await res.json();
setChannels(data);
setFilteredChannels(data);
};
fetchChannels().catch(console.error);
}, []);
const _searchChannels = ($event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<aside className="flex flex-col w-1/5 pl-2 bg-gray-100">
<div className="flex items-center justify-center h-16">
<div className="flex p-8">
<input
type="text"
className="px-4 py-2"
placeholder="Search..."
onChange={_searchChannels}
/>
<button className="flex items-center justify-center px-4 border-l">
<svg
className="w-6 h-6 text-gray-600"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M16.32 14.9l5.39 5.4a1 1 0 0 1-1.42 1.4l-5.38-5.38a8 8 0 1 1 1.41-1.41zM10 16a6 6 0 1 0 0-12 6 6 0 0 0 0 12z" />
</svg>
</button>
</div>
</div>
<ul className="overflow-y-auto">
<li>
{filteredChannels.map((channel: Channel) => (
<Link
key={channel.category_id}
to={`/live/channel/${channel.category_id}`}
className="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg
className="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg>
<span className="ml-3 text-sm font-semibold">
{channel.category_name}
</span>
</Link>
))}
</li>
</ul>
</aside>
);
};
export default Sidebar;

5
frontend/src/index.css Normal file
View File

@@ -0,0 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Raleway&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;

20
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1,5 @@
export interface Channel {
category_id: number;
category_name: string;
parent_id: number;
}

View File

@@ -0,0 +1,14 @@
export interface Stream {
num: number;
name: string;
stream_type: string;
stream_id: number;
stream_icon: string;
epg_channel_id: null;
added: number;
category_id: number;
custom_sid: string;
tv_archive: number;
direct_source: string;
tv_archive_duration: number;
}

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useParams } from "react-router-dom";
import { Stream } from "../models/stream";
const ChannelPage = () => {
let params = useParams();
const [streams, setStreams] = React.useState<Stream[]>([]);
React.useEffect(() => {
const fetchChannels = async () => {
const res = await fetch(
`${process.env.REACT_APP_API_URL}/streams/${params.channelId}`
);
const data = await res.json();
setStreams(data);
};
fetchChannels().catch(console.error);
}, [params.channelId]);
const handleXHR = (...args: any[]) => {
console.log("channel.page", "handleXHR", args);
};
const playStream = async (streamId: number) => {
const res = await fetch(
`${process.env.REACT_APP_API_URL}/live/stream/url/${streamId}`
);
if (res.status !== 200) {
alert("Failed to get stream url");
return;
}
const data = await res.json();
if (data.url) {
const mpv_args =
"--keep-open=yes\n--geometry=1024x768-0-0\n--ontop\n--screen=2\n--ytdl-format=bestvideo[ext=mp4][height<=?720]+bestaudio[ext=m4a]\n--border=no".split(
/\n/
);
const query =
`?play_url=` +
encodeURIComponent(data.url) +
[""].concat(mpv_args.map(encodeURIComponent)).join("&mpv_args=");
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = handleXHR;
xhr.open("GET", `${process.env.REACT_APP_SERVER_URL}/${query}`, true);
xhr.send();
}
};
return (
<div className="flex flex-col w-full h-screen ">
<table className="font-semibold leading-normal table-auto">
<thead className="sticky top-0 font-semibold text-left text-white uppercase bg-indigo-500">
<tr className="">
<th className="text-lg uppercase border-b border-gray-200 1px-5">
Channel name
</th>
<th className="border-b border-gray-200 1px-5">Type</th>
<th className="py-3 border-b border-gray-200 1px-5"></th>
</tr>
</thead>
<tbody className="divide-y ">
{streams.map((stream: Stream) => (
<tr key={stream.num}>
<td className="px-5 py-5 text-sm border-b border-gray-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<img
alt="stream"
src={
stream.stream_icon ||
`${process.env.PUBLIC_URL}/icons/unknown-stream.png`
}
className="object-cover w-10 h-10 mx-auto rounded-full "
/>
</div>
<div className="ml-3">
<p className="text-gray-900 whitespace-no-wrap">
{stream.name}
</p>
</div>
</div>
</td>
<td className="px-5 py-5 text-sm border-b border-gray-200">
<span className="relative inline-block px-3 py-1 font-semibold leading-tight text-green-900">
<span
aria-hidden="true"
className="absolute inset-0 bg-green-200 rounded-full opacity-50"
></span>
<span className="relative">{stream.stream_type}</span>
</span>
</td>
<td className="px-5 py-5 text-sm border-b border-gray-200">
<button onClick={() => playStream(stream.stream_id)}>
<img
className="w-10 h-10 "
src={`${process.env.PUBLIC_URL}/icons/play.svg`}
alt="Play"
/>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ChannelPage;

View File

@@ -0,0 +1,7 @@
import React from "react";
const HomePage = () => {
return <div>HomePage</div>;
};
export default HomePage;

View File

@@ -0,0 +1,5 @@
import ChannelPage from "./channel.page";
import HomePage from "./home.page";
import PlayerPage from "./player.page";
export { HomePage, ChannelPage, PlayerPage };

View File

@@ -0,0 +1,40 @@
import React from "react";
import { useParams } from "react-router-dom";
import { HLSPlayer } from "../components";
const PlayerPage = () => {
const params = useParams();
const [streamUrl, setStreamUrl] = React.useState("");
React.useEffect(() => {
setStreamUrl(
`${process.env.REACT_APP_API_URL}/live/stream/${params.streamId}`
);
console.log("player.page", "streamUrl", streamUrl);
}, [params.streamId, streamUrl]);
return (
<div>
{streamUrl && (
<>
<span>
<a href={streamUrl} target="_blank" rel="noreferrer">
{streamUrl}
</a>
</span>
<div className="w-96 h-96">
<HLSPlayer
src={streamUrl}
hlsConfig={{
debug: true,
maxLoadingDelay: 4,
minAutoBitrate: 0,
lowLatencyMode: true,
}}
/>
</div>
</>
)}
</div>
);
};
export default PlayerPage;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,11 @@
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
fontFamily: {
Rampart: ["Raleway", "sans-serif"],
},
},
},
plugins: [],
};

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

8790
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
version: "3.3"
services:
backend:
image: fergalmoran/xtream-backend
container_name: "xtream-backend"
environment:
- TZ=Europe/Dublin
networks:
- "traefik_proxy"
restart: always
depends_on:
- db
labels:
- "traefik.enable=true"
- "traefik.http.routers.xtream-backend.rule=Host(`api.streams.fergl.ie`)"
- "traefik.http.routers.xtream-backend.entrypoints=websecure"
- "traefik.http.routers.xtream-backend.tls.certresolver=noodles-resolver"
- "traefik.http.services.xtream-backend.loadbalancer.server.port=80"
frontend:
image: fergalmoran/xtream-frontend
container_name: "xtream-frontend"
environment:
- TZ=Europe/Dublin
networks:
- "traefik_proxy"
restart: always
depends_on:
- db
labels:
- "traefik.enable=true"
- "traefik.http.routers.xtream-frontend.rule=Host(`streams.fergl.ie`)"
- "traefik.http.routers.xtream-frontend.entrypoints=websecure"
- "traefik.http.routers.xtream-frontend.tls.certresolver=noodles-resolver"
- "traefik.http.services.xtream-frontend.loadbalancer.server.port=80"
networks:
traefik_proxy:
external: true
name: traefik_proxy

2
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.idea/
__pycache__/

10
server/args.py Normal file
View File

@@ -0,0 +1,10 @@
import argparse
def get_args():
parser = argparse.ArgumentParser(description='Xtream Arguments')
parser.add_argument('--host', type=str, default='localhost', help='Host address to listen on (default localhost)')
parser.add_argument('--port', type=int, default=9531, help='Port to listen on (default 9531)')
return parser.parse_args()

65
server/server.py Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import urllib.parse as urlparse
from http.server import SimpleHTTPRequestHandler, HTTPServer
from subprocess import Popen
from args import get_args
def missing_bin(bin):
print("======================")
print(
f"ERROR: {bin.upper()} does not appear to be installed correctly! please ensure you can launch '{bin}' in the terminal.")
print("======================")
class CORSRequestHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', '*')
self.send_header('Access-Control-Allow-Headers', '*')
self.send_header('Access-Control-Allow-Private-Network', 'true')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
return super(CORSRequestHandler, self).end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.end_headers()
def do_GET(self):
try:
url = urlparse.urlparse(self.path)
query = urlparse.parse_qs(url.query)
except:
query = {}
urls = str(query["play_url"][0])
if urls.startswith('magnet:') or urls.endswith('.torrent'):
try:
pipe = Popen([
'peerflix', '-k', urls, '--', '--force-window'
] + query.get("mpv_args", []))
except FileNotFoundError as e:
missing_bin('peerflix')
else:
try:
pipe = Popen(['mpv', urls, '--force-window'] +
query.get("mpv_args", []))
except FileNotFoundError as e:
missing_bin('mpv')
self.send_response(200, "playing...")
self.send_response(200)
self.end_headers()
if __name__ == '__main__':
args = get_args()
print(args.host)
print(args.port)
print("Listening on {}:{}".format(args.host, args.port))
httpd = HTTPServer((args.host, args.port), CORSRequestHandler)
httpd.serve_forever()