git
This commit is contained in:
commit
164439d3a7
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/config.ini
|
||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
/venv/
|
||||||
|
*.db
|
||||||
|
*.pyc
|
280
app.py
Normal file
280
app.py
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from flask import Flask, request, make_response, session, redirect, render_template, send_from_directory, jsonify
|
||||||
|
from time import gmtime, strftime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import base64
|
||||||
|
import configparser
|
||||||
|
import os
|
||||||
|
|
||||||
|
"""
|
||||||
|
SQLite3 Database Schema
|
||||||
|
|
||||||
|
CREATE TABLE channels (
|
||||||
|
"id" INTEGER PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"live_at" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"user_uuid" TEXT
|
||||||
|
, "last_stream" TEXT);
|
||||||
|
|
||||||
|
CREATE TABLE signed_users (
|
||||||
|
"id" INTEGER PRIMARY KEY,
|
||||||
|
"uuid" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
d2c45910-8eb8-11e8-b357-b10f0028b927
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
|
||||||
|
config['Streaming'] = {
|
||||||
|
'Database': 'streaming.db'
|
||||||
|
'StreamServer': 'https://tv.icynet.eu/live/',
|
||||||
|
'ServerHost': 'icynet.eu',
|
||||||
|
'PublishAddress': 'rtmp://{host}:1935/hls-live/{streamer}',
|
||||||
|
}
|
||||||
|
config['Auth'] = {
|
||||||
|
'Server': 'http://localhost:8282',
|
||||||
|
'Redirect': 'http://localhost:5000/auth/_callback/',
|
||||||
|
}
|
||||||
|
config['OAuth2'] = {
|
||||||
|
'ClientID': '1',
|
||||||
|
'ClientSecret': 'changeme',
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.path.exists('config.ini'):
|
||||||
|
config.read('config.ini')
|
||||||
|
|
||||||
|
with open('config.ini', 'w') as configfile:
|
||||||
|
config.write(configfile)
|
||||||
|
|
||||||
|
# App settings
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
auth_image = "{server}/api/avatar/{uuid}"
|
||||||
|
oauth_auth = "{server}/oauth2/authorize?response_type=code&state={state}&redirect_uri={redirect}&client_id={client}&scope=image"
|
||||||
|
stream_server = config['Streaming']['StreamServer']
|
||||||
|
auth_server = config['Auth']['Server']
|
||||||
|
oauth_redirect = config['Auth']['Redirect']
|
||||||
|
oauth_id = int(config['OAuth2']['ClientID'])
|
||||||
|
oauth_secret = config['OAuth2']['ClientSecret']
|
||||||
|
|
||||||
|
# Database
|
||||||
|
conn = sqlite3.connect(config['Streaming']['Database'], check_same_thread=False)
|
||||||
|
|
||||||
|
# Streamer Cache
|
||||||
|
stream_cache = {}
|
||||||
|
|
||||||
|
# Check if user is a streamer
|
||||||
|
def valid_streamer(uuid):
|
||||||
|
streamer = None
|
||||||
|
|
||||||
|
if 'uuid' in session:
|
||||||
|
if session['uuid'] in stream_cache:
|
||||||
|
streamer = stream_cache[session['uuid']]
|
||||||
|
else:
|
||||||
|
# Find key in database
|
||||||
|
data = conn.execute('SELECT * FROM channels WHERE user_uuid=?', (session['uuid'],))
|
||||||
|
row = data.fetchone()
|
||||||
|
|
||||||
|
# Deny stream publish
|
||||||
|
if row:
|
||||||
|
streamer = row[2]
|
||||||
|
|
||||||
|
return streamer
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
streamer = False
|
||||||
|
if 'uuid' in session:
|
||||||
|
if valid_streamer(session['uuid']):
|
||||||
|
streamer = True
|
||||||
|
|
||||||
|
return render_template("index.html", streamer = streamer)
|
||||||
|
|
||||||
|
@app.route("/dashboard")
|
||||||
|
def dashboard():
|
||||||
|
if not 'uuid' in session:
|
||||||
|
return redirect("/login", code = 302)
|
||||||
|
|
||||||
|
streamkey = valid_streamer(session['uuid'])
|
||||||
|
if not streamkey:
|
||||||
|
return make_response("Unauthorized.", 402)
|
||||||
|
|
||||||
|
return render_template("dashboard.html", stream = streamkey,
|
||||||
|
server = config['Streaming']['PublishAddress'].format(streamer = "", host = config['Streaming']['ServerHost']))
|
||||||
|
|
||||||
|
@app.route("/dashboard/data")
|
||||||
|
def dashboard_data():
|
||||||
|
if not 'uuid' in session:
|
||||||
|
return jsonify({'error': 'Unauthorized'})
|
||||||
|
|
||||||
|
streamkey = valid_streamer(session['uuid'])
|
||||||
|
if not streamkey:
|
||||||
|
return jsonify({'error': 'Unauthorized'})
|
||||||
|
|
||||||
|
# Find key in database
|
||||||
|
data = conn.execute('SELECT * FROM channels WHERE key=?', (streamkey,))
|
||||||
|
row = data.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Unauthorized'})
|
||||||
|
|
||||||
|
livedate = row[3]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'name': row[1],
|
||||||
|
'key': streamkey,
|
||||||
|
'uuid': session['uuid'],
|
||||||
|
'live': livedate != None,
|
||||||
|
'live_at': livedate,
|
||||||
|
'last_stream': row[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
# Called when starting publishing
|
||||||
|
@app.route("/publish", methods=["POST"])
|
||||||
|
def publish():
|
||||||
|
print(json.dumps(request.form, ensure_ascii=False))
|
||||||
|
|
||||||
|
streamkey = request.form["name"]
|
||||||
|
|
||||||
|
# Find key in database
|
||||||
|
data = conn.execute('SELECT * FROM channels WHERE key=?', (streamkey,))
|
||||||
|
row = data.fetchone()
|
||||||
|
|
||||||
|
# Deny stream publish
|
||||||
|
if not row:
|
||||||
|
return make_response("Request Denied", 400)
|
||||||
|
|
||||||
|
streamer = row[1]
|
||||||
|
|
||||||
|
print("Streamer %s has started streaming!" % streamer)
|
||||||
|
|
||||||
|
# Redirect stream publish to stream name
|
||||||
|
url = config['Streaming']['PublishAddress'].format(streamer = streamer, host = "127.0.0.1")
|
||||||
|
|
||||||
|
response = make_response(url, 302)
|
||||||
|
response.headers["Location"] = url
|
||||||
|
|
||||||
|
# Update database with stream timestamp
|
||||||
|
starttime = strftime("%Y-%m-%d %H:%M:%S", gmtime())
|
||||||
|
|
||||||
|
conn.execute('UPDATE channels SET live_at=? WHERE id=?', (starttime, row[0]))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Called when stopped publishing
|
||||||
|
@app.route("/publish_done", methods=["POST"])
|
||||||
|
def publish_done():
|
||||||
|
streamkey = request.form["name"]
|
||||||
|
|
||||||
|
# Update database with stream end time
|
||||||
|
endtime = strftime("%Y-%m-%d %H:%M:%S", gmtime())
|
||||||
|
|
||||||
|
conn.execute('UPDATE channels SET live_at=NULL, last_stream=? WHERE key=?', (endtime, streamkey,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
@app.route("/login")
|
||||||
|
def login():
|
||||||
|
if 'uuid' in session:
|
||||||
|
return make_response("Already authenticated as %s!" % session['username'])
|
||||||
|
|
||||||
|
state = str(uuid4())
|
||||||
|
session['state'] = state
|
||||||
|
|
||||||
|
return redirect(oauth_auth.format(state = state, client = oauth_id, redirect = oauth_redirect, server = auth_server), code = 302)
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect("/", code = 302)
|
||||||
|
|
||||||
|
@app.route("/auth/_callback/")
|
||||||
|
def cb():
|
||||||
|
if not 'state' in session:
|
||||||
|
return make_response("Something went wrong!", 402)
|
||||||
|
|
||||||
|
code = request.args.get('code')
|
||||||
|
state = request.args.get('state')
|
||||||
|
|
||||||
|
if session['state'] != state:
|
||||||
|
return make_response("Something went wrong!", 402)
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return make_response("Authorization denied by user", 402)
|
||||||
|
|
||||||
|
# Get access token
|
||||||
|
r = requests.post(auth_server + "/oauth2/token", data = {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code,
|
||||||
|
'redirect_uri': oauth_redirect,
|
||||||
|
'client_id': oauth_id,
|
||||||
|
'client_secret': oauth_secret
|
||||||
|
}, headers = {
|
||||||
|
'Authorization': 'Basic ' + str(base64.b64encode(bytes("%s:%s" % (oauth_id, oauth_secret), 'utf-8')))
|
||||||
|
})
|
||||||
|
|
||||||
|
res_token = None
|
||||||
|
try:
|
||||||
|
res_token = r.json()
|
||||||
|
except ValueError:
|
||||||
|
return make_response("Something went wrong while getting an access token!")
|
||||||
|
|
||||||
|
if 'error' in res_token:
|
||||||
|
return make_response("%s: %s" % (res_token['error'], res_token['error_description']), 500)
|
||||||
|
|
||||||
|
token = res_token['access_token']
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
ru = requests.get(auth_server + "/oauth2/user", headers = {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
})
|
||||||
|
|
||||||
|
res_uinfo = None
|
||||||
|
try:
|
||||||
|
res_uinfo = ru.json()
|
||||||
|
except ValueError:
|
||||||
|
return make_response("Something went wrong while getting user information!")
|
||||||
|
|
||||||
|
udata = conn.execute('SELECT * FROM signed_users WHERE uuid=?', (res_uinfo['uuid'],))
|
||||||
|
row = udata.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
conn.execute('INSERT INTO signed_users (uuid, name) VALUES (?, ?)', (res_uinfo['uuid'], res_uinfo['username']))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
session['uuid'] = res_uinfo['uuid']
|
||||||
|
session['username'] = res_uinfo['username']
|
||||||
|
|
||||||
|
return redirect("/", code = 302)
|
||||||
|
|
||||||
|
@app.route("/watch/<name>")
|
||||||
|
def watch(name):
|
||||||
|
return render_template("player.html", name = name, server = stream_server)
|
||||||
|
|
||||||
|
@app.route("/player/<name>")
|
||||||
|
def watch_old(name):
|
||||||
|
return redirect("/watch/%s" % name, code = 302)
|
||||||
|
|
||||||
|
@app.route("/dist/<path:path>")
|
||||||
|
def dist(path):
|
||||||
|
return send_from_directory("dist", path)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.secret_key = '00-wegrhr[gqw[er=1ew qwergfdq.///**+'
|
||||||
|
app.config['SESSION_TYPE'] = 'redis'
|
||||||
|
app.debug = True
|
||||||
|
app.run()
|
5509
package-lock.json
generated
Normal file
5509
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "icytv",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend builder",
|
||||||
|
"main": "index.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"watch": "webpack -w --mode=development --log-level=debug",
|
||||||
|
"build": "webpack -p"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bootstrap": "^4.1.3",
|
||||||
|
"css-loader": "^1.0.0",
|
||||||
|
"file-loader": "^1.1.11",
|
||||||
|
"font-awesome": "^4.7.0",
|
||||||
|
"hls.js": "^0.10.1",
|
||||||
|
"jquery": "^3.3.1",
|
||||||
|
"popper.js": "^1.14.4",
|
||||||
|
"style-loader": "^0.21.0",
|
||||||
|
"url-loader": "^1.0.1",
|
||||||
|
"webpack": "^4.16.4",
|
||||||
|
"webpack-command": "^0.4.1"
|
||||||
|
}
|
||||||
|
}
|
93
src/css/dashboard.css
Normal file
93
src/css/dashboard.css
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
body {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feather {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sidebar
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100; /* Behind the navbar */
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sticky {
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
top: 48px; /* Height of navbar */
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
padding-top: .5rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link .feather {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link.active {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-link:hover .feather,
|
||||||
|
.sidebar .nav-link.active .feather {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-heading {
|
||||||
|
font-size: .75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Navbar
|
||||||
|
*/
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
padding-top: .75rem;
|
||||||
|
padding-bottom: .75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, .25);
|
||||||
|
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .form-control {
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-dark {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255, 255, 255, .1);
|
||||||
|
border-color: rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-dark:focus {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
.border-top { border-top: 1px solid #e5e5e5; }
|
||||||
|
.border-bottom { border-bottom: 1px solid #e5e5e5; }
|
110
src/css/index.css
Normal file
110
src/css/index.css
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Globals
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a,
|
||||||
|
a:focus,
|
||||||
|
a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom default button */
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-secondary:hover,
|
||||||
|
.btn-secondary:focus {
|
||||||
|
color: #333;
|
||||||
|
text-shadow: none; /* Prevent inheritance from `body` */
|
||||||
|
background-color: #fff;
|
||||||
|
border: .05rem solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Base structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #006289;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: -webkit-box;
|
||||||
|
display: flex;
|
||||||
|
-ms-flex-pack: center;
|
||||||
|
-webkit-box-pack: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
|
||||||
|
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
max-width: 50em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Header
|
||||||
|
*/
|
||||||
|
.masthead {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-brand {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link {
|
||||||
|
padding: .25rem 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, .5);
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom: .25rem solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link:hover,
|
||||||
|
.nav-masthead .nav-link:focus {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .nav-link + .nav-link {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-masthead .active {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
.masthead-brand {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
.nav-masthead {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cover
|
||||||
|
*/
|
||||||
|
.cover {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
.cover .btn-lg {
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Footer
|
||||||
|
*/
|
||||||
|
.mastfoot {
|
||||||
|
color: rgba(255, 255, 255, .5);
|
||||||
|
}
|
119
src/css/player.css
Normal file
119
src/css/player.css
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
body {
|
||||||
|
font-family: Helvetica;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.livecnt {
|
||||||
|
position: absolute;
|
||||||
|
background-color: black;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.videobox {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#stream {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.live {
|
||||||
|
position: absolute;
|
||||||
|
color: white;
|
||||||
|
font-size: 120%;
|
||||||
|
margin: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: #F44336;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.live.offline {
|
||||||
|
background-color: rgba(93, 93, 93, 0.7);
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transition: opacity .25s ease-in-out;
|
||||||
|
-moz-transition: opacity .25s ease-in-out;
|
||||||
|
-webkit-transition: opacity .25s ease-in-out;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
background-color: rgba(0, 142, 255, 0.42);
|
||||||
|
border-top: 2px solid #006cc1;
|
||||||
|
color: white;
|
||||||
|
height: 45px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.controls .inline {
|
||||||
|
float: left;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.controls .ilbtn {
|
||||||
|
font-size: 200%;
|
||||||
|
width: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
#duration {
|
||||||
|
line-height: 2.4;
|
||||||
|
font-size: 110%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.controls .right {
|
||||||
|
float: right;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.bigplaybtn {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 400%;
|
||||||
|
position: absolute;
|
||||||
|
top: 40%;
|
||||||
|
margin: auto;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
line-height: 1.2;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: rgba(3, 169, 244, 0.7);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 100%;
|
||||||
|
border: 5px dotted #2196F3;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bigplaybtn .fa {
|
||||||
|
top: 6px;
|
||||||
|
position: relative;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
.bigplaybtn.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.seekview .seeker {
|
||||||
|
display: none;
|
||||||
|
width: 120px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #9be0ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seeker .seekbar {
|
||||||
|
background-color: #00b0ff;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.seekview:hover > .seeker {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
20
src/dashboard.js
Normal file
20
src/dashboard.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import $ from 'jquery'
|
||||||
|
|
||||||
|
function dashboard () {
|
||||||
|
$.get("/dashboard/data", function (res) {
|
||||||
|
if (res.error) {
|
||||||
|
window.location.href = "/"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullURL = window.location.origin + "/player/" + res.name
|
||||||
|
$('#myStream').attr('src', fullURL)
|
||||||
|
$('#stream_url').text(fullURL).attr("href", fullURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
$('#show_key').click(function () {
|
||||||
|
$('#show_key').html(window.STREAM_KEY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {start: dashboard}
|
17
src/index.js
Normal file
17
src/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import 'bootstrap'
|
||||||
|
|
||||||
|
// Style
|
||||||
|
import bootstrap from 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
import dash from './css/dashboard.css'
|
||||||
|
import index from './css/index.css'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import dashboard from './dashboard.js'
|
||||||
|
|
||||||
|
bootstrap.ref()
|
||||||
|
if (window.STREAM_KEY) {
|
||||||
|
dash.ref()
|
||||||
|
dashboard.start()
|
||||||
|
} else {
|
||||||
|
index.ref()
|
||||||
|
}
|
248
src/player.js
Normal file
248
src/player.js
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import css from './css/player.css'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
|
let player
|
||||||
|
let vid
|
||||||
|
let btn
|
||||||
|
let bigbtn
|
||||||
|
let overlay
|
||||||
|
let time
|
||||||
|
let fscrn
|
||||||
|
let mutebtn
|
||||||
|
let hideTimeout
|
||||||
|
let hls
|
||||||
|
let lstat
|
||||||
|
let opts
|
||||||
|
let volumebar
|
||||||
|
let retry
|
||||||
|
let vidReady = false
|
||||||
|
let shouldHide = true
|
||||||
|
let infscr = false
|
||||||
|
|
||||||
|
window.onload = function (argument) {
|
||||||
|
player = document.querySelector('.livecnt')
|
||||||
|
vid = player.querySelector('#stream')
|
||||||
|
overlay = player.querySelector('.overlay')
|
||||||
|
btn = overlay.querySelector('#playbtn')
|
||||||
|
time = overlay.querySelector('#duration')
|
||||||
|
fscrn = overlay.querySelector('#fullscrbtn')
|
||||||
|
mutebtn = overlay.querySelector('#mutebtn')
|
||||||
|
lstat = overlay.querySelector('.live')
|
||||||
|
opts = overlay.querySelector('.controls')
|
||||||
|
bigbtn = overlay.querySelector('.bigplaybtn')
|
||||||
|
volumebar = overlay.querySelector('#volume_seek')
|
||||||
|
|
||||||
|
player.addEventListener('mousemove', resetHide)
|
||||||
|
|
||||||
|
opts.addEventListener('mouseenter', () => {
|
||||||
|
shouldHide = false
|
||||||
|
})
|
||||||
|
|
||||||
|
opts.addEventListener('mouseleave', () => {
|
||||||
|
shouldHide = true
|
||||||
|
})
|
||||||
|
|
||||||
|
opts.addEventListener('mousemove', () => {
|
||||||
|
shouldHide = false
|
||||||
|
})
|
||||||
|
|
||||||
|
bigbtn.addEventListener('click', () => {
|
||||||
|
toggleStream()
|
||||||
|
})
|
||||||
|
|
||||||
|
volumebar.addEventListener('click', (e) => {
|
||||||
|
vid.volume = ((e.pageX - volumebar.offsetLeft) / volumebar.clientWidth)
|
||||||
|
updateVolume()
|
||||||
|
})
|
||||||
|
|
||||||
|
let mousewheelevt = (/Firefox/i.test(navigator.userAgent)) ? 'DOMMouseScroll' : 'mousewheel'
|
||||||
|
|
||||||
|
mutebtn.addEventListener(mousewheelevt, (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
let scrollAmnt = (e.wheelDelta == null ? e.detail * -40 : e.wheelDelta)
|
||||||
|
|
||||||
|
if (scrollAmnt < 0) {
|
||||||
|
vid.volume = clampAddition(-0.1)
|
||||||
|
} else {
|
||||||
|
vid.volume = clampAddition(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVolume()
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls()
|
||||||
|
hls.loadSource(STREAM_SERVER + STREAM_NAME + '.m3u8')
|
||||||
|
hls.attachMedia(vid)
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
vidReady = true
|
||||||
|
liveStatus(true)
|
||||||
|
})
|
||||||
|
hls.on(Hls.Events.ERROR, (e) => {
|
||||||
|
vidReady = false
|
||||||
|
liveStatus(false)
|
||||||
|
|
||||||
|
if (!vid.paused) {
|
||||||
|
toggleStream()
|
||||||
|
resetHide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert('Your browser does not support HLS streaming!')
|
||||||
|
}
|
||||||
|
|
||||||
|
playbtn.addEventListener('click', toggleStream)
|
||||||
|
mutebtn.addEventListener('click', toggleSound)
|
||||||
|
fscrn.addEventListener('click', toggleFullscreen)
|
||||||
|
|
||||||
|
document.addEventListener('webkitfullscreenchange', exitHandler, false)
|
||||||
|
document.addEventListener('mozfullscreenchange', exitHandler, false)
|
||||||
|
document.addEventListener('fullscreenchange', exitHandler, false)
|
||||||
|
document.addEventListener('MSFullscreenChange', exitHandler, false)
|
||||||
|
|
||||||
|
vid.addEventListener('timeupdate', updateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampAddition (val) {
|
||||||
|
let volume = vid.volume
|
||||||
|
|
||||||
|
if (volume + val > 1) {
|
||||||
|
volume = 1
|
||||||
|
} else if (volume + val < 0) {
|
||||||
|
volume = 0
|
||||||
|
} else {
|
||||||
|
volume += val
|
||||||
|
}
|
||||||
|
|
||||||
|
return volume.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBigBtn (show) {
|
||||||
|
if (show) {
|
||||||
|
bigbtn.className = 'bigplaybtn'
|
||||||
|
} else {
|
||||||
|
bigbtn.className = 'bigplaybtn hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVolume () {
|
||||||
|
let inner = volumebar.querySelector('.seekbar')
|
||||||
|
inner.style.width = vid.volume * 100 + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveStatus (status) {
|
||||||
|
if (status) {
|
||||||
|
lstat.innerHTML = 'live now'
|
||||||
|
lstat.className = 'live'
|
||||||
|
clearTimeout(retry)
|
||||||
|
if (vid.paused) {
|
||||||
|
showBigBtn(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lstat.innerHTML = 'offline'
|
||||||
|
lstat.className = 'live offline'
|
||||||
|
retry = setTimeout(() => {
|
||||||
|
if (vidReady) return
|
||||||
|
hls.loadSource(readFromURI())
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide () {
|
||||||
|
if (vid.paused || !shouldHide) {
|
||||||
|
overlay.className = 'overlay'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.className = 'overlay hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHide () {
|
||||||
|
overlay.className = 'overlay'
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
|
||||||
|
if (vid.paused) return
|
||||||
|
if (!shouldHide) return
|
||||||
|
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
hide()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTime () {
|
||||||
|
let minutes = Math.floor(vid.currentTime / 60)
|
||||||
|
let seconds = Math.floor(vid.currentTime - minutes * 60)
|
||||||
|
time.innerHTML = minutes + ':' + (seconds < 10 ? '0' + seconds : seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStream () {
|
||||||
|
if (!vid) return
|
||||||
|
if (!vidReady) return
|
||||||
|
if (vid.paused) {
|
||||||
|
vid.play()
|
||||||
|
btn.innerHTML = '<i class="fa fa-pause fa-fw"></i>'
|
||||||
|
showBigBtn(false)
|
||||||
|
} else {
|
||||||
|
vid.pause()
|
||||||
|
btn.innerHTML = '<i class="fa fa-play fa-fw"></i>'
|
||||||
|
showBigBtn(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSound () {
|
||||||
|
let muteicon = mutebtn.querySelector('.fa')
|
||||||
|
if (vid.muted) {
|
||||||
|
vid.muted = false
|
||||||
|
muteicon.className = 'fa fa-volume-up fa-fw'
|
||||||
|
} else {
|
||||||
|
vid.muted = true
|
||||||
|
muteicon.className = 'fa fa-volume-off fa-fw'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitHandler () {
|
||||||
|
if (!(document.fullScreen || document.webkitIsFullScreen || document.mozFullScreen)) {
|
||||||
|
infscr = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infscr) {
|
||||||
|
fscrn.innerHTML = '<i class="fa fa-compress fa-fw"></i>'
|
||||||
|
} else {
|
||||||
|
fscrn.innerHTML = '<i class="fa fa-expand fa-fw"></i>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen () {
|
||||||
|
if (vid.enterFullscreen) {
|
||||||
|
if (!document.fullScreen) {
|
||||||
|
player.requestFullScreen()
|
||||||
|
infscr = true
|
||||||
|
} else {
|
||||||
|
document.cancelFullScreen()
|
||||||
|
}
|
||||||
|
} else if (vid.webkitEnterFullscreen) {
|
||||||
|
if (!document.webkitIsFullScreen) {
|
||||||
|
player.webkitRequestFullScreen()
|
||||||
|
infscr = true
|
||||||
|
} else {
|
||||||
|
document.webkitCancelFullScreen()
|
||||||
|
}
|
||||||
|
} else if (vid.mozRequestFullScreen) {
|
||||||
|
if (!document.mozFullScreen) {
|
||||||
|
player.mozRequestFullScreen()
|
||||||
|
infscr = true
|
||||||
|
} else {
|
||||||
|
document.mozCancelFullScreen()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Your browser doesn\'t support fullscreen!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onblur = () => {
|
||||||
|
shouldHide = true
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onfocus = () => {
|
||||||
|
shouldHide = true
|
||||||
|
}
|
75
templates/dashboard.html
Normal file
75
templates/dashboard.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.STREAM_KEY = "{{ stream }}"
|
||||||
|
</script>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>IcyTV Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0">
|
||||||
|
<a class="navbar-brand col-sm-3 col-md-2 mr-0" href="/">IcyTV</a>
|
||||||
|
<ul class="navbar-nav px-3">
|
||||||
|
<li class="nav-item text-nowrap">
|
||||||
|
<a class="nav-link" href="/logout">Sign out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||||
|
<div class="sidebar-sticky">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="#">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-home"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||||
|
Dashboard <span class="sr-only">(current)</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main role="main" class="col-md-9 ml-sm-auto col-lg-10 pt-3 px-4">
|
||||||
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
|
||||||
|
<h1 class="h2">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe allowfullscreen src="" id="myStream" width="1542" height="651" style="display: block; width: 1542px; height: 651px;"></iframe>
|
||||||
|
<h1 class="h2">Information</h1>
|
||||||
|
<p class="lead">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<label>Stream Server</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<span>{{ server }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<label>Stream Key</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a href="#" id="show_key">Show Stream Key</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<label>Stream Link</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a href="" id="stream_url"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/dist/main.bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
44
templates/index.html
Normal file
44
templates/index.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>IcyTV Private Livestreaming Server</title>
|
||||||
|
</head>
|
||||||
|
<body class="text-center">
|
||||||
|
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
|
||||||
|
<header class="masthead mb-auto">
|
||||||
|
<div class="inner">
|
||||||
|
<h3 class="masthead-brand">IcyTV</h3>
|
||||||
|
<nav class="nav nav-masthead justify-content-center">
|
||||||
|
<a class="nav-link active" href="/">Home</a>
|
||||||
|
{% if session.uuid %}
|
||||||
|
<a class="nav-link" href="/logout">{{ session.username }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link" href="/login">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main role="main" class="inner cover">
|
||||||
|
<h1 class="cover-heading">IcyTV</h1>
|
||||||
|
<p class="lead">This is a private livestreaming server for Icy Network members.</p>
|
||||||
|
{% if streamer %}
|
||||||
|
<p class="lead">Welcome back, {{ session.username }}!</p>
|
||||||
|
<p class="lead">
|
||||||
|
<a href="/dashboard" class="btn btn-lg btn-secondary">My Dashboard</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="lead">Email me at <code>evert(at)lunasqu.ee</code> if you're interested.</p>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="mastfoot mt-auto">
|
||||||
|
<div class="inner">
|
||||||
|
<p>Copyleft © 2018 - <a href="https://icynet.eu/" target="_blank">Icy Network</a></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="/dist/main.bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
35
templates/player.html
Normal file
35
templates/player.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>IcyTV - {{ name }}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||||
|
<script type="text/javascript">
|
||||||
|
const STREAM_SERVER = "{{ server }}"
|
||||||
|
const STREAM_NAME = "{{ name }}"
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="livecnt">
|
||||||
|
<div class="videobox">
|
||||||
|
<video id="stream"></video>
|
||||||
|
</div>
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="live offline">offline</div>
|
||||||
|
<div class="bigplaybtn hidden"><i class="fa fa-play fa-fw"></i></div>
|
||||||
|
<div class="controls">
|
||||||
|
<div id="playbtn" class="inline ilbtn"><i class="fa fa-play fa-fw"></i></div>
|
||||||
|
<span class="seekview">
|
||||||
|
<div id="mutebtn" class="inline ilbtn"><i class="fa fa-volume-up fa-fw"></i></div>
|
||||||
|
<div class="seeker" id="volume_seek">
|
||||||
|
<div class="seekbar" style="width: 100%"></div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<div id="duration">0:00</div>
|
||||||
|
<div id="fullscrbtn" class="right ilbtn"><i class="fa fa-expand fa-fw"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/dist/player.bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
25
webpack.config.js
Normal file
25
webpack.config.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: {
|
||||||
|
main: './src/index.js',
|
||||||
|
player: './src/player.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: '[name].bundle.js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
{ loader: "style-loader/useable" },
|
||||||
|
{ loader: "css-loader" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url-loader?limit=10000&mimetype=application/font-woff" },
|
||||||
|
{ test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "file-loader" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
Reference in New Issue
Block a user