コミットを比較

...

33 コミット

作成者 SHA1 メッセージ 日付
n9k a5b66c8d5b Add js appearance form (not complete c.f. nojs) 2022-03-07 18:45:17 +13:00
n9k af9536298b Fix js memory leak
Already existing tripcode css rules were being re-inserted because of a typo.
2022-03-07 16:36:37 +13:00
n9k b113688306 Nojs chat form: use `:checked` instead of `:target`
This works around a bug in mobile Firefox where under certain cirucmstances two
elements inside an iframe both become the iframe's target elment at the same
time, which breaks the CSS logic so instead of exactly one form being displayed,
nothing is displayed.
2022-03-07 16:36:37 +13:00
n9k b288337f1d Keyboard accessible js captcha 2022-03-07 16:36:37 +13:00
n9k 6274903fc3 Autofocus chat form textarea 2022-03-07 16:36:37 +13:00
n9k b62084564e Show notice from websocket in js chat form 2022-03-07 16:36:37 +13:00
n9k d9d29b6527 Add config option for old tripcode algorithm 2022-03-07 16:36:37 +13:00
n9k d61b503e43 Add broadcaster insignia
Also surrounded users' name tags in <b> (HTML) tags.
2022-03-07 16:36:37 +13:00
n9k 9ebcf57de5 Nojs uptime counter 2022-03-07 16:36:36 +13:00
n9k 5590fbbdbe Chat: use breaking space between name and message 2022-03-07 12:56:08 +13:00
n9k 2bb23ab4c4 Rename templates & routes to fit naming scheme 2022-03-07 12:56:08 +13:00
n9k c103de9849 Add meta viewport tags 2022-03-07 12:56:08 +13:00
n9k bb3002ffd5 Nojs chat: add fallback meta refresh to redirect url
Hacky workaround of weird behaviour in Firefox where on a page whose url has a
fragment/hash/anchor in it, sometimes a urlless meta refresh tag will jump to
the element instead of refreshing the page. Same thing happens if the meta
refresh tag's url component is the same as the page's url.
2022-03-07 12:56:08 +13:00
n9k 46fce9c393 Add nojs 'Reload stream' button
The nojs button appears when the stream is online and the user is not watching.
The js button appears when the stream is online and the media element either
(1) is not using the network or (2) fires an error event.
2022-03-07 12:56:08 +13:00
n9k 2763891a4e Nojs chat: only deverify user when they leave a message
Matches the behaviour of the js chat. Makes it so if you submit an empty
message but with a correct captcha, you won't be deverified and given another
captcha until you successfully send a message (and exceed the flood threshold).
Previously you could fill in the captcha with no message and be given back a
new captcha, which doesn't make that much sense.
2022-03-07 12:56:08 +13:00
n9k d4b0594103 Add `?token=...` to every url 2022-03-07 12:56:08 +13:00
n9k 6eda20a244 Add 'Reload stream' button in js 2022-03-07 12:56:08 +13:00
n9k edddbf00bc Show tripcodes in users list 2022-03-07 12:56:08 +13:00
n9k 7962de87e3 WS: combine `uptime` and `viewership` into `stats`
If the stream is offline, `stats` is null, otherwise it contains uptime and
viewership.
2022-03-07 12:56:08 +13:00
n9k ba90e18e30 Minor changes to the appearance of the users list
Made the 'Users in chat' header above the overflow area, so it always stays on
top. Now using `visibility: hidden;` instead of `display: none;` to show/hide
messages/users so that nojs css animations don't reset.
2022-03-07 12:56:08 +13:00
n9k a970368ee6 Nojs users list: add meta refresh tag & timeout 2022-03-07 12:56:08 +13:00
n9k 84ec253001 Show list of watching/non-watching users with js 2022-03-07 12:56:08 +13:00
n9k bfa77b738d Tell websockets which users are watching
This adds a field 'watching' in `user_for_websocket` that's True iff WATCHING,
False iff NOTWATCHING, and None otherwise (since clients don't need to know if
a user is tentative or absent). When the value of this field changes for any
user, they get added to the update buffer (like with any other change).

Removed race condition in `t_sunset_users`: `broadcast_users_update` was being
called *after* a user was removed from memory (and for each user being removed,
which was redundant). In that scenario if there's a user in the update buffer
and `t_sunset_users` wins the race between it and `t_broadcast_users_update`,
then when `t_sunset_users` calls `broadcast_users_update` a KeyError would be
raised since the user's already been removed.

Fixed unintended behaviour of `t_sunset_users`: it was removing users based on
the result of `is_visible`, so users who were actually tenative (as opposed to
absent) were being removed.
2022-03-07 12:54:35 +13:00
n9k 2b1cf7d7b0 CSS: make users button lighter 2022-03-07 12:54:35 +13:00
n9k 1b26ddb816 Nojs chat: add list of watching/non-watching users 2022-03-07 12:54:35 +13:00
n9k 3583005123 Link to git repos 2022-03-07 12:54:35 +13:00
n9k 8589216bf1 Send new captcha over websocket with js 2022-03-07 12:54:35 +13:00
n9k 3016705783 Keep track of stream viewership (number of viewers) 2022-03-07 12:54:35 +13:00
n9k da6e0352b8 Beautify nojs chat template, strip jinja whitespace 2022-03-07 12:54:35 +13:00
n9k a3b18bdc9f Background task for broadcasting title/uptime changes 2022-03-07 12:54:35 +13:00
n9k c36d2b2c38 Catch exception when inbound websocket data is not JSON 2022-03-07 12:54:35 +13:00
n9k 8b4d6e8c09 Get stream title from disk
By default from `title.txt`. Also replace newlines with spaces when setting the
title in js, for parity with the nojs info iframe.
2022-03-07 12:54:35 +13:00
n9k 8d1f273a99 Show and update stream uptime in js 2022-03-07 12:54:33 +13:00
26個のファイルの変更1533行の追加284行の削除

1
.gitignore vendored
ファイルの表示

@ -1,3 +1,4 @@
__pycache__/
stream/
*~
title.txt

ファイルの表示

@ -1,3 +1,15 @@
# onion-livestreaming
# anonstream
Recipe for livestreaming over the Tor network
Recipe for livestreaming over Tor
## Repo
The canonical location of this repo is https://git.076.ne.jp/ninya9k/anonstream.
These mirrors also exist:
* https://gitlab.com/ninya9k/anonstream
* https://github.com/ninya9k/anonstream
## Credits
* [setting](https://thenounproject.com/icon/setting-685325/) ([/anonstream/static/settings.svg](https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg)) by [ulimicon](https://thenounproject.com/unlimicon/) is licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).

ファイルの表示

@ -20,7 +20,12 @@ def create_app(config_file):
print('Broadcaster password:', auth_password)
app = Quart('anonstream')
app.jinja_options.update({
'trim_blocks': True,
'lstrip_blocks': True,
})
app.config.update({
'SECRET_KEY_STRING': config['secret_key'],
'SECRET_KEY': config['secret_key'].encode(),
'AUTH_USERNAME': config['auth']['username'],
'AUTH_PWHASH': auth_pwhash,
@ -32,6 +37,8 @@ def create_app(config_file):
'SEGMENT_SEARCH_COOLDOWN': config['segments']['search_cooldown'],
'SEGMENT_SEARCH_TIMEOUT': config['segments']['search_timeout'],
'SEGMENT_STREAM_INITIAL_BUFFER': config['segments']['stream_initial_buffer'],
'STREAM_TITLE': config['title']['file'],
'STREAM_TITLE_CACHE_LIFETIME': config['title']['file_cache_lifetime'],
'DEFAULT_HOST_NAME': config['names']['broadcaster'],
'DEFAULT_ANON_NAME': config['names']['anonymous'],
'MAX_STATES': config['memory']['states'],
@ -41,6 +48,7 @@ def create_app(config_file):
'TASK_PERIOD_ROTATE_USERS': config['tasks']['rotate_users'],
'TASK_PERIOD_ROTATE_CAPTCHAS': config['tasks']['rotate_captchas'],
'TASK_PERIOD_BROADCAST_USERS_UPDATE': config['tasks']['broadcast_users_update'],
'TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE': config['tasks']['broadcast_stream_info_update'],
'THRESHOLD_USER_NOTWATCHING': config['thresholds']['user_notwatching'],
'THRESHOLD_USER_TENTATIVE': config['thresholds']['user_tentative'],
'THRESHOLD_USER_ABSENT': config['thresholds']['user_absent'],
@ -49,6 +57,7 @@ def create_app(config_file):
'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'],
'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'],
'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']),
'CHAT_LEGACY_TRIPCODE_ALGORITHM': config['chat']['legacy_tripcode_algorithm'],
'FLOOD_DURATION': config['flood']['duration'],
'FLOOD_THRESHOLD': config['flood']['threshold'],
'CAPTCHA_LIFETIME': config['captcha']['lifetime'],
@ -80,8 +89,13 @@ def create_app(config_file):
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
# State for tasks
app.users_update_buffer = set()
app.stream_title = None
app.stream_uptime = None
app.stream_viewership = None
# Background tasks' asyncio.sleep tasks, cancelled on shutdown
app.background_sleep = set()
@app.after_serving

ファイルの表示

@ -11,19 +11,27 @@ CONFIG = current_app.config
def _generate_tripcode_digest_legacy(password):
hexdigest, _ = werkzeug.security._hash_internal(
'pbkdf2:sha256:150000',
CONFIG['SECRET_KEY'],
CONFIG['SECRET_KEY_STRING'],
password,
)
digest = bytes.fromhex(hexdigest)
return base64.b64encode(digest)[:8].decode()
def generate_tripcode_digest(password):
def _generate_tripcode_digest(password):
parts = CONFIG['SECRET_KEY'] + b'tripcode\0' + password.encode()
digest = hashlib.sha256(parts).digest()
return base64.b64encode(digest)[:8].decode()
def generate_tripcode(password, generate_digest=generate_tripcode_digest):
digest = generate_digest(password)
def generate_tripcode_digest(password):
algorithm = (
_generate_tripcode_digest_legacy
if CONFIG['CHAT_LEGACY_TRIPCODE_ALGORITHM'] else
_generate_tripcode_digest
)
return algorithm(password)
def generate_tripcode(password):
digest = generate_tripcode_digest(password)
background_colour = generate_colour(
seed='tripcode-background\0' + digest,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],

ファイルの表示

@ -1,25 +1,15 @@
import hashlib
import base64
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import current_app
from anonstream.utils.colour import generate_colour, colour_to_color
from anonstream.utils.user import Presence
CONFIG = current_app.config
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token_hash_and_tag(token):
parts = CONFIG['SECRET_KEY'] + b'token-hash\0' + token.encode()
digest = hashlib.sha256(parts).digest()
@ -29,7 +19,7 @@ def generate_token_hash_and_tag(token):
return token_hash, tag
def generate_user(timestamp, token, broadcaster):
def generate_user(timestamp, token, broadcaster, presence):
colour = generate_colour(
seed='name\0' + token,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
@ -51,6 +41,7 @@ def generate_user(timestamp, token, broadcaster):
'seen': timestamp,
'watching': -inf,
},
'presence': presence,
}
def get_default_name(user):
@ -75,17 +66,3 @@ def get_presence(timestamp, user):
return Presence.TENTATIVE
return Presence.ABSENT
def is_listed(timestamp, user):
return (
get_presence(timestamp, user)
in {Presence.WATCHING, Presence.NOTWATCHING}
)
def is_visible(timestamp, messages, user):
def user_left_messages():
return any(
message['token'] == user['token']
for message in messages
)
return is_listed(timestamp, user) or user_left_messages()

ファイルの表示

@ -2,8 +2,8 @@ from quart import current_app, request, render_template, redirect, url_for, esca
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected
from anonstream.stream import get_stream_title
from anonstream.user import add_state, pop_state, try_change_appearance, verify, deverify, BadCaptcha
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name
@ -13,20 +13,33 @@ from anonstream.utils.user import concatenate_for_notice
CONFIG = current_app.config
USERS_BY_TOKEN = current_app.users_by_token
@current_app.route('/stream.html')
@with_user_from(request)
async def nojs_stream(user):
return await render_template(
'nojs_stream.html',
user=user,
)
@current_app.route('/info.html')
@with_user_from(request)
async def nojs_info(user):
update_presence(user)
uptime, viewership = get_stream_uptime_and_viewership()
return await render_template(
'nojs_info.html',
user=user,
title=get_stream_title(),
viewership=viewership,
uptime=uptime,
title=await get_stream_title(),
Presence=Presence,
)
@current_app.route('/chat/messages.html')
@with_user_from(request)
async def nojs_chat(user):
async def nojs_chat_messages(user):
return await render_template_with_etag(
'nojs_chat.html',
'nojs_chat_messages.html',
user=user,
users_by_token=USERS_BY_TOKEN,
messages=get_scrollback(current_app.messages),
@ -36,17 +49,30 @@ async def nojs_chat(user):
@current_app.route('/chat/messages')
@with_user_from(request)
async def nojs_chat_redirect(user):
return redirect(url_for('nojs_chat', _anchor='end'))
async def nojs_chat_messages_redirect(user):
return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end'))
@current_app.route('/chat/users.html')
@with_user_from(request)
async def nojs_chat_users(user):
users_by_presence = get_users_by_presence()
return await render_template_with_etag(
'nojs_chat_users.html',
user=user,
get_default_name=get_default_name,
users_watching=users_by_presence[Presence.WATCHING],
users_notwatching=users_by_presence[Presence.NOTWATCHING],
timeout=CONFIG['THRESHOLD_NOJS_CHAT_TIMEOUT'],
)
@current_app.route('/chat/form.html')
@with_user_from(request)
async def nojs_form(user):
async def nojs_chat_form(user):
state_id = request.args.get('state', type=int)
state = pop_state(user, state_id)
prefer_chat_form = request.args.get('landing') != 'appearance'
return await render_template(
'nojs_form.html',
'nojs_chat_form.html',
user=user,
state=state,
prefer_chat_form=prefer_chat_form,
@ -57,7 +83,7 @@ async def nojs_form(user):
@current_app.post('/chat/form')
@with_user_from(request)
async def nojs_form_redirect(user):
async def nojs_chat_form_redirect(user):
comment = (await request.form).get('comment', '')
if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']:
comment = ''
@ -67,7 +93,7 @@ async def nojs_form_redirect(user):
else:
state_id = None
return redirect(url_for('nojs_form', state=state_id))
return redirect(url_for('nojs_chat_form', token=user['token'], state=state_id))
@current_app.post('/chat/message')
@with_user_from(request)
@ -87,7 +113,7 @@ async def nojs_submit_message(user):
try:
# If the comment is empty but the captcha was just solved,
# be lenient: don't raise an exception and don't create a notice
add_chat_message(
message_was_added = add_chat_message(
user,
nonce,
comment,
@ -97,11 +123,12 @@ async def nojs_submit_message(user):
notice, *_ = e.args
state_id = add_state(user, notice=notice, comment=comment)
else:
deverify(user)
state_id = None
if message_was_added:
deverify(user)
return redirect(url_for(
'nojs_form',
'nojs_chat_form',
token=user['token'],
landing='chat',
state=state_id,
@ -111,23 +138,24 @@ async def nojs_submit_message(user):
@with_user_from(request)
async def nojs_submit_appearance(user):
form = await request.form
name = form.get('name', '').strip()
color = form.get('color', '')
password = form.get('password', '')
want_delete_tripcode = form.get('clear-tripcode', type=bool)
want_change_tripcode = form.get('set-tripcode', type=bool)
if len(name) == 0 or name == get_default_name(user):
# Collect form data
name = form.get('name', '').strip()
if len(name) == 0:
name = None
errors = try_change_appearance(
user,
name,
color,
password,
want_delete_tripcode,
want_change_tripcode,
)
color = form.get('color', '')
password = form.get('password', '')
if form.get('clear-tripcode', type=bool):
want_tripcode = False
elif form.get('set-tripcode', type=bool):
want_tripcode = True
else:
want_tripcode = None
# Change appearance (iff form data was good)
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
notice = Markup('<br>').join(
concatenate_for_notice(*error.args) for error in errors
@ -137,7 +165,7 @@ async def nojs_submit_appearance(user):
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
return redirect(url_for(
'nojs_form',
'nojs_chat_form',
token=user['token'],
landing='appearance' if errors else 'chat',
state=state_id,

ファイルの表示

@ -8,7 +8,7 @@ from werkzeug.security import check_password_hash
from anonstream.broadcast import broadcast
from anonstream.user import see
from anonstream.helpers.user import generate_user
from anonstream.utils.user import generate_token
from anonstream.utils.user import generate_token, Presence
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -68,6 +68,7 @@ def with_user_from(context):
timestamp=timestamp,
token=token,
broadcaster=broadcaster,
presence=Presence.NOTWATCHING,
)
USERS_BY_TOKEN[token] = user

ファイルの表示

@ -1,28 +1,63 @@
/* token */
const token = document.body.dataset.token;
const TOKEN = document.body.dataset.token;
const TOKEN_HASH = document.body.dataset.tokenHash;
/* insert js-only markup */
const jsmarkup_style_color = '<style id="style-color"></style>'
const jsmarkup_style_tripcode_display = '<style id="style-tripcode-display"></style>'
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></style>'
const jsmarkup_info = '<div id="info_js"></div>';
const jsmarkup_info_title = '<header id="info_js__title" data-js="true"></header>';
const jsmarkup_stream = `<video id="stream_js" src="/stream.mp4?token=${encodeURIComponent(TOKEN)}" autoplay controls></video>`
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
const jsmarkup_info_float_button = '<button id="info_js__float__button">Reload stream</button>';
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
const jsmarkup_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
const jsmarkup_chat_users = `\
<article id="chat-users_js">
<h5 id="chat-users_js__watching-header"></h5>
<ul id="chat-users_js__watching"></ul>
<br>
<h5 id="chat-users_js__notwatching-header"></h5>
<ul id="chat-users_js__notwatching"></ul>
</article>`;
const jsmarkup_chat_form = `\
<form id="chat-form_js" data-js="true" action="/chat" method="post">
<input id="chat-form_js__nonce" type="hidden" name="nonce" value="">
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1" autofocus></textarea>
<div id="chat-live">
<span id="chat-live__ball"></span>
<span id="chat-live__status"><span>Not connected<span data-verbose='true'> to chat</span></span></span>
<span id="chat-live__status">
<span data-verbose="true">Not connected to chat</span>
<span data-verbose="false">&times;</span>
</span>
</div>
<input id="chat-form_js__captcha-digest" type="hidden" name="captcha-digest" disabled>
<img id="chat-form_js__captcha-image" width="72" height="30">
<input id="chat-form_js__captcha-image" type="image" width="72" height="30">
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled>
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings">
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
<article id="chat-form_js__notice">
<button id="chat-form_js__notice__button" type="button">
<header id="chat-form_js__notice__button__header"></header>
<small>Click to dismiss</small>
</button>
</article>
</form>
<form id="appearance-form_js" data-hidden="">
<span id="appearance-form_js__label-name">Name:</span>
<input id="appearance-form_js__name" name="name" maxlength="24">
<input id="appearance-form_js__color" type="color" name="color">
<span id="appearance-form_js__label-tripcode">Tripcode:</span>
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)" maxlength="1024">
<div id="appearance-form_js__row">
<article id="appearance-form_js__row__result"></article>
<input id="appearance-form_js__row__submit" type="submit" value="Update">
</div>
</form>`;
const insert_jsmarkup = () => {
const insert_jsmarkup = () => {jsmarkup_info_float_viewership
if (document.getElementById("style-color") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_color);
@ -35,16 +70,40 @@ const insert_jsmarkup = () => {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_colors);
}
if (document.getElementById("stream_js") === null) {
const parent = document.getElementById("stream");
parent.insertAdjacentHTML("beforeend", jsmarkup_stream);
}
if (document.getElementById("info_js") === null) {
const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
}
if (document.getElementById("info_js__float") === null) {
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float);
}
if (document.getElementById("info_js__float__button") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_button);
}
if (document.getElementById("info_js__float__viewership") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_viewership);
}
if (document.getElementById("info_js__float__uptime") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_uptime);
}
if (document.getElementById("info_js__title") === null) {
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_title);
}
if (document.getElementById("chat-users_js") === null) {
const parent = document.getElementById("chat__body__users");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_users);
}
if (document.getElementById("chat-messages_js") === null) {
const parent = document.getElementById("chat__messages");
const parent = document.getElementById("chat__body__messages");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_messages);
}
if (document.getElementById("chat-form_js") === null) {
@ -58,9 +117,52 @@ const stylesheet_color = document.styleSheets[1];
const stylesheet_tripcode_display = document.styleSheets[2];
const stylesheet_tripcode_colors = document.styleSheets[3];
/* override chat form notice button */
const chat_form = document.getElementById("chat-form_js");
const chat_form_notice_button = document.getElementById("chat-form_js__notice__button");
const chat_form_notice_header = document.getElementById("chat-form_js__notice__button__header");
chat_form_notice_button.addEventListener("click", (event) => {
chat_form.removeAttribute("data-notice");
chat_form_notice_header.innerText = "";
});
const show_notice = (text) => {
chat_form_notice_header.innerText = text;
chat_form.dataset.notice = "";
}
/* override chat form settings input */
const chat_appearance_form = document.getElementById("appearance-form_js");
const chat_appearance_form_result = document.getElementById("appearance-form_js__row__result");
const chat_form_settings = document.getElementById("chat-form_js__settings");
chat_form_settings.addEventListener("click", (event) => {
event.preventDefault();
if (chat_appearance_form.dataset.hidden === undefined) {
chat_appearance_form.dataset.hidden = "";
chat_form_settings.style.backgroundColor = "";
chat_appearance_form_result.innerText = "";
if (!chat_appearance_form_submit.disabled) {
chat_appearance_form.reset();
}
} else {
chat_appearance_form.removeAttribute("data-hidden");
chat_form_settings.style.backgroundColor = "#4f4f53";
}
});
/* appearance form */
const chat_appearance_form_name = document.getElementById("appearance-form_js__name");
const chat_appearance_form_color = document.getElementById("appearance-form_js__color");
const chat_appearance_form_password = document.getElementById("appearance-form_js__password");
/* create websocket */
const info_title = document.getElementById("info_js__title");
const info_viewership = document.getElementById("info_js__float__viewership");
const info_uptime = document.getElementById("info_js__float__uptime");
const chat_messages = document.getElementById("chat-messages_js");
const chat_users_watching = document.getElementById("chat-users_js__watching");
const chat_users_watching_header = document.getElementById("chat-users_js__watching-header");
const chat_users_notwatching = document.getElementById("chat-users_js__notwatching");
const chat_users_notwatching_header = document.getElementById("chat-users_js__notwatching-header");
const create_chat_message = (object) => {
const user = users[object.token_hash];
@ -76,18 +178,7 @@ const create_chat_message = (object) => {
chat_message_time.title = `${object.date} ${object.time_seconds}`;
chat_message_time.innerText = object.time_minutes;
const chat_message_name = create_chat_message_name(user);
const chat_message_tripcode_nbsp = document.createElement("span");
chat_message_tripcode_nbsp.classList.add("for-tripcode");
chat_message_tripcode_nbsp.innerHTML = "&nbsp;";
const chat_message_tripcode = document.createElement("span");
chat_message_tripcode.classList.add("tripcode");
chat_message_tripcode.classList.add("for-tripcode");
if (user.tripcode !== null) {
chat_message_tripcode.innerHTML = user.tripcode.digest;
}
const chat_message_user_components = create_chat_user_components(user);
const chat_message_markup = document.createElement("span");
chat_message_markup.classList.add("chat-message__markup");
@ -95,26 +186,57 @@ const create_chat_message = (object) => {
chat_message.insertAdjacentElement("beforeend", chat_message_time);
chat_message.insertAdjacentHTML("beforeend", "&nbsp;");
chat_message.insertAdjacentElement("beforeend", chat_message_name);
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode_nbsp);
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode);
chat_message.insertAdjacentHTML("beforeend", ":&nbsp;");
for (const chat_message_user_component of chat_message_user_components) {
chat_message.insertAdjacentElement("beforeend", chat_message_user_component);
}
chat_message.insertAdjacentHTML("beforeend", ": ");
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
return chat_message;
}
const create_chat_message_name = (user) => {
const chat_message_name = document.createElement("span");
chat_message_name.classList.add("chat-message__name");
chat_message_name.innerText = get_user_name({user});
//chat_message_name.dataset.color = user.color; // not working in any browser
const create_chat_user_name = (user) => {
const chat_user_name = document.createElement("span");
chat_user_name.classList.add("chat-name");
chat_user_name.innerText = get_user_name({user});
//chat_user_name.dataset.color = user.color; // not working in any browser
if (!user.broadcaster && user.name === null) {
const chat_message_name_tag = document.createElement("sup");
chat_message_name_tag.classList.add("chat-message__name__tag");
chat_message_name_tag.innerText = user.tag;
chat_message_name.insertAdjacentElement("beforeend", chat_message_name_tag);
const b = document.createElement("b");
b.innerText = user.tag;
const chat_user_name_tag = document.createElement("sup");
chat_user_name_tag.classList.add("chat-name__tag");
chat_user_name_tag.innerHTML = b.outerHTML;
chat_user_name.insertAdjacentElement("beforeend", chat_user_name_tag);
}
return chat_message_name;
return chat_user_name;
}
const create_chat_user_components = (user) => {
const chat_user_name = create_chat_user_name(user);
const chat_user_tripcode_nbsp = document.createElement("span");
chat_user_tripcode_nbsp.classList.add("for-tripcode");
chat_user_tripcode_nbsp.innerHTML = "&nbsp;";
const chat_user_tripcode = document.createElement("span");
chat_user_tripcode.classList.add("tripcode");
chat_user_tripcode.classList.add("for-tripcode");
if (user.tripcode !== null) {
chat_user_tripcode.innerHTML = user.tripcode.digest;
}
let result;
if (!user.broadcaster) {
result = [];
} else {
const chat_user_insignia = document.createElement("b");
chat_user_insignia.classList.add("chat-insignia")
chat_user_insignia.title = "Broadcaster";
chat_user_insignia.innerText = "##";
const chat_user_insignia_nbsp = document.createElement("span");
chat_user_insignia_nbsp.innerHTML = "&nbsp;"
result = [chat_user_insignia, chat_user_insignia_nbsp];
}
result.push(...[chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode]);
return result;
}
const create_and_add_chat_message = (object) => {
const chat_message = create_chat_message(object);
@ -125,6 +247,8 @@ const create_and_add_chat_message = (object) => {
}
let users = {};
let stats = null;
let stats_received = null;
let default_name = {true: "Broadcaster", false: "Anonymous"};
let max_chat_scrollback = 256;
const tidy_stylesheet = ({stylesheet, selector_regex, ignore_condition}) => {
@ -155,7 +279,7 @@ const update_user_colors = (token_hash=null) => {
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
const {to_delete, to_ignore} = tidy_stylesheet({
stylesheet: stylesheet_color,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.chat-message__name/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.chat-name/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correct_color = equal(css_rule.style.color, this_user.color);
@ -167,7 +291,7 @@ const update_user_colors = (token_hash=null) => {
if (!to_ignore.has(this_token_hash)) {
const user = users[this_token_hash];
stylesheet_color.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .chat-message__name { color: ${user.color}; }`,
`[data-token-hash="${this_token_hash}"] > .chat-name { color: ${user.color}; }`,
stylesheet_color.cssRules.length,
);
}
@ -187,8 +311,8 @@ const update_user_names = (token_hash=null) => {
const this_token_hash = chat_message.dataset.tokenHash;
if (token_hashes.includes(this_token_hash)) {
const user = users[this_token_hash];
const chat_message_name = chat_message.querySelector(".chat-message__name");
chat_message_name.innerHTML = create_chat_message_name(user).innerHTML;
const chat_message_name = chat_message.querySelector(".chat-name");
chat_message_name.innerHTML = create_chat_user_name(user).innerHTML;
}
}
}
@ -197,7 +321,7 @@ const update_user_tripcodes = (token_hash=null) => {
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
const {to_delete: to_delete_display, to_ignore: to_ignore_display} = tidy_stylesheet({
stylesheet: stylesheet_tripcode_display,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.for-tripcode/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.for-tripcode/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correctly_hidden = this_user.tripcode === null && css_rule.style.display === "none";
@ -207,7 +331,7 @@ const update_user_tripcodes = (token_hash=null) => {
});
const {to_delete: to_delete_colors, to_ignore: to_ignore_colors} = tidy_stylesheet({
stylesheet: stylesheet_tripcode_colors,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.tripcode/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.tripcode/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correctly_blank = (
@ -228,28 +352,28 @@ const update_user_tripcodes = (token_hash=null) => {
for (const this_token_hash of token_hashes) {
const tripcode = users[this_token_hash].tripcode;
if (tripcode === null) {
if (!to_ignore_display.has(token_hash)) {
if (!to_ignore_display.has(this_token_hash)) {
stylesheet_tripcode_display.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
if (!to_ignore_colors.has(this_token_hash)) {
stylesheet_tripcode_colors.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
stylesheet_tripcode_colors.cssRules.length,
);
}
} else {
if (!to_ignore_display.has(token_hash)) {
if (!to_ignore_display.has(this_token_hash)) {
stylesheet_tripcode_display.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
if (!to_ignore_colors.has(this_token_hash)) {
stylesheet_tripcode_colors.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
stylesheet_tripcode_colors.cssRules.length,
);
}
@ -279,13 +403,31 @@ const chat_form_captcha_digest = document.getElementById("chat-form_js__captcha-
const chat_form_captcha_image = document.getElementById("chat-form_js__captcha-image");
const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-answer");
chat_form_captcha_image.addEventListener("loadstart", (event) => {
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.alt = "Loading...";
});
chat_form_captcha_image.addEventListener("load", (event) => {
chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("error", (event) => {
chat_form_captcha_image.alt = "Captcha failed to load";
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("click", (event) => {
event.preventDefault();
if (chat_form_captcha_image.dataset.reloadable !== undefined) {
chat_form_submit.disabled = true;
chat_form_captcha_image.alt = "Waiting...";
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.removeAttribute("src");
const payload = {type: "captcha"};
ws.send(JSON.stringify(payload));
}
});
const enable_captcha = (digest) => {
chat_form_captcha_digest.value = digest;
@ -295,7 +437,8 @@ const enable_captcha = (digest) => {
chat_form_captcha_answer.disabled = false;
chat_form_comment.required = false;
chat_form_captcha_image.removeAttribute("src");
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(token)}&digest=${encodeURIComponent(digest)}`;
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(TOKEN)}&digest=${encodeURIComponent(digest)}`;
chat_form_submit.disabled = false;
chat_form.dataset.captcha = "";
}
const disable_captcha = () => {
@ -306,26 +449,150 @@ const disable_captcha = () => {
chat_form_captcha_digest.value = "";
chat_form_captcha_answer.value = "";
chat_form_captcha_answer.required = false;
chat_form_submit.disabled = false;
chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.removeAttribute("src");
}
const set_title = (title) => {
const h1 = document.createElement("h1");
h1.innerText = title.replaceAll(/\r?\n/g, " ");
info_title.innerHTML = h1.outerHTML;
}
const update_uptime = () => {
if (stats_received === null) {
return;
} else if (stats === null) {
info_uptime.innerText = "";
} else {
const stats_received_ago = (new Date() - stats_received) / 1000;
const uptime = Math.round(stats.uptime + stats_received_ago);
const s = Math.round(uptime % 60);
const m = Math.floor(uptime / 60) % 60
const h = Math.floor(uptime / 3600);
const ss = s.toString().padStart(2, "0");
if (uptime < 3600) {
info_uptime.innerText = `${m}:${ss}`;
} else {
const mm = m.toString().padStart(2, "0");
info_uptime.innerText = `${h}:${mm}:${ss}`;
}
}
}
setInterval(update_uptime, 1000); // always update uptime
const update_viewership = () => {
info_viewership.innerText = stats === null ? "" : `${stats.viewership} viewers`;
}
const update_stats = () => {
if (stats === null) {
update_viewership();
update_uptime();
} else {
update_uptime();
update_viewership();
}
}
const update_users_list = () => {
listed_watching = new Set();
listed_notwatching = new Set();
// remove no-longer-known users
for (const element of chat_users_watching.querySelectorAll(".chat-user")) {
const token_hash = element.dataset.tokenHash;
if (!Object.prototype.hasOwnProperty(users, token_hash)) {
element.remove();
} else {
listed_watching.add(token_hash);
}
}
for (const element of chat_users_notwatching.querySelectorAll(".chat-user")) {
const token_hash = element.dataset.tokenHash;
if (!Object.prototype.hasOwnProperty(users, token_hash)) {
element.remove();
} else {
listed_notwatching.add(token_hash);
}
}
// add remaining watching/non-watching users
const insert = (user, token_hash, is_you, chat_users_sublist) => {
const chat_user_components = create_chat_user_components(user);
const chat_user = document.createElement("li");
chat_user.classList.add("chat-user");
chat_user.dataset.tokenHash = token_hash;
for (const chat_user_component of chat_user_components) {
chat_user.insertAdjacentElement("beforeend", chat_user_component);
}
if (is_you) {
const you = document.createElement("span");
you.innerText = " (You)";
chat_user.insertAdjacentElement("beforeend", you);
}
chat_users_sublist.insertAdjacentElement("beforeend", chat_user);
}
let watching = 0, notwatching = 0;
for (const token_hash of Object.keys(users)) {
const user = users[token_hash];
const is_you = token_hash === TOKEN_HASH;
if (user.watching === true && !listed_watching.has(token_hash)) {
insert(user, token_hash, is_you, chat_users_watching);
watching++;
}
if (user.watching === false && !listed_notwatching.has(token_hash)) {
insert(user, token_hash, is_you, chat_users_notwatching);
notwatching++;
}
}
// show correct numbers
chat_users_watching_header.innerText = `Watching (${watching})`;
chat_users_notwatching_header.innerText = `Not watching (${notwatching})`;
}
const on_websocket_message = (event) => {
//console.log("websocket message", event);
const receipt = JSON.parse(event.data);
switch (receipt.type) {
case "error":
console.log("ws error", receipt);
chat_form_submit.disabled = false;
chat_appearance_form_submit.disabled = false;
break;
case "init":
console.log("ws init", receipt);
info_title.innerText = receipt.title;
// set title
set_title(receipt.title);
// update stats (uptime/viewership)
stats = receipt.stats;
stats_received = new Date();
update_stats();
// stream reload button
if (stats === null || stream.networkState === stream.NETWORK_LOADING) {
info_button.removeAttribute("data-visible");
} else {
info_button.dataset.visible = "";
}
// chat form nonce
chat_form_nonce.value = receipt.nonce;
// chat form captcha digest
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
// chat form submit button
chat_form_submit.disabled = false;
// remove messages the server isn't acknowledging the existance of
const seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
const to_delete = [];
for (const chat_message of chat_messages.children) {
@ -338,13 +605,26 @@ const on_websocket_message = (event) => {
chat_message.remove();
}
// settings
default_name = receipt.default;
max_chat_scrollback = receipt.scrollback;
// update users
users = receipt.users;
update_user_names();
update_user_colors();
update_user_tripcodes();
update_users_list()
// appearance form default values
const user = users[TOKEN_HASH];
if (user.name !== null) {
chat_appearance_form_name.setAttribute("value", user.name);
}
chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]);
chat_appearance_form_color.setAttribute("value", user.color);
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
const last_seq = last === null ? null : parseInt(last.dataset.seq);
for (const message of receipt.messages) {
@ -355,13 +635,37 @@ const on_websocket_message = (event) => {
break;
case "title":
console.log("ws title", receipt);
info_title.innerText = receipt.title;
case "info":
console.log("ws info", receipt);
// set title
if (receipt.title !== undefined) {
set_title(receipt.title);
}
// update stats (uptime/viewership)
if (receipt.stats !== undefined) {
stats = receipt.stats;
stats_received = new Date();
update_stats();
}
// stream reload button
if (stats === null || stream.networkState === stream.NETWORK_LOADING) {
info_button.removeAttribute("data-visible");
} else {
info_button.dataset.visible = "";
}
break;
case "ack":
console.log("ws ack", receipt);
if (receipt.notice !== null) {
show_notice(receipt.notice);
}
const existing_nonce = chat_form_nonce.value;
if (receipt.clear && receipt.nonce === existing_nonce) {
chat_form_comment.value = "";
@ -371,6 +675,7 @@ const on_websocket_message = (event) => {
chat_form_nonce.value = receipt.next;
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
chat_form_submit.disabled = false;
break;
case "message":
@ -391,6 +696,7 @@ const on_websocket_message = (event) => {
update_user_names();
update_user_colors();
update_user_tripcodes();
update_users_list()
break;
case "rem-users":
@ -400,6 +706,47 @@ const on_websocket_message = (event) => {
}
update_user_colors();
update_user_tripcodes();
update_users_list()
break;
case "captcha":
console.log("ws captcha", receipt);
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
break;
case "appearance":
console.log("ws appearance", receipt);
if (receipt.errors === undefined) {
if (receipt.name !== null) {
chat_appearance_form_name.setAttribute("value", receipt.name);
}
chat_appearance_form_color.setAttribute("value", receipt.color);
chat_appearance_form_result.innerHTML = receipt.result;
} else {
const ul = document.createElement("ul");
for (const error of receipt.errors) {
const li = document.createElement("li");
li.innerText = error[0];
for (const tuple of error.slice(1)) {
const mark = document.createElement("mark");
mark.innerText = tuple[0];
li.insertAdjacentText("beforeend", " ");
li.insertAdjacentElement("beforeend", mark);
li.insertAdjacentText("beforeend", tuple[1]);
}
ul.insertAdjacentElement("beforeend", li);
}
const result = document.createElement("div");
result.innerText = "Errors:";
result.insertAdjacentElement("beforeend", ul);
chat_appearance_form_result.innerHTML = result.innerHTML;
}
chat_appearance_form_submit.disabled = false;
chat_appearance_form.removeAttribute("data-hidden");
chat_form_settings.style.backgroundColor = "#4f4f53";
break;
default:
@ -416,13 +763,13 @@ const connect_websocket = () => {
return;
}
chat_live_ball.style.borderColor = "gold";
chat_live_status.innerHTML = "<span data-verbose='false'>Waiting...</span> <span data-verbose='true'>Connecting to chat...</span>";
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>&middot;&middot;&middot;</span>";
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}`);
ws.addEventListener("open", (event) => {
console.log("websocket open", event);
chat_form_submit.disabled = false;
chat_live_ball.style.borderColor = "green";
chat_live_status.innerHTML = "<span>Connected<span data-verbose='true'> to chat</span></span>";
chat_live_status.innerHTML = "<span><span data-verbose='true'>Connected to chat</span><span data-verbose='false'>&check;</span></span>";
// When the server is offline, a newly opened websocket can take a second
// to close. This timeout tries to ensure the backoff doesn't instantly
// (erroneously) reset to 2 seconds in that case.
@ -438,7 +785,7 @@ const connect_websocket = () => {
console.log("websocket close", event);
chat_form_submit.disabled = true;
chat_live_ball.style.borderColor = "maroon";
chat_live_status.innerHTML = "<span data-verbose='false'>Failed to connect</span> <span data-verbose='true'>Disconnected from chat</span>";
chat_live_status.innerHTML = "<span data-verbose='true'>Disconnected from chat</span><span data-verbose='false'>&times;</span>";
if (!ws.successor) {
ws.successor = true;
setTimeout(connect_websocket, websocket_backoff);
@ -456,18 +803,43 @@ const connect_websocket = () => {
connect_websocket();
/* stream reload button */
const stream = document.getElementById("stream_js");
const info_button = document.getElementById("info_js__float__button");
info_button.addEventListener("click", (event) => {
stream.load();
info_button.removeAttribute("data-visible");
});
stream.addEventListener("error", (event) => {
if (stats !== null) {
info_button.dataset.visible = "";
}
});
/* override js-only chat form */
const chat_form = document.getElementById("chat-form_js");
const chat_form_nonce = document.getElementById("chat-form_js__nonce");
const chat_form_comment = document.getElementById("chat-form_js__comment");
const chat_form_submit = document.getElementById("chat-form_js__submit");
chat_form.addEventListener("submit", (event) => {
event.preventDefault();
const payload = Object.fromEntries(new FormData(chat_form));
const form = Object.fromEntries(new FormData(chat_form));
const payload = {type: "message", form: form};
chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload));
});
/* override js-only appearance form */
const chat_appearance_form_submit = document.getElementById("appearance-form_js__row__submit");
chat_appearance_form.addEventListener("submit", (event) => {
event.preventDefault();
const form = Object.fromEntries(new FormData(chat_appearance_form));
const payload = {type: "appearance", form: form};
chat_appearance_form_submit.disabled = true;
chat_appearance_form_password.value = "";
chat_appearance_form_result.innerText = "";
ws.send(JSON.stringify(payload));
});
/* when chat is being resized, peg its bottom in place (instead of its top) */
const track_scroll = (element) => {
chat_messages.dataset.scrollTop = chat_messages.scrollTop;

4
anonstream/static/settings.svg ノーマルファイル
ファイルの表示

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg">
<path d="m104.65 0-8.2461 29.598c-7.6914 2.1172-14.992 5.2305-21.777 9.0898l-27.062-15.012-23.891 23.891 15.012 27.062c-3.8594 6.7852-6.9727 14.086-9.0898 21.777l-29.598 8.2461v34.25l29.598 8.2461c2.1172 7.6914 5.2305 14.992 9.0898 21.777l-15.012 27.062 23.891 23.891 27.062-15.012c6.7852 3.8594 14.086 6.9727 21.777 9.0898l8.2461 29.598h34.25l8.2461-29.598c7.6914-2.1172 14.992-5.2305 21.777-9.0898l27.062 15.012 23.891-23.891-15.012-27.062c3.8594-6.7812 6.9727-14.086 9.0898-21.777l29.598-8.2461v-34.25l-29.598-8.2461c-2.1172-7.6914-5.2305-14.992-9.0898-21.777l15.012-27.062-23.891-23.891-27.062 15.012c-6.7852-3.8594-14.086-6.9727-21.777-9.0898l-8.2461-29.598zm17.125 74.418c26.156 0 47.359 21.203 47.359 47.359s-21.203 47.359-47.359 47.359-47.359-21.203-47.359-47.359 21.203-47.359 47.359-47.359z" fill="#bbbbbf"/>
</svg>

変更後

幅:  |  高さ:  |  サイズ: 984 B

ファイルの表示

@ -30,7 +30,7 @@ body {
grid-auto-rows: var(--video-height) auto min-content 1fr auto;
grid-template-areas:
"stream"
"toggle"
"nav"
"info"
"chat"
"footer";
@ -50,10 +50,15 @@ noscript {
#stream {
background: black;
width: 100%;
height: var(--video-height);
grid-area: stream;
}
#stream_js {
width: 100%;
height: 100%;
}
#stream_nojs {
height: 100%;
}
#info {
border-top: var(--main-border);
@ -61,11 +66,27 @@ noscript {
}
#info_js {
overflow-y: auto;
padding: 1ch 1.5ch;
padding: 0.75ch 1.25ch;
height: 100%;
box-sizing: border-box;
}
#info_js__title {
#info_js__float {
float: right;
font-size: 11pt;
display: grid;
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#info_js__float__button:not([data-visible]) {
display: none;
}
#info_js__float__uptime {
font-variant-numeric: tabular-nums;
}
#info_js__title > h1 {
margin: 0;
font-size: 18pt;
line-height: 1.125;
overflow-wrap: anywhere;
}
#info_nojs {
@ -81,15 +102,67 @@ noscript {
grid-area: chat;
height: 50vh;
min-height: 24ch;
position: relative;
}
#chat__toggle {
opacity: 0;
position: absolute;
pointer-events: none;
}
#chat__toggle:checked ~ #chat__body > #chat__body__messages,
#chat__toggle:not(:checked) ~ #chat__body > #chat__body__users {
visibility: hidden;
}
#chat__toggle:checked + #chat__header > #chat__header__button {
border-style: inset;
background-color: #b3b3bf;
}
#chat__toggle:checked + #chat__header > #chat__header__button:hover {
background-color: #a6a6b1;
}
#chat__toggle:focus-visible + #chat__header > #chat__header__button {
border-color: #3584e4;
box-shadow: 0 0 6px #3584e4;
}
#chat__header {
text-align: center;
padding: 0.5rem 0;
padding: 0.5rem;
border-bottom: var(--chat-border);
display: grid;
align-items: center;
}
#chat__messages {
#chat__header__button {
grid-column: 1;
grid-row: 1;
width: min-content;
z-index: 1;
padding: 1px 5px;
background-color: #c9c9d3;
border: 3px outset #8f8f9d;
border-radius: 4px;
color: black;
user-select: none;
}
#chat__header__button:hover {
background-color: #b3b3bf;
}
#chat__header__button:active {
border-style: inset;
background-color: #9999a4 !important;
}
#chat__header__text {
margin: 0;
font-weight: normal;
text-align: center;
grid-column: 1;
grid-row: 1;
}
#chat__body {
position: relative;
}
#chat__body__messages {
position: relative;
height: 100%;
}
#chat-messages_js {
list-style: none;
margin: 0;
@ -117,15 +190,17 @@ noscript {
.chat-message__time {
color: #b2b2b3;
font-size: 10pt;
}
.chat-insignia {
cursor: help;
}
.chat-message__name {
.chat-name {
overflow-wrap: anywhere;
font-weight: bold;
/* color: attr("data-color"); */
cursor: default;
}
.chat-message__name__tag {
.chat-name__tag {
font-family: monospace;
font-size: 9pt;
vertical-align: top;
@ -141,18 +216,59 @@ noscript {
font-size: 9pt;
cursor: default;
}
#chat__body__users {
background-color: #121214;
mask-image: linear-gradient(black calc(100% - 0.625rem), transparent calc(100% - 0.125rem));
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: grid;
grid-auto-rows: min-content auto;
}
#chat-users-header {
padding: 0.5rem;
background-color: #2c2c30;
border-bottom: var(--chat-border);
}
#chat-users-header > h4 {
margin: 0;
font-weight: normal;
text-align: center;
}
#chat-users_js {
padding: 0.5rem 0.75rem 0.875rem;
overflow: auto;
}
#chat-users_js__watching-header,
#chat-users_js__notwatching-header {
margin: 0;
}
#chat-users_js__watching,
#chat-users_js__notwatching {
margin: 0;
padding-left: 0.75rem;
list-style: none;
}
.chat-user {
line-height: 1.4375;
}
#chat-users_nojs {
height: 100%;
}
#chat__form {
position: relative;
}
#chat-form_js {
display: grid;
grid-template-columns: 1fr min-content min-content 5rem;
grid-template-columns: 1fr min-content min-content min-content 5rem;
grid-template-rows: auto var(--button-height);
grid-gap: 0.375rem;
margin: 0 0.5rem 0.5rem 0.5rem;
}
#chat-form_js__submit {
grid-column: 2 / span 1;
padding: 0 0.5rem 0.5rem 0.5rem;
position: relative;
}
#chat-form_js__comment {
grid-column: 1 / span 4;
grid-column: 1 / span 5;
background-color: #434347;
border-radius: 4px;
border: 2px solid transparent;
@ -175,19 +291,106 @@ noscript {
color: inherit;
font-size: 8pt;
}
#chat-form_js__captcha-image[data-reloadable] {
cursor: pointer;
}
#chat-form_js__captcha-answer {
width: 8ch;
}
#chat-form_js__submit {
#chat-form_js__settings {
align-self: center;
padding: 5px;
box-sizing: border-box;
border-radius: 3px;
color: var(--text-color);
grid-column: 4;
}
#chat-form_js__settings:hover {
background-color: #434347;
}
#chat-form_js__submit {
grid-column: 5;
}
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-image,
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-answer {
display: none;
}
#chat-form_js:not([data-notice]) > #chat-form_js__notice {
display: none;
}
#chat-form_js__notice {
position: absolute;
width: 100%;
background: linear-gradient(#23232700, #2323277f 8%, #232327);
height: 100%;
display: grid;
z-index: 1;
}
#chat-form_js__notice__button {
color: inherit;
border-color: black;
background-color: #232327;
border-radius: 4px;
text-align: center;
margin: auto;
display: grid;
grid-gap: 0.375rem;
padding: 0.625rem 1.25rem;
box-shadow: 0 0 12px black;
cursor: pointer;
}
#chat-form_js__notice__button__header {
font-size: 14pt;
line-height: 1.5;
}
#chat-form_nojs {
height: 13ch;
}
#appearance-form_js {
position: absolute;
bottom: 3rem;
padding: 0.5rem;
margin: 0 1rem;
width: calc(100% - 2rem);
box-sizing: border-box;
background: #343437df;
border: 2px outset #434347;
border-radius: 4px;
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: 1fr 1fr auto;
grid-gap: 0.375rem;
}
#appearance-form_js[data-hidden] {
display: none;
}
#appearance-form_js__label-name,
#appearance-form_js__label-tripcode {
align-self: center;
}
#appearance-form_js__name,
#appearance-form_js__password {
min-width: 12ch;
}
#appearance-form_js__row {
grid-column: 1 / span 3;
grid-row: 3;
display: grid;
grid-template-columns: auto 4rem;
align-items: end;
}
#appearance-form_js__row__result {
font-weight: bold;
font-size: 11pt;
}
#appearance-form_js__row__result > ul {
margin: 0;
padding-left: 1.125rem;
font-size: 10pt;
}
#appearance-form_js__row__submit {
min-height: 1.75rem;
}
#chat-live {
position: relative;
font-size: 9pt;
@ -218,13 +421,13 @@ noscript {
100% {filter: brightness(100%)}
}
#toggle {
grid-area: toggle;
#nav {
grid-area: nav;
border-top: var(--main-border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
#toggle > a {
#nav > a {
text-align: center;
padding: 1ch;
font-variant: all-small-caps;
@ -251,9 +454,9 @@ footer {
#chat:target, #both:target > #chat {
display: grid;
}
#info:target ~ #toggle > [href="#info"],
#chat:target ~ #toggle > [href="#chat"],
#both:target > #toggle > [href="#both"] {
#info:target ~ #nav > [href="#info"],
#chat:target ~ #nav > [href="#chat"],
#both:target > #nav > [href="#both"] {
background-color: #3065a6;
border-style: inset;
}
@ -276,7 +479,7 @@ footer {
"info chat"
"footer chat";
}
#toggle {
#nav {
display: none;
}
#info {

ファイルの表示

@ -1,9 +1,25 @@
import itertools
import operator
import time
from anonstream.segments import get_playlist, Offline
import aiofiles
from quart import current_app
def get_stream_title():
return 'Stream title'
from anonstream.segments import get_playlist, Offline
from anonstream.wrappers import ttl_cache_async, with_timestamp
from anonstream.user import get_watching_users
CONFIG = current_app.config
USERS = current_app.users
@ttl_cache_async(CONFIG['STREAM_TITLE_CACHE_LIFETIME'])
async def get_stream_title():
try:
async with aiofiles.open(CONFIG['STREAM_TITLE']) as fp:
title = await fp.read(8192)
except FileNotFoundError:
title = ''
return title
def get_stream_uptime(rounded=True):
try:
@ -20,6 +36,28 @@ def get_stream_uptime(rounded=True):
uptime = round(uptime, 2) if rounded else uptime
return uptime
@with_timestamp
def get_raw_viewership(timestamp):
users = get_watching_users(timestamp)
return max(
map(operator.itemgetter(0), zip(itertools.count(1), users)),
default=0,
)
def get_stream_uptime_and_viewership(for_websocket=False):
uptime = get_stream_uptime()
if not for_websocket:
viewership = None if uptime is None else get_raw_viewership()
result = (uptime, viewership)
elif uptime is None:
result = None
else:
result = {
'uptime': uptime,
'viewership': get_raw_viewership(),
}
return result
def is_online():
try:
get_playlist()

ファイルの表示

@ -1,11 +1,13 @@
import asyncio
import itertools
from functools import wraps
from quart import current_app
from anonstream.broadcast import broadcast, broadcast_users_update
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import get_sunsettable_users
from anonstream.wrappers import with_timestamp
from anonstream.helpers.user import is_visible
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -27,12 +29,12 @@ def with_period(period):
def periodically(f):
@wraps(f)
async def wrapper(*args, **kwargs):
while True:
for iteration in itertools.count():
await f(iteration, *args, **kwargs)
try:
await sleep_and_collect_task(period)
except asyncio.CancelledError:
break
f(*args, **kwargs)
return wrapper
@ -40,22 +42,20 @@ def with_period(period):
@with_period(CONFIG['TASK_PERIOD_ROTATE_USERS'])
@with_timestamp
def t_sunset_users(timestamp):
tokens = []
for token in USERS_BY_TOKEN:
user = USERS_BY_TOKEN[token]
if not is_visible(timestamp, MESSAGES, user):
tokens.append(token)
async def t_sunset_users(timestamp, iteration):
if iteration == 0:
return
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
token_hashes = []
while tokens:
token = tokens.pop()
token_hash = USERS_BY_TOKEN.pop(token)['token_hash']
token_hashes.append(token_hash)
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
users = list(get_sunsettable_users(timestamp))
while users:
user = users.pop()
USERS_BY_TOKEN.pop(user['token'])
token_hashes.append(user['token_hash'])
if token_hashes:
broadcast(
@ -67,7 +67,10 @@ def t_sunset_users(timestamp):
)
@with_period(CONFIG['TASK_PERIOD_ROTATE_CAPTCHAS'])
def t_expire_captchas():
async def t_expire_captchas(iteration):
if iteration == 0:
return
to_delete = []
for digest in CAPTCHAS:
valid = CAPTCHA_SIGNER.validate(
@ -76,13 +79,70 @@ def t_expire_captchas():
)
if not valid:
to_delete.append(digest)
for digest in to_delete:
CAPTCHAS.pop(digest)
@with_period(CONFIG['TASK_PERIOD_BROADCAST_USERS_UPDATE'])
def t_broadcast_users_update():
broadcast_users_update()
async def t_broadcast_users_update(iteration):
if iteration == 0:
return
else:
broadcast_users_update()
@with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE'])
async def t_broadcast_stream_info_update(iteration):
if iteration == 0:
title = await get_stream_title()
uptime, viewership = get_stream_uptime_and_viewership()
current_app.stream_title = title
current_app.stream_uptime = uptime
current_app.stream_viewership = viewership
else:
payload = {}
title = await get_stream_title()
uptime, viewership = get_stream_uptime_and_viewership()
# Check if the stream title has changed
if current_app.stream_title != title:
current_app.stream_title = title
payload['title'] = title
# Check if the stream uptime has changed unexpectedly
if current_app.stream_uptime is None:
expected_uptime = None
else:
expected_uptime = (
current_app.stream_uptime
+ CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']
)
current_app.stream_uptime = uptime
if uptime is None and expected_uptime is None:
stats_changed = False
elif uptime is None or expected_uptime is None:
stats_changed = True
else:
stats_changed = abs(uptime - expected_uptime) >= 0.0625
# Check if viewership has changed
if current_app.stream_viewership != viewership:
current_app.stream_viewership = viewership
stats_changed = True
if stats_changed:
if uptime is None:
payload['stats'] = None
else:
payload['stats'] = {
'uptime': uptime,
'viewership': viewership,
}
if payload:
broadcast(USERS, payload={'type': 'info', **payload})
current_app.add_background_task(t_sunset_users)
current_app.add_background_task(t_expire_captchas)
current_app.add_background_task(t_broadcast_users_update)
current_app.add_background_task(t_broadcast_stream_info_update)

ファイルの表示

@ -2,28 +2,41 @@
<html id="nochat">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
</head>
<body id="both" data-token="{{ user.token }}">
<video id="stream" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}">
<article id="stream">
<noscript><iframe id="stream_nojs" name="stream_nojs" src="{{ url_for('nojs_stream', token=user.token) }}"></iframe></noscript>
</article>
<article id="info">
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" data-js="false"></iframe></noscript>
</article>
<aside id="chat">
<header id="chat__header">Stream chat</header>
<article id="chat__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
<input id="chat__toggle" type="checkbox">
<header id="chat__header">
<label id="chat__header__button" for="chat__toggle">Users</label>
<h3 id="chat__header__text">Stream chat</h3>
</header>
<article id="chat__body">
<section id="chat__body__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat_messages', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
</section>
<section id="chat__body__users">
<header id="chat-users-header"><h4>Users in chat</h4></header>
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_chat_users', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token) }}" data-js="false"></iframe></noscript>
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_chat_form', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</aside>
<nav id="toggle">
<nav id="nav">
<a href="#info">info</a>
<a href="#chat">chat</a>
<a href="#both">both</a>
</nav>
<footer>anonstream 1.0.0 &mdash; <a href="#" target="_blank">source</a></footer>
<footer>anonstream pre-1.0.0 &mdash; <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
</body>
</html>

27
anonstream/templates/macros/user.html ノーマルファイル
ファイルの表示

@ -0,0 +1,27 @@
{%
macro appearance(
user,
insignia_class,
name_class,
tag_class,
tripcode_nbsp_class='for-tripcode',
tripcode_class='tripcode for-tripcode'
)
%}
{{- '' -}}
{%- if user.broadcaster -%}
<b class="{{ insignia_class }}" title="Broadcaster">##</b>
{{- '&nbsp;' | safe -}}
{%- endif -%}
<span class="{{ name_class }}" style="color:{{ user.color }};">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="{{ tag_class }}"><b>{{ user.tag }}</b></sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="{{ tripcode_nbsp_class }}">&nbsp;</span>
{{- '' -}}
<span class="{{ tripcode_class }}" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
{%- endif -%}
{% endmacro %}

ファイルの表示

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--link-color: #42a5d7;
@ -55,13 +56,14 @@
height: 100%;
box-sizing: border-box;
align-items: center;
cursor: pointer;
}
#notice h1 {
#notice h2 {
margin: 0;
font-size: 18pt;
line-height: 1.25;
}
#notice.verbose h1 {
#notice.verbose h2 {
font-size: 14pt;
}
@ -72,7 +74,7 @@
grid-gap: 0.375rem;
}
#chat-form__exit,
#appearance-form__exit,
#appearance-form__buttons__exit,
#appearance-form__label-name,
#appearance-form__label-password {
font-size: 11pt;
@ -119,6 +121,7 @@
}
#appearance-form {
display: grid;
grid-auto-rows: 1fr 1fr 2rem;
grid-auto-columns: min-content 1fr min-content;
}
@ -171,49 +174,51 @@
display: none;
}
#appearance-form {
#toggle {
opacity: 0;
position: absolute;
pointer-events: none;
}
#chat-form__exit > label,
#appearance-form__buttons__exit > label {
padding: 1px;
}
#toggle:focus-visible ~ #chat-form > #chat-form__exit > label,
#toggle:focus-visible ~ #appearance-form #appearance-form__buttons__exit > label {
padding: 0;
border: 1px dotted;
}
#notice-radio {
display: none;
}
#appearance:target ~ #appearance-form {
display: grid;
}
#appearance:target ~ #chat-form {
#toggle:checked ~ #chat-form,
#toggle:not(:checked) ~ #appearance-form {
display: none;
}
#chat:target ~ #appearance-form {
#notice-radio:checked + #notice,
#notice-radio:not(:checked) ~ #chat-form,
#notice-radio:not(:checked) ~ #appearance-form {
display: none;
}
{% if state.notice %}
#chat-form {
display: none;
}
#chat:target ~ #chat-form {
display: grid;
}
#chat:target ~ #notice,
#appearance:target ~ #notice {
display: none;
}
{% endif %}
</style>
</head>
<body>
<div id="chat"></div>
<div id="appearance"></div>
<input id="toggle" type="checkbox"{% if not prefer_chat_form %} checked{% endif %}>
{% if state.notice %}
<a id="notice" {% if state.verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
<header><h1>{{ state.notice }}</h1></header>
<input id="notice-radio" type="radio">
<label id="notice" for="notice-radio"{% if state.verbose %} class="verbose"{% endif %}>
<header><h2>{{ state.notice }}</h2></header>
<small>Click to dismiss</small>
</a>
</label>
{% endif %}
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
<input type="hidden" name="nonce" value="{{ nonce }}">
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1">{{ state.comment }}</textarea>
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1" autofocus>{{ state.comment }}</textarea>
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
<div id="chat-form__exit"><label for="toggle" class="pseudolink">Settings</label></div>
{% if digest %}
<input type="hidden" name="captcha-digest" value="{{ digest }}">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
{% endif %}
</form>
@ -238,7 +243,7 @@
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)" maxlength="1024">
<div id="hide-password"><label for="password-toggle" class="pseudolink x">&cross;</label></div>
<div id="appearance-form__buttons">
<div id="appearance-form__exit"><a href="#chat">Return to chat</a></div>
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
<input type="submit" value="Update">
</div>
</form>

ファイルの表示

@ -1,8 +1,11 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="4">
<meta http-equiv="refresh" content="5; url={{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
<style>
html {
height: 100%;
@ -32,7 +35,7 @@
text-decoration: none;
transform: rotate(-180deg);
}
#chat-timeout {
#timeout {
z-index: 1;
position: absolute;
top: 0.5rem;
@ -40,15 +43,15 @@
visibility: hidden;
animation: appear 0s {{ timeout }}s forwards;
}
#chat-timeout header {
#timeout header {
font-size: 20pt;
}
#chat-timeout-dismiss {
#timeout-dismiss {
position: absolute;
bottom: 2px;
width: calc(100% - 1rem);
}
#chat-timeout-dismiss > .button {
#timeout-dismiss > .button {
visibility: hidden;
height: 0;
padding: 0;
@ -57,12 +60,12 @@
appear 0s {{ timeout }}s forwards,
unskinny 0s {{ timeout }}s forwards;
}
#chat-timeout-alt {
#timeout-alt {
padding: 4px 0 2px 0;
}
#notimeout:target + #chat-timeout,
#notimeout:target ~ #chat-timeout-dismiss,
#notimeout:not(:target) ~ #chat-timeout-alt {
#notimeout:target + #timeout,
#notimeout:target ~ #timeout-dismiss,
#notimeout:not(:target) ~ #timeout-alt {
display: none;
}
@keyframes appear {
@ -97,6 +100,8 @@
.chat-message__time {
color: #b2b2b3;
font-size: 10pt;
}
.chat-message__insignia {
cursor: help;
}
.chat-message__name {
@ -126,8 +131,8 @@
<body>
<div id="end"></div>
<div id="notimeout"></div>
<aside id="chat-timeout">
<a class="button" href="{{ url_for('nojs_chat_redirect') }}">
<aside id="timeout">
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
<header>Timed out</header>
<small>Click to refresh</small>
</a>
@ -136,16 +141,20 @@
{% for message in messages | reverse %}
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ user.token_hash }}">
{% with user = users_by_token[message.token] %}
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>&nbsp;<span class="chat-message__name" style="color:{{ user.color }};">{{ user.name or get_default_name(user) }}{% if not user.broadcaster and user.name is none %}<sup class="chat-message__name__tag">{{ user.tag }}</sup>{% endif %}</span>{% if user.tripcode %}<span class="for-tripcode">&nbsp;</span><span class="tripcode for-tripcode" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>{% endif %}:&nbsp;<span class="chat-message__markup">{{ message.markup }}</span>
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
{{- '&nbsp;' | safe -}}
{{ appearance(user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{- ': ' -}}
<span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %}
</li>
{% endfor %}
</ol>
<aside id="chat-timeout-dismiss">
<aside id="timeout-dismiss">
<a class="button" href="#notimeout">Hide timeout notice</a>
</aside>
<aside id="chat-timeout-alt">
<a class="button" href="{{ url_for('nojs_chat_redirect') }}">Click to refresh</a>
<aside id="timeout-alt">
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">Click to refresh</a>
</aside>
</body>
</html>

114
anonstream/templates/nojs_chat_users.html ノーマルファイル
ファイルの表示

@ -0,0 +1,114 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="6">
<style>
html {
min-height: 100%;
}
body {
margin: 0;
color: #ddd;
font-family: sans-serif;
}
#timeout {
height: 0;
visibility: hidden;
animation: appear 0s {{ timeout }}s forwards;
}
#timeout > a {
display: block;
text-align: center;
background-color: #3674bf;
border: 4px outset #3584e4;
box-shadow: 0 0 5px #3584e4;
padding: 1.25ch 0;
box-sizing: border-box;
color: inherit;
font-size: 12pt;
font-weight: bold;
text-decoration: none;
animation: unskinny 0s {{ timeout }}s forwards;
}
#timeout header {
font-size: 20pt;
}
@keyframes appear {
to {
height: auto;
visibility: visible;
}
}
@keyframes unskinny {
to {
margin: 0.5rem;
}
}
#main {
margin: 0.5rem 0.75rem 0.875rem;
}
#main > h5 {
margin: 0;
}
#main > ul {
margin: 0;
padding-left: 0.75rem;
list-style: none;
}
.user {
line-height: 1.4375;
}
.user__insignia {
cursor: help;
}
.user__name {
font-weight: bold;
cursor: default;
}
.user__name__tag {
font-family: monospace;
font-size: 9pt;
vertical-align: top;
}
.tripcode {
padding: 0 5px;
border-radius: 7px;
font-family: monospace;
font-size: 9pt;
cursor: default;
}
</style>
</head>
<body>
<aside id="timeout">
<a href="">
<header>Timed out</header>
<small>Click to refresh</small>
</a>
</aside>
<main id="main">
<h5>Watching ({{ users_watching | length }})</h5>
<ul>
{% for user_listed in users_watching %}
<li class="user">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
<br>
<h5>Not watching ({{ users_notwatching | length }})</h5>
<ul>
{% for user_listed in users_notwatching %}
<li class="user">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
</main>
</body>
</html>

ファイルの表示

@ -2,20 +2,174 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="6">
<style>
body {
overflow-y: auto;
margin: 1ch 1.5ch;
margin: 0.75ch 1.25ch;
font-family: sans-serif;
color: #ddd;
}
#title {
#float {
float: right;
font-size: 11pt;
display: grid;
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#float__form {
display: block;
margin: 0;
}
#float__uptime {
font-variant-numeric: tabular-nums;
}
#uptime-static[data-hidden], #uptime-static__label {
display: none;
}
{% if uptime is not none and uptime < 360000 %}
#s0::before, #s1::before,
#m0::before, #m1::before,
#h0::before, #h1::before {
animation-timing-function: step-end;
animation-delay: {{ -uptime }}s;
animation-iteration-count: infinite;
content: "";
}
#m0::after, #h0::after {
content: ":";
}
#s0::before {
animation-name: tick10;
animation-duration: 10s;
}
#s1::before {
animation-name: tick6;
animation-duration: 60s;
}
#m0::before {
animation-name: tick10;
animation-duration: 600s;
}
#m1::before {
animation-name: tick6;
animation-duration: 3600s;
}
#h0::before {
animation-name: tick10;
animation-duration: 36000s;
}
#h1::before {
animation-name: tick10;
animation-duration: 360000s;
}
#m1, #h0, #h1 {
display: inline-block;
animation: appear step-end both;
}
#m1 {
animation-duration: {{ 600 - uptime }}s;
}
#h0 {
animation-duration: {{ 3600 - uptime }}s;
}
#h1 {
animation-duration: {{ 36000 - uptime }}s;
}
#uptime-dynamic-overflow {
animation: appear step-end {{ 360000 - uptime }}s backwards;
}
#uptime-dynamic {
animation: disappear step-end {{ 360000 - uptime }}s forwards;
}
@keyframes appear {
from {
width: 0;
visibility: hidden;
}
}
@keyframes disappear {
to {
height: 0;
opacity: 0;
}
}
@keyframes tick6 {
00.0000% { content: "0"; }
16.6667% { content: "1"; }
33.3333% { content: "2"; }
50.0000% { content: "3"; }
66.6667% { content: "4"; }
83.3333% { content: "5"; }
}
@keyframes tick10 {
00% { content: "0"; }
10% { content: "1"; }
20% { content: "2"; }
30% { content: "3"; }
40% { content: "4"; }
50% { content: "5"; }
60% { content: "6"; }
70% { content: "7"; }
80% { content: "8"; }
90% { content: "9"; }
}
{% endif %}
#title > h1 {
margin: 0;
font-size: 18pt;
line-height: 1.125;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<header id="title">{{ title }}</header>
{% if uptime is not none %}
<aside id="float">
{% if user.presence != Presence.WATCHING %}
<form id="float__form" action="{{ url_for('nojs_stream') }}" target="stream_nojs">
<input type="hidden" name="token" value="{{ user.token }}">
<input type="submit" value="Reload stream">
</form>
{% endif %}
<div id="float__viewership">{{ viewership }} viewers</div>
<div id="float__uptime">
<div id="uptime-static"{% if uptime < 360000 %} data-hidden=""{% endif %}>
<span id="uptime-static__label">Uptime:</span>
<span>
{%- if uptime >= 3600 -%}
{{- (uptime // 3600) | int -}}
{{- ':' -}}
{{- '%02.0f' | format(uptime % 3600 // 60) -}}
{%- else -%}
{{- uptime % 3600 // 60 | int -}}
{%- endif -%}
{{- ':' -}}
{{- '%02.0f' | format(uptime % 60) -}}
</span>
</div>
{% if uptime < 360000 %}
<div id="uptime-dynamic">
<span id="h1"></span>
{{- '' -}}
<span id="h0"></span>
{{- '' -}}
<span id="m1"></span>
{{- '' -}}
<span id="m0"></span>
{{- '' -}}
<span id="s1"></span>
{{- '' -}}
<span id="s0"></span>
</div>
<div id="uptime-dynamic-overflow">100+ hours</div>
{% endif %}
</div>
</aside>
{% endif %}
<header id="title"><h1>{{ title }}</h1></header>
</body>
</html>

24
anonstream/templates/nojs_stream.html ノーマルファイル
ファイルの表示

@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
overflow: hidden;
}
#stream {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video id="stream" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
</body>
</html>

ファイルの表示

@ -4,11 +4,11 @@ from math import inf
from quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp
from anonstream.helpers.user import is_visible
from anonstream.helpers.user import get_default_name, get_presence, Presence
from anonstream.helpers.captcha import check_captcha_digest, Answer
from anonstream.helpers.tripcode import generate_tripcode
from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor
from anonstream.utils.user import get_user_for_websocket
from anonstream.utils.user import get_user_for_websocket, trilean
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -36,25 +36,30 @@ def pop_state(user, state_id):
state = None
return state
def try_change_appearance(user, name, color, password,
want_delete_tripcode, want_change_tripcode):
def try_change_appearance(user, name, color, password, want_tripcode):
errors = []
def try_(f, *args, **kwargs):
return try_except_log(errors, BadAppearance)(f)(*args, **kwargs)
try_(change_name, user, name, dry_run=True)
try_(change_color, user, color, dry_run=True)
if want_delete_tripcode:
pass
elif want_change_tripcode:
if want_tripcode:
try_(change_tripcode, user, password, dry_run=True)
if not errors:
change_name(user, name)
change_color(user, color)
if want_delete_tripcode:
# Leave tripcode
if want_tripcode is None:
pass
# Delete tripcode
elif not want_tripcode:
delete_tripcode(user)
elif want_change_tripcode:
# Change tripcode
elif want_tripcode:
change_tripcode(user, password)
# Add to the users update buffer
@ -64,6 +69,8 @@ def try_change_appearance(user, name, color, password,
def change_name(user, name, dry_run=False):
if dry_run:
if name == get_default_name(user):
name = None
if name is not None:
if len(name) == 0:
raise BadAppearance('Name was empty')
@ -86,7 +93,7 @@ def change_color(user, color, dry_run=False):
if contrast < min_contrast:
raise BadAppearance(
'Colour had insufficient contrast:',
(f'{contrast:.2f}', f'/{min_contrast}'),
(f'{contrast:.2f}', f'/{min_contrast:.2f}'),
)
else:
user['color'] = color
@ -112,13 +119,9 @@ def watched(timestamp, user):
@with_timestamp
def get_all_users_for_websocket(timestamp):
visible_users = filter(
lambda user: is_visible(timestamp, MESSAGES, user),
USERS,
)
return {
user['token_hash']: get_user_for_websocket(user)
for user in visible_users
for user in get_unsunsettable_users(timestamp)
}
def verify(user, digest, answer):
@ -153,3 +156,63 @@ def deverify(timestamp, user):
if n_user_messages >= CONFIG['FLOOD_THRESHOLD']:
user['verified'] = False
def _update_presence(timestamp, user):
old, user['presence'] = user['presence'], get_presence(timestamp, user)
if trilean(user['presence']) != trilean(old):
USERS_UPDATE_BUFFER.add(user['token'])
return user['presence']
@with_timestamp
def update_presence(timestamp, user):
return _update_presence(timestamp, user)
def get_users_and_update_presence(timestamp):
for user in USERS:
_update_presence(timestamp, user)
yield user
def get_watching_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.WATCHING,
get_users_and_update_presence(timestamp),
)
def get_absent_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.ABSENT,
get_users_and_update_presence(timestamp),
)
def is_sunsettable(user):
return user['presence'] == Presence.ABSENT and not has_left_messages(user)
def has_left_messages(user):
return any(
message['token'] == user['token']
for message in MESSAGES
)
def get_sunsettable_users(timestamp):
return filter(
is_sunsettable,
get_users_and_update_presence(timestamp),
)
def get_unsunsettable_users(timestamp):
return filter(
lambda user: not is_sunsettable(user),
get_users_and_update_presence(timestamp),
)
@with_timestamp
def get_users_by_presence(timestamp):
users_by_presence = {
Presence.WATCHING: [],
Presence.NOTWATCHING: [],
Presence.TENTATIVE: [],
Presence.ABSENT: [],
}
for user in get_users_and_update_presence(timestamp):
users_by_presence[user['presence']].append(user)
return users_by_presence

ファイルの表示

@ -2,17 +2,24 @@ import base64
import hashlib
import secrets
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import escape, Markup
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token():
return secrets.token_hex(16)
def get_user_for_websocket(user):
keys = ['broadcaster', 'name', 'color', 'tripcode', 'tag']
return {key: user[key] for key in keys}
def concatenate_for_notice(string, *tuples):
if not tuples:
return string
@ -23,3 +30,19 @@ def concatenate_for_notice(string, *tuples):
)
)
return string + markup
def trilean(presence):
match presence:
case Presence.WATCHING:
return True
case Presence.NOTWATCHING:
return False
case _:
return None
def get_user_for_websocket(user):
keys = ('broadcaster', 'name', 'color', 'tripcode', 'tag')
return {
**{key: user[key] for key in keys},
'watching': trilean(user['presence']),
}

ファイルの表示

@ -1,19 +1,49 @@
from enum import Enum
WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE'))
class Malformed(Exception):
pass
def get(t, pairs, key, default=None):
value = pairs.get(key, default)
if isinstance(value, t):
return value
else:
raise Malformed(f'malformed {key}')
def parse_websocket_data(receipt):
if not isinstance(receipt, dict):
raise Malformed('not a json object')
comment = receipt.get('comment')
if not isinstance(comment, str):
raise Malformed('malformed comment')
match receipt.get('type'):
case 'message':
form = get(dict, receipt, 'form')
nonce = get(str, form, 'nonce')
comment = get(str, form, 'comment')
digest = get(str, form, 'captcha-digest', '')
answer = get(str, form, 'captcha-answer', '')
return WS.MESSAGE, (nonce, comment, digest, answer)
nonce = receipt.get('nonce')
if not isinstance(nonce, str):
raise Malformed('malformed nonce')
case 'appearance':
form = get(dict, receipt, 'form')
name = get(str, form, 'name').strip()
if len(name) == 0:
name = None
color = get(str, form, 'color')
password = get(str, form, 'password')
#match get(str | None, form, 'want-tripcode'):
# case '0':
# want_tripcode = False
# case '1':
# want_tripcode = True
# case _:
# want_tripcode = None
want_tripcode = bool(password)
return WS.APPEARANCE, (name, color, password, want_tripcode)
digest = receipt.get('captcha-digest', '')
answer = receipt.get('captcha-answer', '')
case 'captcha':
return WS.CAPTCHA, ()
return nonce, comment, digest, answer
case _:
raise Malformed('malformed type')

ファイルの表示

@ -1,13 +1,14 @@
import asyncio
import json
from quart import current_app, websocket
from anonstream.stream import get_stream_title, get_stream_uptime
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance
from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse_websocket_data, Malformed
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
CONFIG = current_app.config
@ -15,8 +16,8 @@ async def websocket_outbound(queue, user):
payload = {
'type': 'init',
'nonce': generate_nonce(),
'title': get_stream_title(),
'uptime': get_stream_uptime(),
'title': await get_stream_title(),
'stats': get_stream_uptime_and_viewership(for_websocket=True),
'messages': get_all_messages_for_websocket(),
'users': get_all_users_for_websocket(),
'default': {
@ -33,10 +34,14 @@ async def websocket_outbound(queue, user):
async def websocket_inbound(queue, user):
while True:
receipt = await websocket.receive_json()
see(user)
try:
nonce, comment, digest, answer = parse_websocket_data(receipt)
receipt = await websocket.receive_json()
except json.JSONDecodeError:
receipt = None
finally:
see(user)
try:
receipt_type, parsed = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
@ -44,29 +49,65 @@ async def websocket_inbound(queue, user):
'because': error,
}
else:
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:
notice, *_ = e.args
else:
try:
message_was_added = add_chat_message(
user,
nonce,
comment,
ignore_empty=verification_happened,
)
except Rejected as e:
notice, *_ = e.args
else:
deverify(user)
notice = None
payload = {
'type': 'ack',
'nonce': nonce,
'next': generate_nonce(),
'notice': notice,
'clear': message_was_added,
'digest': get_random_captcha_digest_for(user),
}
match receipt_type:
case WS.MESSAGE:
handle = handle_inbound_message
case WS.APPEARANCE:
handle = handle_inbound_appearance
case WS.CAPTCHA:
handle = handle_inbound_captcha
payload = handle(user, *parsed)
queue.put_nowait(payload)
def handle_inbound_captcha(user):
return {
'type': 'captcha',
'digest': get_random_captcha_digest_for(user),
}
def handle_inbound_appearance(user, name, color, password, want_tripcode):
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
return {
'type': 'appearance',
'errors': [error.args for error in errors],
}
else:
return {
'type': 'appearance',
'result': 'Changed appearance',
'name': user['name'],
'color': user['color'],
#'tripcode': user['tripcode'],
}
def handle_inbound_message(user, nonce, comment, digest, answer):
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:
notice, *_ = e.args
message_was_added = False
else:
try:
message_was_added = add_chat_message(
user,
nonce,
comment,
ignore_empty=verification_happened,
)
except Rejected as e:
notice, *_ = e.args
message_was_added = False
else:
notice = None
if message_was_added:
deverify(user)
return {
'type': 'ack',
'nonce': nonce,
'next': generate_nonce(),
'notice': notice,
'clear': message_was_added,
'digest': get_random_captcha_digest_for(user),
}

ファイルの表示

@ -53,3 +53,24 @@ def ttl_cache(ttl):
return wrapper
return ttl_cache_specific
def ttl_cache_async(ttl):
'''
Async version of `ttl_cache`. Wraps zero-argument coroutines.
'''
def ttl_cache_specific(f):
value, expires = None, None
@wraps(f)
async def wrapper():
nonlocal value, expires
if expires is None or time.monotonic() >= expires:
value = await f()
expires = time.monotonic() + ttl
return value
return wrapper
return ttl_cache_specific

ファイルの表示

@ -12,6 +12,10 @@ search_cooldown = 0.25
search_timeout = 5.0
stream_initial_buffer = 3
[title]
file = "title.txt"
file_cache_lifetime = 0.5
[captcha]
lifetime = 1800
fonts = []
@ -30,6 +34,7 @@ chat_scrollback = 256
rotate_users = 60.0
rotate_captchas = 60.0
broadcast_users_update = 4.0
broadcast_stream_info_update = 3.0
[names]
broadcaster = "Broadcaster"
@ -40,6 +45,7 @@ max_comment_length = 512
max_name_length = 24
min_name_contrast = 3.0
background_color = "#232327"
legacy_tripcode_algorithm = false
[flood]
duration = 20.0

1
title.txt ノーマルファイル
ファイルの表示

@ -0,0 +1 @@
Lorem ipsum dolor sit amet