show list of users in chat
このコミットが含まれているのは:
コミット
bf2ed3c6c6
2
app.py
2
app.py
|
@ -2,4 +2,4 @@ from website import create_app
|
|||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=False)
|
||||
app.run(debug=False, threaded=True)
|
||||
|
|
|
@ -48,11 +48,6 @@ def comment(text, token, c_response, c_token, nonce):
|
|||
|
||||
if viewers[token]['banned']:
|
||||
failure_reason = N_BANNED
|
||||
elif now < viewers[token]['comment'] + CHAT_TIMEOUT:
|
||||
failure_reason = N_TOOFAST
|
||||
elif len(viewers[token]['recent_comments']) + 1 >= FLOOD_THRESHOLD:
|
||||
failure_reason = N_FLOOD
|
||||
viewers[token]['verified'] = False
|
||||
elif not viewers[token]['verified'] and c_token not in captchas:
|
||||
failure_reason = N_CAPTCHA_MISSING
|
||||
elif not viewers[token]['verified'] and captchas[c_token] != c_response:
|
||||
|
@ -60,6 +55,11 @@ def comment(text, token, c_response, c_token, nonce):
|
|||
elif secrets.randbelow(50) == 0:
|
||||
failure_reason = N_CAPTCHA_RANDOM
|
||||
viewers[token]['verified'] = False
|
||||
elif now < viewers[token]['last_comment'] + CHAT_TIMEOUT:
|
||||
failure_reason = N_TOOFAST
|
||||
elif len(viewers[token]['recent_comments']) + 1 >= FLOOD_THRESHOLD:
|
||||
failure_reason = N_FLOOD
|
||||
viewers[token]['verified'] = False
|
||||
else:
|
||||
try:
|
||||
nonces.remove(nonce)
|
||||
|
@ -73,7 +73,7 @@ def comment(text, token, c_response, c_token, nonce):
|
|||
'hidden': False,
|
||||
'time': dt.strftime('%H:%M'),
|
||||
'date': dt.strftime('%F %T')})
|
||||
viewers[token]['comment'] = now
|
||||
viewers[token]['last_comment'] = now
|
||||
viewers[token]['recent_comments'].append(now)
|
||||
viewers[token]['verified'] = True
|
||||
behead_chat()
|
||||
|
|
|
@ -17,14 +17,22 @@ viewers = viewership.viewers
|
|||
def new_token():
|
||||
return secrets.token_hex(8)
|
||||
|
||||
def get_token(form=False):
|
||||
token = (request.form if form else request.args).get('token')
|
||||
if token == None or len(token) >= 256 or len(token) < 4:
|
||||
token = request.cookies.get('token')
|
||||
if token and (len(token) >= 256 or len(token) < 4):
|
||||
token = None
|
||||
return token
|
||||
|
||||
@current_app.route('/')
|
||||
def index(token=None):
|
||||
token = token or request.args.get('token') or request.cookies.get('token') or new_token()
|
||||
token = token or get_token() or new_token()
|
||||
try:
|
||||
viewership.video_was_corrupted.remove(token)
|
||||
except KeyError:
|
||||
pass
|
||||
viewership.setdefault(token)
|
||||
viewership.made_request(token)
|
||||
response = Response(render_template('index.html', token=token)) # TODO: add a view of the chat only, either as an arg here or another route
|
||||
response.set_cookie('token', token)
|
||||
return response
|
||||
|
@ -34,17 +42,37 @@ def index(token=None):
|
|||
def broadcaster():
|
||||
return index(token=BROADCASTER_TOKEN)
|
||||
|
||||
## simple version, just reads the file from disk
|
||||
#@current_app.route('/stream.m3u8')
|
||||
#def playlist():
|
||||
# if not stream.is_online():
|
||||
# return abort(404)
|
||||
#
|
||||
# token = get_token() or new_token()
|
||||
# try:
|
||||
# viewership.video_was_corrupted.remove(token)
|
||||
# except KeyError:
|
||||
# pass
|
||||
# response = send_from_directory(SEGMENTS_DIR, 'stream.m3u8', add_etags=False)
|
||||
# response.headers['Cache-Control'] = 'no-cache'
|
||||
# response.set_cookie('token', token)
|
||||
# return response
|
||||
|
||||
@current_app.route('/stream.m3u8')
|
||||
def playlist():
|
||||
if not stream.is_online():
|
||||
return abort(404)
|
||||
|
||||
token = request.args.get('token') or request.cookies.get('token') or new_token()
|
||||
token = get_token() or new_token()
|
||||
try:
|
||||
viewership.video_was_corrupted.remove(token)
|
||||
except KeyError:
|
||||
pass
|
||||
response = send_from_directory(SEGMENTS_DIR, 'stream.m3u8', add_etags=False)
|
||||
|
||||
try:
|
||||
file_wrapper = wrap_file(request.environ, stream.TokenPlaylist(token))
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
response = Response(file_wrapper, mimetype='application/x-mpegURL')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.set_cookie('token', token)
|
||||
return response
|
||||
|
@ -54,7 +82,7 @@ def segment_init():
|
|||
if not stream.is_online():
|
||||
return abort(404)
|
||||
|
||||
token = request.args.get('token') or request.cookies.get('token') or new_token()
|
||||
token = get_token() or new_token()
|
||||
try:
|
||||
viewership.video_was_corrupted.remove(token)
|
||||
except KeyError:
|
||||
|
@ -69,7 +97,7 @@ def segment_arbitrary(n):
|
|||
if not stream.is_online():
|
||||
return abort(404)
|
||||
|
||||
token = request.args.get('token') or request.cookies.get('token')
|
||||
token = get_token()
|
||||
try:
|
||||
viewership.video_was_corrupted.remove(token)
|
||||
except KeyError:
|
||||
|
@ -86,7 +114,7 @@ def segment_arbitrary(n):
|
|||
def segments():
|
||||
if not stream.is_online():
|
||||
return abort(404)
|
||||
token = request.args.get('token') or request.cookies.get('token')
|
||||
token = get_token()
|
||||
try:
|
||||
viewership.video_was_corrupted.remove(token)
|
||||
except KeyError:
|
||||
|
@ -106,16 +134,27 @@ def segments():
|
|||
# or no-js users have to load it and js users get it in heartbeat, or no-js users always load it like /chat and js users get it in heartbeat
|
||||
@current_app.route('/chat')
|
||||
def chat_iframe():
|
||||
token = request.args.get('token') or request.cookies.get('token') or new_token()
|
||||
token = get_token() or new_token()
|
||||
viewership.made_request(token)
|
||||
|
||||
include_user_list = bool(request.args.get('users', default=0, type=int))
|
||||
messages = (message for message in chat.messages if not message['hidden'])
|
||||
messages = zip(messages, range(CHAT_SCROLLBACK)) # show at most CHAT_SCROLLBACK messages
|
||||
messages = (message for message, _ in messages)
|
||||
return render_template('chat-iframe.html', token=token, messages=messages, default_nickname=viewership.default_nickname, broadcaster=token == BROADCASTER_TOKEN, broadcaster_colour=BROADCASTER_COLOUR, debug=request.args.get('debug'))
|
||||
return render_template('chat-iframe.html',
|
||||
token=token,
|
||||
messages=messages,
|
||||
people=viewership.get_people_list(),
|
||||
default_nickname=viewership.default_nickname,
|
||||
broadcaster=token == BROADCASTER_TOKEN,
|
||||
broadcaster_colour=BROADCASTER_COLOUR,
|
||||
debug=request.args.get('debug'),
|
||||
len=len)
|
||||
|
||||
@current_app.route('/heartbeat')
|
||||
def heartbeat():
|
||||
token = request.args.get('token') or request.cookies.get('token')
|
||||
viewership.heartbeat(token)
|
||||
token = get_token()
|
||||
viewership.made_request(token)
|
||||
online = stream.is_online()
|
||||
start_abs, start_rel = stream.get_start(absolute=True, relative=True)
|
||||
return {'viewers': viewership.count(),
|
||||
|
@ -127,7 +166,8 @@ def heartbeat():
|
|||
|
||||
@current_app.route('/comment-box')
|
||||
def comment_iframe(token=None):
|
||||
token = token or request.args.get('token') or request.cookies.get('token') or new_token()
|
||||
token = token or get_token() or new_token()
|
||||
viewership.made_request(token)
|
||||
|
||||
try:
|
||||
preset = viewership.preset_comment_iframe.pop(token)
|
||||
|
@ -154,12 +194,14 @@ def comment_iframe(token=None):
|
|||
|
||||
@current_app.route('/comment', methods=['POST'])
|
||||
def comment():
|
||||
token = request.form.get('token') or request.cookies.get('token') or new_token()
|
||||
token = get_token(form=True) or new_token()
|
||||
nonce = request.form.get('nonce')
|
||||
message = request.form.get('message', '').replace('\r', '').replace('\n', ' ').strip()
|
||||
c_response = request.form.get('captcha')
|
||||
c_token = request.form.get('captcha-token')
|
||||
|
||||
viewership.made_request(token)
|
||||
|
||||
failure_reason = chat.comment(message, token, c_response, c_token, nonce)
|
||||
|
||||
viewership.preset_comment_iframe[token] = {'note': failure_reason, 'message': message if failure_reason else ''}
|
||||
|
@ -171,10 +213,12 @@ def comment():
|
|||
# for changing your appearance. So this is not done for now.
|
||||
@current_app.route('/settings', methods=['POST'])
|
||||
def settings():
|
||||
token = request.form.get('token') or request.cookies.get('token') or new_token()
|
||||
token = get_token(form=True) or new_token()
|
||||
nickname = request.form.get('nickname', '')
|
||||
password = request.form.get('password', '')
|
||||
|
||||
viewership.made_request(token)
|
||||
|
||||
note, ok = chat.set_nickname(nickname, token)
|
||||
if ok:
|
||||
if request.form.get('remove-tripcode'):
|
||||
|
@ -195,8 +239,11 @@ def mod():
|
|||
|
||||
@current_app.route('/stream-info')
|
||||
def stream_info():
|
||||
token = request.args.get('token') or request.cookies.get('token')
|
||||
token = get_token()
|
||||
embed_images = bool(request.args.get('embed', type=int))
|
||||
|
||||
viewership.made_request(token)
|
||||
|
||||
start_abs, start_rel = stream.get_start(absolute=True, relative=True)
|
||||
online = stream.is_online()
|
||||
return render_template('stream-info-iframe.html',
|
||||
|
@ -211,6 +258,12 @@ def stream_info():
|
|||
token=token,
|
||||
broadcaster_colour=BROADCASTER_COLOUR)
|
||||
|
||||
@current_app.route('/users')
|
||||
def users():
|
||||
token = get_token()
|
||||
viewership.made_request(token)
|
||||
return render_template('users.html', token=token, people=viewership.get_people_list(), default_nickname=viewership.default_nickname, broadcaster_colour=BROADCASTER_COLOUR, len=len)
|
||||
|
||||
@current_app.route('/static/radial.apng')
|
||||
def radial():
|
||||
response = send_from_directory(DIR_STATIC, 'radial.apng', mimetype='image/png', add_etags=False)
|
||||
|
@ -218,9 +271,15 @@ def radial():
|
|||
response.expires = response.date
|
||||
return response
|
||||
|
||||
@current_app.route('/static/external/<path:path>')
|
||||
def third_party(path):
|
||||
response = send_from_directory(DIR_STATIC_EXTERNAL, path, add_etags=False)
|
||||
@current_app.route('/static/<fn>')
|
||||
def _static(fn):
|
||||
response = send_from_directory(DIR_STATIC, fn, add_etags=False)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
@current_app.route('/static/external/<fn>')
|
||||
def third_party(fn):
|
||||
response = send_from_directory(DIR_STATIC_EXTERNAL, fn, add_etags=False)
|
||||
response.headers['Cache-Control'] = 'public, max-age=604800, immutable'
|
||||
response.expires = response.date + datetime.timedelta(days=7)
|
||||
return response
|
||||
|
@ -235,4 +294,4 @@ def add_header(response):
|
|||
|
||||
@current_app.route('/teapot')
|
||||
def teapot():
|
||||
return {'short': True, 'stout': True}, 418
|
||||
return {'short': True, 'stout': True}, 418
|
長すぎる行があるためファイル差分は表示されません
長すぎる行があるためファイル差分は表示されません
ファイル差分が大きすぎるため省略します
差分を読み込み
長すぎる行があるためファイル差分は表示されません
ファイル差分が大きすぎるため省略します
差分を読み込み
長すぎる行があるためファイル差分は表示されません
|
@ -1,43 +1,77 @@
|
|||
.banner {
|
||||
text-align: center;
|
||||
margin: 0.5em;
|
||||
padding-bottom:0.5em;
|
||||
border-bottom:1px solid gray;
|
||||
font-size:125%;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid #434343;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #ff8280;
|
||||
fill: #ff8280;
|
||||
.hue-rotate {
|
||||
filter: hue-rotate(150deg);
|
||||
}
|
||||
|
||||
#stream {
|
||||
height: calc(100vw * 9 / 16);
|
||||
}
|
||||
|
||||
#chat {
|
||||
#chat-window iframe {
|
||||
height: calc(100vh - (100vw * 9 / 16 + 20em));
|
||||
min-height: 12em;
|
||||
}
|
||||
|
||||
#chat {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
#noscript {
|
||||
text-align: center;
|
||||
margin-top: calc(-100vw * 9 / 16 / 2 - 1ex);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
iframe.stream-info {
|
||||
#stream-info {
|
||||
min-height: 7em;
|
||||
height: 7em;
|
||||
}
|
||||
|
||||
#source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.users-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
svg.users-logo {
|
||||
fill: white;
|
||||
}
|
||||
.chat-banner-left {
|
||||
float: left;
|
||||
width: 1em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.users-logo:hover {
|
||||
border-radius: 6px;
|
||||
background-color: #2f2f2f;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
#users-toggle:checked + .banner .users-logo {
|
||||
border-radius: 6px;
|
||||
background-color: #3f3f3f;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
}
|
||||
#users-toggle:checked + .banner svg.users-logo {
|
||||
fill: #0078e7;
|
||||
}
|
||||
#users-toggle:checked + .banner .users-logo img {
|
||||
filter: brightness(0) saturate(100%) invert(41%) sepia(92%) saturate(5359%) hue-rotate(195deg) brightness(97%) contrast(103%); /* thanks to https://codepen.io/sosuke/pen/Pjoqqp */
|
||||
}
|
||||
form[target="users"] {
|
||||
display: none;
|
||||
}
|
||||
#users-toggle:checked + .banner form[target="chat"] button {
|
||||
filter: revert;
|
||||
}
|
||||
@media screen and (min-width:48em) {
|
||||
#chat {
|
||||
#chat-window iframe {
|
||||
height: calc(100vh - 1px - 10em);
|
||||
}
|
||||
#stream {
|
||||
|
@ -46,42 +80,36 @@ iframe.stream-info {
|
|||
#noscript {
|
||||
margin-top: calc(-100vw * 2 / 3 * 9 / 16 / 2 - 1ex);
|
||||
}
|
||||
iframe.stream-info {
|
||||
#stream-info {
|
||||
height: calc(100vh - 100vw * 2 / 3 * 9 / 16 - 2px);
|
||||
}
|
||||
#source {
|
||||
display: revert;
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
border:0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dark-theme a:hover,
|
||||
.dark-theme a:active {
|
||||
color: #00b6f0;
|
||||
}
|
||||
|
||||
.dark-theme a {
|
||||
color: #a0a0a0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.dark-theme {
|
||||
background-color: #232323;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.dark-theme input,
|
||||
.dark-theme select,
|
||||
.dark-theme textarea {
|
||||
color: #232323;
|
||||
}
|
||||
|
||||
/* workaround to make some custom user css work
|
||||
e.g. https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/usercss/2154.user.css */
|
||||
div[class="vjs-text-track-display"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ let streamAbsoluteStart, streamRelativeStart, streamTimer, streamTimerLastUpdate
|
|||
// ensure only one heartbeat is sent at a time
|
||||
let heartIsBeating = false;
|
||||
|
||||
let streamInfoFrame = window.frames['stream-info'];
|
||||
let streamInfoFrame = window.frames["stream-info"];
|
||||
streamInfoFrame.addEventListener("load", function() {
|
||||
console.log("stream info iframe loaded");
|
||||
|
||||
|
@ -58,9 +58,9 @@ function currentSegment() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateStreamStatus(msg, color, showRefreshButton) {
|
||||
function updateStreamStatus(msg, backgroundColor, showRefreshButton) {
|
||||
streamStatus.innerHTML = msg;
|
||||
streamLight.style.color = color;
|
||||
streamLight.style.backgroundColor = backgroundColor;
|
||||
if ( showRefreshButton ) {
|
||||
refreshButton.style.display = null;
|
||||
} else {
|
||||
|
@ -102,6 +102,10 @@ function resetRadialLoader() {
|
|||
radialLoader = newElement;
|
||||
}
|
||||
|
||||
// TODO: this
|
||||
function fitFrame(frame) {
|
||||
}
|
||||
|
||||
// get stream info from the server (viewer count, current segment, if stream is online, etc.)
|
||||
function heartbeat() {
|
||||
if ( heartIsBeating ) {
|
||||
|
@ -176,8 +180,8 @@ function heartbeat() {
|
|||
}
|
||||
|
||||
xhr.send();
|
||||
} catch (e) {
|
||||
} catch ( error ) {
|
||||
heartIsBeating = false;
|
||||
throw e;
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,8 @@
|
|||
<meta http-equiv="conent-security-policy" content="default-src 'none'">
|
||||
{% if not debug %}<meta http-equiv="refresh" content="8">{% endif %}
|
||||
<style>
|
||||
body {margin: 0.5em;margin-bottom: 0;min-height: calc(100vh - 1em);transform: scaleX(-1);}
|
||||
body {margin: 0.5em;margin-bottom: 0;min-height: calc(100vh - 1em);}
|
||||
#messages {transform: scaleX(-1);}
|
||||
.inline-block {display: inline-block;}
|
||||
.rotate {transform: rotate(-180deg);}
|
||||
.reverse {direction: rtl;}
|
||||
|
@ -20,11 +21,15 @@
|
|||
font-size: 90%;
|
||||
font-family: monospace;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
.message {margin-left: 0.5em;overflow-wrap: break-word;unicode-bidi: isolate;}
|
||||
.camera {transform: scaleX(-1);text-shadow: 0px 0px 6px #{{ broadcaster_colour.hex() }};cursor: help;margin-right:0.25em;}
|
||||
.time {font-size: 80%;color: gray;vertical-align:middle;margin-right:0.5em;}
|
||||
|
||||
#users {margin-left:0.5em;}
|
||||
.group-name {color:white;margin-bottom:0.125em;}
|
||||
|
||||
input[type="submit"] {padding: 0;margin-bottom: 0.5em;}
|
||||
div#refresh {
|
||||
color: white;
|
||||
|
@ -54,7 +59,7 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="messages">
|
||||
{% if broadcaster %}
|
||||
<form action="/mod" method="post">
|
||||
<div class="reverse">
|
||||
|
@ -89,6 +94,22 @@
|
|||
{% endif %}
|
||||
|
||||
<a href="" style="text-decoration: none;"><div id="refresh" class="rotate">Manual refresh required</div></a>
|
||||
|
||||
</div>
|
||||
<div id="users" style="display: none;">
|
||||
{% with broadcaster = people['broadcaster'] %}
|
||||
{% if broadcaster %}
|
||||
<div style="margin-bottom:1em;">
|
||||
<div class="group-name">Broadcaster</div>
|
||||
<span class="camera" title="Broadcaster">🎥</span><span class="name" style="color:#{{ broadcaster['colour'].hex() }};">{{ broadcaster['nickname'] or default_nickname(broadcaster['token']) }}</span>{% if broadcaster['tripcode']['string'] %}<div class="tripcode" style="background-color:#{{ person['tripcode']['background_colour'].hex() }};color:#{{ person['tripcode']['foreground_colour'].hex() }};">{{ broadcaster['tripcode']['string'] }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="group-name">Users ({{ len(people['users']) }})</div>
|
||||
{% for person in people['users'] %}
|
||||
<div class="person">
|
||||
<span class="name" style="color:#{{ person['colour'].hex() }};">{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}<sup>{{ person['tag'] }}</sup>{% endif %}</span>{% if person['tripcode']['string'] %}{% if tag %}<span style="margin-right:0.125em;"></span>{% endif %}<div class="tripcode" style="background-color:#{{ person['tripcode']['background_colour'].hex() }};color:#{{ person['tripcode']['foreground_colour'].hex() }};">{{ person['tripcode']['string'] }}</div>{% endif %}{% endwith %}{% if person['token'] == token %}<span style="margin-left:0.5em;color:white;">(You)</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
|
@ -218,4 +218,4 @@
|
|||
</form>
|
||||
<div id="note">{{ note }}</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -2,18 +2,41 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/static/external/video-js.css" rel="stylesheet">
|
||||
<!-- https://unpkg.com/video.js@7.12.1/dist/video-js.min.css -->
|
||||
<link href="/static/external/video-js.min.css" rel="stylesheet">
|
||||
<link href="/static/external/pure-min.css" rel="stylesheet">
|
||||
<link href="/static/external/grids-responsive-min.css" rel="stylesheet">
|
||||
<link href="/static/platform.css" rel="stylesheet">
|
||||
<script src="/static/external/video.js"></script>
|
||||
<script src="/static/external/videojs-contrib-hls.js"></script>
|
||||
<!-- <script src="/static/external/dash.all.debug.js"></script>
|
||||
<script src="/static/external/videojs-dash.js"></script>-->
|
||||
<noscript><style>#videojs {display: none;}</style></noscript>
|
||||
<!-- https://unpkg.com/video.js@7.12.1/dist/video.min.js -->
|
||||
<script src="/static/external/video.min.js"></script>
|
||||
<!-- https://unpkg.com/@videojs/http-streaming@2.7.1/dist/videojs-http-streaming.min.js -->
|
||||
<script src="/static/external/videojs-http-streaming.min.js"></script>
|
||||
<noscript>
|
||||
<style>
|
||||
#videojs {
|
||||
display: none;
|
||||
}
|
||||
#users-container {
|
||||
display: none;
|
||||
}
|
||||
#users-toggle:checked ~ #chat-window #users-container {
|
||||
display: revert;
|
||||
}
|
||||
#users-toggle:checked ~ #chat-window #chat-container {
|
||||
display: none;
|
||||
}
|
||||
#users-toggle:checked + .banner form[target="chat"] {
|
||||
display: none;
|
||||
}
|
||||
#users-toggle:checked + .banner form[target="users"] {
|
||||
display: revert;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<div class=pure-g>
|
||||
<!-- TODO: get rid of PureCSS dependency here; the noscript block on top of the video is too large because the PureCSS files take longer to load -->
|
||||
<div class="pure-u-1 pure-u-md-2-3">
|
||||
<div id="stream" class="border">
|
||||
<!-- https://stackoverflow.com/questions/41014197/how-can-i-play-a-m3u8-file-video-using-the-html5-video-element -->
|
||||
|
@ -24,36 +47,79 @@
|
|||
<video style="width: 100%;height: 100%;" controls autoplay src="{{ url_for('segments', token=token) }}">
|
||||
</noscript>
|
||||
</div>
|
||||
<div id="stream-info-container">
|
||||
<noscript><iframe class="stream-info" src="{{ url_for('stream_info', token=token, embed=1) }}"></iframe></noscript>
|
||||
</div>
|
||||
<div style="margin: -2.75em 0 1.5em 1.25em;"><a href="https://gitlab.com/ninya9k/onion-livestreaming" target="_blank">source code</a></div>
|
||||
<div id="stream-info-container"><noscript><iframe id="stream-info" src="{{ url_for('stream_info', token=token, embed=1) }}"></iframe></noscript></div>
|
||||
<div id="source" style="margin: -2.75em 0 1.5em 1.25em;"><a href="https://gitlab.com/ninya9k/onion-livestreaming" target="_blank">source code</a></div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<div class="border">
|
||||
<div class="banner" style="padding-bottom:0.5em;border-bottom:1px solid gray;padding-left: 1em;font-size:125%;">
|
||||
<!-- TODO: mobile tooltip -->
|
||||
<input id="users-toggle" type="checkbox" style="display: none;">
|
||||
<div class="banner">
|
||||
<div class="chat-banner-left">
|
||||
<label for="users-toggle" title="Users in chat">
|
||||
<svg class="users-logo" style="display:none;" version="1.1" viewBox="0 0 20 20" x="0px" y="0px"><g><path fill-rule="evenodd" d="M7 2a4 4 0 00-1.015 7.87c-.098.64-.651 1.13-1.318 1.13A2.667 2.667 0 002 13.667V18h2v-4.333c0-.368.298-.667.667-.667.908 0 1.732-.363 2.333-.953.601.59 1.425.953 2.333.953.369 0 .667.299.667.667V18h2v-4.333A2.667 2.667 0 009.333 11c-.667 0-1.22-.49-1.318-1.13A4.002 4.002 0 007 2zM5 6a2 2 0 104 0 2 2 0 00-4 0z" clip-rule="evenodd"></path><path d="M14 11.83V18h4v-3.75c0-.69-.56-1.25-1.25-1.25a.75.75 0 01-.75-.75v-.42a3.001 3.001 0 10-2 0z"></path></g>
|
||||
</svg>
|
||||
<noscript><div class="users-logo"><img src="/static/external/users.png"></div></noscript>
|
||||
</label>
|
||||
</div>
|
||||
<span>Stream chat</span>
|
||||
<form target="chat" action="/chat" method="get" class="banner" style="float: right;margin: 0;width: 1em;">
|
||||
<form target="chat" action="/chat" method="get" style="float: right;margin: 0;width: 1em;">
|
||||
<input id="token" type="hidden" name="token" value="{{ token }}">
|
||||
<input type="checkbox" style="display:none;" name="debug">
|
||||
<button style="font-weight: bold;background: none;border: none;margin: 0;padding: 0;cursor: pointer;" type="submit" class="">🔄</button>
|
||||
<button class="hue-rotate" title="Refresh chat window" style="font-weight: bold;background: none;border: none;margin: 0;padding: 0;cursor: pointer;" type="submit" class="">🔄</button>
|
||||
</form>
|
||||
<form target="users" action="/users" method="get" style="float: right;margin: 0;width: 1em;">
|
||||
<input id="token" type="hidden" name="token" value="{{ token }}">
|
||||
<input type="checkbox" style="display:none;" name="debug">
|
||||
<button title="Refresh chat window" style="font-weight: bold;background: none;border: none;margin: 0;padding: 0;cursor: pointer;" type="submit" class="">🔄</button>
|
||||
</form>
|
||||
</div>
|
||||
<iframe id="chat" name="chat" style="transform: rotate(180deg);transform: scaleY(-1);" src="{{ url_for('chat_iframe', token=token) }}"></iframe>
|
||||
<div id="chat-window">
|
||||
<div id="chat-container"><noscript><iframe id="chat" name="chat" src="{{ url_for('chat_iframe', token=token) }}"></iframe></noscript></div>
|
||||
<div id="users-container"><noscript><iframe name="users" src="{{ url_for('users') }}"></iframe></noscript></div>
|
||||
</div>
|
||||
<iframe style="height:6em;border-top:1px solid #434343;padding-top:0.5em;" src="{{ url_for('comment_iframe', token=token) }}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const streamInfoContainer = document.getElementById("stream-info-container")
|
||||
const streamInfoWithEmbeds = streamInfoContainer.querySelector("*");
|
||||
const streamInfoNoEmbeds = document.createElement("iframe");
|
||||
streamInfoNoEmbeds.id = "stream-info";
|
||||
streamInfoNoEmbeds.className = "stream-info";
|
||||
streamInfoNoEmbeds.src = "{{ url_for('stream_info', token=token) }}";
|
||||
streamInfoContainer.replaceChild(streamInfoNoEmbeds, streamInfoWithEmbeds);
|
||||
function replaceFrameURL(frameContainerId, newUrl, newId, newName) {
|
||||
const frameContainer = document.getElementById(frameContainerId);
|
||||
const oldFrame = frameContainer.querySelector("*");
|
||||
const newFrame = document.createElement("iframe");
|
||||
newFrame.id = newId;
|
||||
newFrame.name = newName;
|
||||
newFrame.src = newUrl;
|
||||
frameContainer.replaceChild(newFrame, oldFrame);
|
||||
}
|
||||
replaceFrameURL("stream-info-container", "{{ url_for('stream_info', token=token) }}", "stream-info", "");
|
||||
replaceFrameURL("chat-container", "{{ url_for('chat_iframe') }}?token={{ token }}&users=1", "chat", "chat");
|
||||
|
||||
/* ensure that svg only appears when scripts are enabled */
|
||||
for ( element of document.querySelectorAll("svg") ) {
|
||||
element.style.display = null;
|
||||
}
|
||||
|
||||
const chat = document.getElementById("chat");
|
||||
const usersToggle = document.getElementById("users-toggle");
|
||||
usersToggle.onchange = function() {
|
||||
const chatMessages = chat.contentDocument.getElementById("messages");
|
||||
const chatUsers = chat.contentDocument.getElementById("users");
|
||||
if ( chatUsers == null || chatMessages == null )
|
||||
return;
|
||||
if ( usersToggle.checked ) {
|
||||
chatMessages.style.display = "none";
|
||||
chatUsers.style.display = null;
|
||||
chat.style.transform = "revert";
|
||||
} else {
|
||||
chatUsers.style.display = "none";
|
||||
chatMessages.style.display = null;
|
||||
chat.style.transform = null;
|
||||
}
|
||||
}
|
||||
chat.addEventListener("load", usersToggle.onchange);
|
||||
</script>
|
||||
<script src="/static/platform.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
|
@ -211,4 +211,4 @@
|
|||
var streamRelativeStart = {{ stream_start_rel_json }};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
|
@ -35,4 +35,8 @@ def gen_colour(seed, background=BACKGROUND_COLOUR, *avoid):
|
|||
return colour
|
||||
if best_score == None or score > best_score:
|
||||
best_colour = colour
|
||||
return best_colour
|
||||
return best_colour
|
||||
|
||||
def tag(colour):
|
||||
tag = ((colour[2] & 0xf0) >> 2) | ((colour[1] & 0xf0) >> 6) | ((colour[2] & 0xf0) >> 10)
|
||||
return f'#{tag:03x}'
|
|
@ -1,9 +1,11 @@
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from flask import abort
|
||||
from website.constants import SEGMENTS_DIR, SEGMENTS_M3U8, SEGMENT_INIT, STREAM_PIDFILE, STREAM_START, STREAM_TITLE
|
||||
|
||||
RE_SEGMENT = re.compile(r'stream(?P<number>\d+).m4s')
|
||||
RE_SEGMENT_OR_INIT = re.compile(r'\b(stream(?P<number>\d+)\.m4s|init\.mp4)\b')
|
||||
RE_SEGMENT = re.compile(r'stream(?P<number>\d+)\.m4s')
|
||||
|
||||
def _segment_number(fn):
|
||||
if fn == SEGMENT_INIT: return None
|
||||
|
@ -72,4 +74,34 @@ def get_start(absolute=True, relative=False):
|
|||
elif absolute:
|
||||
return start
|
||||
elif relative:
|
||||
return diff
|
||||
return diff
|
||||
|
||||
|
||||
class TokenPlaylist:
|
||||
'''
|
||||
Append '?token={token}' to each segment in the playlist
|
||||
'''
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.fp = open(SEGMENTS_M3U8)
|
||||
self.leftover = b''
|
||||
|
||||
def read(self, n):
|
||||
if self.token == None:
|
||||
return self.fp.read(n)
|
||||
|
||||
leftover = self.leftover
|
||||
chunk = b''
|
||||
while True:
|
||||
line = self.fp.readline()
|
||||
if len(line) == 0:
|
||||
break
|
||||
injected_line = RE_SEGMENT_OR_INIT.sub(lambda match: f'{match.group()}?token={self.token}', line)
|
||||
chunk += injected_line.encode()
|
||||
if len(chunk) >= n:
|
||||
chunk, self.leftover = chunk[:n], chunk[n:]
|
||||
break
|
||||
return leftover + chunk
|
||||
|
||||
def close(self):
|
||||
self.fp.close()
|
|
@ -24,11 +24,12 @@ def default_nickname(token):
|
|||
return ANON_DEFAULT_NICKNAME
|
||||
|
||||
def setdefault(token):
|
||||
if token in viewers:
|
||||
if token in viewers or token == None:
|
||||
return
|
||||
viewers[token] = {'token': token,
|
||||
'comment': float('-inf'),
|
||||
'heartbeat': int(time.time()),
|
||||
'last_comment': float('-inf'),
|
||||
'last_request': float('-inf'),
|
||||
'first_request': float('-inf'),
|
||||
'verified': False,
|
||||
'recent_comments': [],
|
||||
'nickname': None,
|
||||
|
@ -36,17 +37,21 @@ def setdefault(token):
|
|||
'banned': False,
|
||||
'tripcode': tripcode.default(),
|
||||
'broadcaster': False}
|
||||
c = viewers[token]['colour']
|
||||
tag = ((c[2] & 0xf0) >> 2) | ((c[1] & 0xf0) >> 6) | ((c[2] & 0xf0) >> 10)
|
||||
viewers[token]['tag'] = f'#{tag:03x}'
|
||||
viewers[token]['tag'] = colour.tag(viewers[token]['colour'])
|
||||
if token == BROADCASTER_TOKEN:
|
||||
viewers[token]['broadcaster'] = True
|
||||
viewers[token]['colour'] = BROADCASTER_COLOUR
|
||||
viewers[token]['verified'] = True
|
||||
|
||||
def heartbeat(token):
|
||||
# TODO: generalise this and reduce the number of keys in last_request; comment is used for flood detection and the rest is for get_user_list
|
||||
def made_request(token):
|
||||
if token == None:
|
||||
return
|
||||
now = int(time.time())
|
||||
setdefault(token)
|
||||
viewers[token]['heartbeat'] = int(time.time())
|
||||
if viewers[token]['first_request'] == float('-inf'):
|
||||
viewers[token]['first_request'] = now
|
||||
viewers[token]['last_request'] = now
|
||||
|
||||
def view_segment(n, token=None, check_exists=True):
|
||||
# n is None if segment_hook is called in ConcatenatedSegments and the current segment is init.mp4
|
||||
|
@ -60,18 +65,20 @@ def view_segment(n, token=None, check_exists=True):
|
|||
with lock:
|
||||
now = int(time.time())
|
||||
segment_views.setdefault(n, []).append({'time': now, 'token': token})
|
||||
if token:
|
||||
made_request(token)
|
||||
print(f'seg{n}: {token}')
|
||||
|
||||
def count_site_tokens():
|
||||
'''
|
||||
Return the number of viewers who have sent a heartbeat or commented in the last 30 seconds
|
||||
'''
|
||||
n = 0
|
||||
now = int(time.time())
|
||||
for token in set(viewers):
|
||||
if max(viewers[token]['heartbeat'], viewers[token]['comment']) >= now - VIEW_COUNTING_PERIOD:
|
||||
n += 1
|
||||
return n
|
||||
#def count_site_tokens():
|
||||
# '''
|
||||
# Return the number of viewers who have sent a heartbeat or commented in the last 30 seconds
|
||||
# '''
|
||||
# n = 0
|
||||
# now = int(time.time())
|
||||
# for token in set(viewers):
|
||||
# if max(viewers[token]['last_request']['heartbeat'], viewers[token]['last_request']['comment']) >= now - VIEW_COUNTING_PERIOD:
|
||||
# n += 1
|
||||
# return n
|
||||
|
||||
# TODO: account for the stream restarting; segments will be out of order
|
||||
def count_segment_views(exclude_token_views=True):
|
||||
|
@ -139,4 +146,20 @@ def count():
|
|||
with lock:
|
||||
a, b = count_segment_tokens(), count_segment_views(exclude_token_views=True)
|
||||
print(f'count_segment_tokens={a}; count_segment_views={b}')
|
||||
return a + b
|
||||
return a + b
|
||||
|
||||
# TODO: separate users into watching and not watching
|
||||
def get_people_list():
|
||||
now = int(time.time())
|
||||
users = filter(lambda token: viewers[token]['first_request'] > float('-inf'), viewers)
|
||||
users = filter(lambda token: now - viewers[token]['last_request'] < 24, users)
|
||||
users = sorted(users, key=lambda token: viewers[token]['first_request'])
|
||||
|
||||
people = {'broadcaster': None, 'users': []}
|
||||
for token in users:
|
||||
if viewers[token]['broadcaster']:
|
||||
people['broadcaster'] = viewers[token]
|
||||
else:
|
||||
people['users'].append(viewers[token])
|
||||
|
||||
return people
|
||||
|
|
読み込み中…
新しいイシューから参照