diff --git a/static/style.css b/static/style.css index d755cc479b4aef218235117a20eeb0c647304000..c0edc32ff2e525e763e19c37e0741a6b55c8223a 100644 --- a/static/style.css +++ b/static/style.css @@ -245,6 +245,13 @@ header h1 a:not(:first-of-type) { margin-top: 24%; } +body.inactive #stream{ + background-color: black; + border: 1em solid black; + box-sizing: border-box; + min-height: 26em; +} + @keyframes slideInFromLeft { 0% { transform: translateX(-100%); diff --git a/static/sync-stream.js b/static/sync-stream.js index 96e4fbce5a42bee19f3d3092ef1ba9d92f26fb0f..9e989d3c412cad04033114a5091b32f0545c38c9 100644 --- a/static/sync-stream.js +++ b/static/sync-stream.js @@ -1,4 +1,6 @@ var socket = io(); +let hasEverRun = false; +let player = null; // Extract foobar from the .stream-foobar key of an element function extractStreamKey(e) { @@ -20,10 +22,23 @@ function hasStream(streamlist, k) { return streamlist.some(({ key }) => key === k); } +// Returns the Stream from the streamlist +function getStream(streamlist, k) { + return streamlist.find(({ key }) => key === k); +} + // Send a message to the server when the socket is established socket.on('connect', function() { let key = getStreamKey() socket.emit('join', {"key" : key}); + // Ask about the state of the stream + socket.emit('stream_info', {"key" : key}); +}); + +// After initial connect, receive a streamlist +socket.on('stream_info', function(data) { + var stream = JSON.parse(data) + updateStream(stream, "update"); }); // Send a message to the server when the socket is established @@ -50,16 +65,21 @@ socket.on('stream_added', function(data) { var streamlist = JSON.parse(data["list"]) let streamkey = getStreamKey(); if (hasStream(streamlist, streamkey)) { - updateStream("activate", streamkey); + let stream = getStream(streamlist, streamkey); + updateStream(stream, "added"); + }else{ + console.log("..but "+streamkey+" was not in streamlist"); + console.log(streamlist); } }); // New streamlist arrives here when webserver gets notivied of a stream removal socket.on('stream_removed', function(data) { console.log('Stream ' + data['key'] + ' removed.'); - var streamlist = JSON.parse(data["list"]) let streamkey = getStreamKey(); - updateStream("deactivate", streamkey); + if (streamkey == data['key']) { + updateStream(streamkey, "removed"); + } }); @@ -76,35 +96,227 @@ function updateViewCount(viewercount) { } // Update the stream when it has been added -function updateStream(what, streamkey) { +function updateStream(stream, what) { + if (what == "update") { + console.log("Stream "+stream.key+" updated") + if (!stream.active) { + deactivateStream(stream.key); + }else{ + hasEverRun = true; + } + } else if (what == "added") { + console.log("Stream "+stream.key+" has started"); + hasEverRun = true; + activateStream(stream); + }else if (what == "removed") { + console.log("Stream "+stream+" has stopped"); + deactivateStream(stream); + } +} + + +function deactivateStream(streamkey) { + // Add inactive class to body + document.body.classList.add("inactive"); + + // Pause the player + if (player !== null) { + player.pause(); + } - if (what == "activate") { - console.log("Stream "+streamkey+" has started"); - }else if (what == "deactivate") { - console.log("Stream "+streamkey+" has stopped"); + // Get width and height from player + var videoPlayer = document.getElementById("stream"); + var w = videoPlayer.offsetWidth; + var h = videoPlayer.offsetHeight; + + // Remove video player + if (document.getElementById("stream") !== null) { + document.querySelectorAll('#stream').forEach(e => e.remove()); + } + + if (player !== null) { + player.dispose(); + } + + // Get parent element + let content = document.getElementsByTagName("content")[0]; + + // Create a div + let div = document.createElement("div"); + div.id = "stream"; + div.classList.add("stream-"+streamkey) + div.style.height = h+"px"; + div.style.width = w+"px"; + + // Add a notice to it + let h2 = document.createElement("h2"); + if (hasEverRun) { + h2.textContent = "The stream has ended" + h2.classList.add("stopped"); + document.body.classList.add("stopped"); + }else{ + h2.textContent = "The stream hasn't started yet (or it doesn't exist)" + h2.classList.add("not_started"); + document.body.classList.add("not-started"); } + h2.id = "no-stream-notice"; + div.appendChild(h2); + + content.prepend(div); +} + +function activateStream(stream) { + document.body.classList.remove("inactive"); + document.body.classList.remove("not-started"); + document.body.classList.remove("stopped"); + + // Remove div placeholder + if (document.getElementById("stream") !== null) { + document.querySelectorAll('#stream').forEach(e => e.remove()); + } + + // TODO: Add description, ... + addPlayer(stream.key); + player = initializePlayer(); + player.load(); + player.play(); + + updateDescription(stream); } +function updateDescription(stream) { + if (stream.description !== null && stream.description !== "") { + let descriptions = document.querySelectorAll('.description'); + if (descriptions.length === 0) { + buildDescriptionBlock(); + } + let description = document.querySelectorAll('.description')[0]; + + // TODO: handle markdown...? + description.textContent = stream.description; + }else{ + // There was formerly a description which is now gone, so destroy description + document.querySelectorAll('.description').forEach(e => e.remove()); + } +} + +function buildDescriptionBlock() { + let content = document.getElementsByTagName("content")[0]; + let section = document.createElement("section"); + section.classList.add("description"); + content.appendChild(section); +} + + +// When the window is loaded check for errors window.onload = function() { + if (!document.body.classList.contains("inactive")){ + player = initializePlayer(); + } + // Run this block with a delay setTimeout(function() { - var player = document.getElementById("stream"); - if (player.classList.contains("vjs-error")) { - console.log("Contained Error"); - var intervalId = setInterval(checkIfStillErrored, 2000); - clearInterval(intervalId); + var videoPlayer = document.getElementById("stream"); + + if (!document.body.classList.contains("inactive")){ + // If there is an error try every two seconds if the player now finds the + // video. Once it is found, remove the interval + if (videoPlayer.classList.contains("vjs-error")) { + var intervalId = setInterval(function() { + checkIfStillErrored(intervalId) ; + }, 2000); + } + + // Autoplay with delay if possible + player.play(); + }else{ + deactivateStream(getStreamKey()); } - }, 1000); + + }, 200); } -function checkIfStillErrored() { - var player = document.getElementById("stream"); - if (player.classList.contains("vjs-error")) { - console.log("Still errored"); - location.reload(); +function checkIfStillErrored(intervalId) { + var videoPlayer = document.getElementById("stream"); + if (videoPlayer.classList.contains("vjs-error")) { + console.log("Stream still errored, trying to reload it"); + player.pause(); + player.load(); }else{ - console.log("Not errored anymore"); + console.log("Error seems resolved"); + player.load(); + player.play(); + clearInterval(intervalId); } +} + + +function addPlayer(streamkey) { + // Get parent element + let content = document.getElementsByTagName("content")[0]; + + // Create player + let videojs = document.createElement("video-js"); + videojs.id = "stream"; + videojs.classList.add("vjs-default-skin", "stream-"+streamkey, ); + videojs.setAttribute("data-setup", '{"fluid": true, "liveui": true}'); + videojs.toggleAttribute('controls'); + + let source = document.createElement("source"); + source.src = "../hls/"+streamkey+".m3u8"; + source.type = "application/x-mpegURL" + + videojs.prepend(source); + content.prepend(videojs) +} + + +function initializePlayer() { + var player = videojs('stream'); + player.autoplay('any'); + + function displayMuteifNeeded(player) { + if (player.muted()){ + let title = document.querySelector("#page_title"); + if (title.querySelector(".muted") == null) { + let a = document.createElement("a"); + let img = document.createElement("img"); + img.src = "/static/mute.svg"; + a.classList.add("muted"); + a.onclick = function() { + player.muted(false); + displayMuteifNeeded(player); + }; + a.appendChild(img); + title.appendChild(a); + } + }else{ + let title = document.querySelector("#page_title"); + title.querySelectorAll('.muted').forEach(e => e.remove()); + } + } + + player.on('play', () => { + displayMuteifNeeded(player); + }); + + player.on("volumechange",function(){ + displayMuteifNeeded(player); + }); + + // player.on('error', () => { + // player.createModal('Retrying connection'); + // if (player.error().code === 4) { + // this.player.retryLock = setTimeout(() => { + // player.src({ + // src: data.url + // }); + // player.load(); + // }, 2000); + // } + // }); + + return player; } \ No newline at end of file diff --git a/streamviewer/server.py b/streamviewer/server.py index 860c5f2d5c9b2f0a334ad1a72fba9396e76b4b2a..6679fc239299371f570c1460680fabecbf9ded16 100644 --- a/streamviewer/server.py +++ b/streamviewer/server.py @@ -10,7 +10,7 @@ from flaskext.markdown import Markdown from flask_socketio import SocketIO, join_room, leave_room from .config import initialize_config, APPLICATION_NAME, DEFAULT_CONFIG -from .streams import Stream, StreamList, value_to_flag +from .streams import Stream, StreamList, value_to_flag, key_if_not_None # Initialization @@ -67,17 +67,21 @@ def stream(streamkey): # Strip potential trailing slashes streamkey = streamkey.rstrip("/") stream = streamlist.get_stream(streamkey) + streamkey = key_if_not_None(stream, "key", that=streamkey) + description = key_if_not_None(stream, "description") # Render a different Template if the stream is missing if stream is None: + existed = False # Stream was Missing, log warning - app.logger.warning("Looking for stream {}, but it didn't exist".format(streamkey)) - return render_template("stream_missing.html", application_name=APPLICATION_NAME, page_title=config["application"]["page_title"], streamkey=streamkey, list_streams=config["application"]["list_streams"]), 404 + running_since = None + app.logger.info("Client {} looked for non-existent stream {}".format(request.remote_addr, streamkey)) else: - app.logger.debug("Looking for {}/{}.m3u8".format(config["application"]["hls_path"], streamkey)) + existed = True + app.logger.debug("Client requests stream {} ({}/{}.m3u8)".format(streamkey, config["application"]["hls_path"], streamkey)) running_since = humanize.naturaldelta(dt.timedelta(seconds=stream.active_since())) # Everything ok, return Stream - return render_template('stream.html', application_name=APPLICATION_NAME, page_title=config["application"]["page_title"], hls_path=config["application"]["hls_path"], streamkey=stream.key, description=stream.description, running_since=running_since) + return render_template('stream.html', application_name=APPLICATION_NAME, page_title=config["application"]["page_title"], hls_path=config["application"]["hls_path"], streamkey=streamkey, description=description, running_since=running_since, existed=existed) @app.route('/', methods = ['GET']) @@ -168,17 +172,26 @@ def client_list_connected(): socketio.emit('stream_list', {'list': json_list}) -@socketio.on('connect_single') -def client_single_connected(data): - if data is not None: - app.logger.info('Client (single page > {}) connected via socket.io'.format(data)) - @socketio.on('stream_list') def send_streamlist(): json_list = streamlist.json_list() socketio.emit('stream_list', {'list': json_list}) +@socketio.on('stream_info') +def send_streaminfo(data): + if type(data) is dict and "key" in data.keys(): + app.logger.info('Client wants info about stream {}'.format(data['key'])) + key = data["key"] + stream = streamlist.get_stream(key) + if stream is not None: + json = stream.to_json() + app.logger.debug('Sending Stream info\n{}'.format(json)) + socketio.emit('stream_info', json) + else: + app.logger.warning('Client {} asked for info on non-existing stream {}'.format(request.remote_addr, data['key'])) + + @socketio.on('join') def on_join(data): app.logger.info('Client connected to stream {}'.format(data['key'])) @@ -187,6 +200,7 @@ def on_join(data): count = streamlist.add_viewer(key) socketio.emit('viewercount', {'count': count, 'direction': 'up'}, room=key) + @socketio.on('leave') def on_leave(data): app.logger.info('Client left to stream {}'.format(data['key'])) @@ -195,5 +209,6 @@ def on_leave(data): count = streamlist.remove_viewer(key) socketio.emit('viewercount', {'count': count, 'direction': 'down'}, room=key) + if __name__ == '__main__': socketio.run(app) \ No newline at end of file diff --git a/streamviewer/streams.py b/streamviewer/streams.py index af1ddaacecacad98a19b0bd99abe637ead4cbe87..51c6a8c71b4905e6e416b8554d6f1a41ff16e71c 100644 --- a/streamviewer/streams.py +++ b/streamviewer/streams.py @@ -1,6 +1,7 @@ #!/usr/bin/env python #-*- coding: utf-8 -*- import json +import copy from typing import Optional, NewType, List, Any import datetime as dt @@ -38,6 +39,25 @@ def none_if_no_key_value_otherwise(d: dict, key: str, that=None) -> Optional[Any return d[key] +def key_if_not_None(o, key: str, that=None): + """ + Return the object.key or object["key"] if it exists, + otherwise return that (per default None) + """ + if o is None: + return that + elif type(o) is dict: + if key in o.keys(): + return o[key] + else: + return that + else: + try: + return getattr(o, key) + except TypeError: + return that + + def value_to_flag(value) -> bool: """ Return False if the value was None, otherwise return wether it was in the list @@ -94,8 +114,19 @@ class Stream(): return "{} ({})".format(self.key, ", ".join(attributes)) return "{}".format(self.key) + def __iter__(self): + clone = copy.deepcopy(self) + del clone.protected + del clone.password + del clone.unlisted + for key in clone.__dict__: + yield key, getattr(clone, key) + + def to_dict(self) -> dict: + return dict(self) + def to_json(self): - return json.dumps(self, default=jsonconverter, + return json.dumps(self.to_dict(), default=jsonconverter, sort_keys=True, indent=4) @property @@ -480,10 +511,12 @@ class StreamList(): return self - - def jsonconverter(o): - if isinstance(o, dt.datetime): + o = copy.deepcopy(o) + if isinstance(o, Stream): + # We use to_dict() here, to keep fields like password private : ) + return o.to_dict() + elif isinstance(o, dt.datetime): return o.__str__() else: return o.__dict__ \ No newline at end of file diff --git a/templates/stream.html b/templates/stream.html index 7d34234ecc13f1764a4d6d446f64af48a74d9e22..afaea2f328bcfd16c8c598bfa89c48d8026ee645 100644 --- a/templates/stream.html +++ b/templates/stream.html @@ -14,52 +14,30 @@ {% endblock %} {% block content %} - <video-js id="stream" class="vjs-default-skin stream-{{ streamkey }}" data-setup='{"fluid": true, "liveui": true}' controls> - <source src="../hls/{{ streamkey }}.m3u8" type="application/x-mpegURL"> - </video-js> - {% if description %} - <section class="description"> - {{ description|markdown }} - </section> + {% if existed %} + <video-js id="stream" class="vjs-default-skin stream-{{ streamkey }}" data-setup='{"fluid": true, "liveui": true}' controls> + <source src="../hls/{{ streamkey }}.m3u8" type="application/x-mpegURL"> + </video-js> + {% if description %} + <section class="description"> + {{ description|markdown }} + </section> + {% endif %} + {% else %} + <div id="stream" class="stream-{{ streamkey }}"> + <h2 class="stopped" id="no-stream-notice">The stream hasn't started yet (or it doesn't exist)</h2> + </div> {% endif %} {% endblock %} {% block footer %} {{ super() }} - <script src="{{ url_for('static', filename='video.min.js') }}"></script> - <script src="{{ url_for('static', filename='videojs-http-streaming.min.js') }}"></script> + {% if not existed %} <script> - let player = videojs('stream'); - player.autoplay('any'); - - function displayMuteifNeeded(player) { - if (player.muted()){ - let title = document.querySelector("#page_title"); - if (title.querySelector(".muted") == null) { - let a = document.createElement("a"); - let img = document.createElement("img"); - img.src = "{{ url_for('static', filename='mute.svg') }}"; - a.classList.add("muted"); - a.onclick = function() { - player.muted(false); - displayMuteifNeeded(player); - }; - a.appendChild(img); - title.appendChild(a); - } - }else{ - let title = document.querySelector("#page_title"); - title.querySelectorAll('.muted').forEach(e => e.remove()); - } - } - - player.on('play', () => { - displayMuteifNeeded(player); - }); - - player.on("volumechange",function(){ - displayMuteifNeeded(player); - }); + document.body.classList.add("inactive"); </script> + {% endif %} + <script src="{{ url_for('static', filename='video.min.js') }}"></script> + <script src="{{ url_for('static', filename='videojs-http-streaming.min.js') }}"></script> <script src="{{ url_for('static', filename='sync-stream.js') }}"></script> {% endblock %} diff --git a/templates/stream_missing.html b/templates/stream_missing.html deleted file mode 100644 index 6ad08e4a13aee4482b91cf93266b024aced95d8b..0000000000000000000000000000000000000000 --- a/templates/stream_missing.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "layout.html" %} -{% block title %}{{ page_title }}/{{ streamkey }}{% endblock %} -{% block head %} - <link href="{{ url_for('static', filename='video-js.css') }}" rel="stylesheet"> - {{ super() }} -{% endblock %} - -{% block header %} - <h1 id="page_title"><a href="/">{{ page_title }}</a>/<s>{{ streamkey }}</s></h1> -{% endblock %} - -{% block content %} - <h2 class="404">Uh-oh!</h2> - <h2>The stream {{ streamkey }} doesn't seem to be here (anymore?)</h2> - {% if list_streams %} - <p>Have a look at the <a href="/streams">list of current streams</a></p> - {% endif %} -{% endblock %} - -{% block footer %} - {{ super() }} -{% endblock %} - - -