fix #1 introduce the /button API to control the workflow with a single big red button

This commit is contained in:
Davide Alberani 2019-11-01 20:42:54 +01:00
parent d4cd19cca4
commit 1576411103
6 changed files with 267 additions and 63 deletions

View file

@ -36,7 +36,7 @@ Some browsers require HTTPS, to allow access to the webcam. In the *ssl/* direc
# License and copyright # License and copyright
Copyright 2018 Davide Alberani <da@mimante.net> Copyright 2018-2019 Davide Alberani <da@mimante.net>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

6
static/css/iziToast.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -6,20 +6,22 @@
<link rel="icon" type="image/png" href="/static/images/sb-favicon.png"> <link rel="icon" type="image/png" href="/static/images/sb-favicon.png">
<link rel="stylesheet" type="text/css" href="/static/css/materialize-icons.css"> <link rel="stylesheet" type="text/css" href="/static/css/materialize-icons.css">
<link rel="stylesheet" type="text/css" href="/static/css/materialize.min.css"> <link rel="stylesheet" type="text/css" href="/static/css/materialize.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/iziToast.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/sb.css"> <link rel="stylesheet" type="text/css" href="/static/css/sb.css">
<script type="text/javascript" src="/static/js/materialize.min.js"></script> <script type="text/javascript" src="/static/js/materialize.min.js"></script>
<script type="text/javascript" src="/static/js/iziToast.min.js"></script>
<script type="text/javascript" src="/static/js/sb.js"></script> <script type="text/javascript" src="/static/js/sb.js"></script>
</head> </head>
<body> <body>
<div id="main" class="container"> <div id="main" class="container">
<div id="header" class="row"> <div id="header" class="row center-align">
<h1>toot-my-t-shirt</h1> <h1>toot-my-t-shirt</h1>
Share the love for your geek t-shirt. Share the love for your geek t-shirt.
</div> </div>
<div id="button-containers" class="row"> <div id="button-containers" class="row center-align">
<button id="take-photo-btn" class="btn waves-effect waves-light disabled" name="take-photo-btn" onClick="takePhoto()"> <button id="take-photo-btn" class="btn waves-effect waves-light disabled" name="take-photo-btn" onClick="takePhoto()">
<i class="material-icons left">camera</i>Take photo <small>(2 secs delay)</small> <i class="material-icons left">camera</i>Take photo <small>(1 sec delay)</small>
</button> </button>
<button id="send-photo-btn" class="btn waves-effect waves-light disabled" name="send-photo-btn" onClick="sendPhoto()"> <button id="send-photo-btn" class="btn waves-effect waves-light disabled" name="send-photo-btn" onClick="sendPhoto()">
<i class="material-icons left">share</i>Share photo <i class="material-icons left">share</i>Share photo
@ -32,10 +34,7 @@
</button> </button>
</div> </div>
<div class="row"> <div class="row center-align">
<div id="sb-message">
<span id="sb-message-text">will be gone in few seconds! <span id="sb-countdown">&nbsp;</span></span>
</div>
<div id="canvas-container"> <div id="canvas-container">
<video id="sb-video" autoplay="true" muted="muted"></video> <video id="sb-video" autoplay="true" muted="muted"></video>
<canvas id="sb-canvas"></canvas> <canvas id="sb-canvas"></canvas>

6
static/js/iziToast.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
var countdown = { var Countdown = {
_timeout: null, _timeout: null,
_stepCb: null, _stepCb: null,
_timeoutCb: null, _timeoutCb: null,
@ -7,48 +7,89 @@ var countdown = {
_initial_seconds: 5, _initial_seconds: 5,
start: function(seconds, timeoutCb, stepCb) { start: function(seconds, timeoutCb, stepCb) {
countdown.stop(); Countdown.stop();
countdown.seconds = countdown._initial_seconds = seconds || 5; Countdown.seconds = Countdown._initial_seconds = seconds || 5;
countdown._timeoutCb = timeoutCb || countdown._timeoutCb; Countdown._timeoutCb = timeoutCb || Countdown._timeoutCb;
countdown._stepCb = stepCb || countdown._stepCb; Countdown._stepCb = stepCb || Countdown._stepCb;
countdown.running = true; Countdown.running = true;
countdown._step(); Countdown._step();
}, },
stop: function() { stop: function() {
if (countdown._timeout) { if (Countdown._timeout) {
window.clearTimeout(countdown._timeout); window.clearTimeout(Countdown._timeout);
} }
countdown.running = false; Countdown.running = false;
}, },
restart: function() { restart: function() {
countdown.start(countdown._initial_seconds); Countdown.start(Countdown._initial_seconds);
}, },
_step: function() { _step: function() {
if (countdown._stepCb) { if (Countdown._stepCb) {
countdown._stepCb(); Countdown._stepCb();
} }
if (countdown.seconds === 0) { if (Countdown.seconds === 0) {
if (countdown._timeoutCb) { if (Countdown._timeoutCb) {
countdown._timeoutCb(); Countdown._timeoutCb();
} }
countdown.stop(); Countdown.stop();
} else { } else {
countdown._decrement(); Countdown._decrement();
} }
}, },
_decrement: function() { _decrement: function() {
countdown.seconds = countdown.seconds - 1; Countdown.seconds = Countdown.seconds - 1;
countdown._timeout = window.setTimeout(function() { Countdown._timeout = window.setTimeout(function() {
countdown._step(); Countdown._step();
}, 1000); }, 1000);
} }
}; };
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
var UUID = uuidv4();
function getWSPath() {
var loc = window.location, new_uri;
if (loc.protocol === "https:") {
new_uri = "wss:";
} else {
new_uri = "ws:";
}
new_uri += "//" + loc.host + "/ws?uuid=" + UUID;
return new_uri;
}
var WS = new WebSocket(getWSPath(), ['soap', 'xmpp']);
WS.onerror = function(error) {
console.log('WebSocket Error ' + error);
};
WS.onmessage = function(e) {
console.log("received message on websocket: " + e.data);
var jdata = JSON.parse(e.data);
if (!(jdata && jdata.source == "button" && jdata.action == "clicked")) {
return;
}
if (!Countdown.running) {
takePhoto("press again to publish!");
} else {
sendPhoto();
}
};
function runCamera(stream) { function runCamera(stream) {
console.log("initialize the camera"); console.log("initialize the camera");
var video = document.querySelector("#sb-video"); var video = document.querySelector("#sb-video");
@ -62,8 +103,6 @@ function runCamera(stream) {
function sendData(data) { function sendData(data) {
var xhr = new XMLHttpRequest();
var boundary = "youarenotsupposedtolookatthis";
var formData = new FormData(); var formData = new FormData();
var msg = ""; var msg = "";
formData.append("selfie", new Blob([data]), "selfie.jpeg"); formData.append("selfie", new Blob([data]), "selfie.jpeg");
@ -72,9 +111,18 @@ function sendData(data) {
body: formData body: formData
}).then(function(response) { }).then(function(response) {
if (response.status !== 200) { if (response.status !== 200) {
msg = "something went wrong sending the data: " + response.status; msg = response.status;
console.log(msg); console.log(msg);
M.toast({"html": msg}); iziToast.error({
"title": "😭 something wrong sending the data 😭",
"message": msg,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
} }
cancelPhoto(); cancelPhoto();
return response.json(); return response.json();
@ -84,19 +132,42 @@ function sendData(data) {
if (json && json.success) { if (json && json.success) {
msg = "❤ ❤ ❤ photo sent successfully! ❤ ❤ ❤"; msg = "❤ ❤ ❤ photo sent successfully! ❤ ❤ ❤";
console.log(msg); console.log(msg);
M.toast({"html": msg}); iziToast.destroy();
iziToast.success({
"title": msg,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
} else { } else {
msg = "😭 😭 😭 something wrong on the backend 😭 😭 😭"; msg = json.message;
console.log(msg); console.log(msg);
M.toast({"html": msg}); iziToast.error({
msg = "the server says: " + json.message; "title": "😭 backend error 😭",
console.log(msg); "message": msg,
M.toast({"html": msg}); "titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
} }
}).catch(function(err) { }).catch(function(err) {
msg = "something went wrong connecting to server: " + err;
console.log(msg); console.log(msg);
M.toast({"html": msg}); iziToast.error({
"title": "😭 error connecting to the server 😭",
"message": err,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
cancelPhoto(); cancelPhoto();
}); });
} }
@ -104,19 +175,12 @@ function sendData(data) {
function cancelPhoto() { function cancelPhoto() {
console.log("cancel photo"); console.log("cancel photo");
document.querySelector("#sb-message").style.visibility = "hidden";
document.querySelector("#send-photo-btn").classList.add("disabled"); document.querySelector("#send-photo-btn").classList.add("disabled");
document.querySelector("#cancel-photo-btn").classList.add("disabled"); document.querySelector("#cancel-photo-btn").classList.add("disabled");
var canvas = document.querySelector("#sb-canvas"); var canvas = document.querySelector("#sb-canvas");
var context = canvas.getContext("2d"); var context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
countdown.stop(); Countdown.stop();
}
function updateSendCountdown() {
document.querySelector("#sb-countdown").innerText = "" + countdown.seconds;
console.log("deleting photo in " + countdown.seconds + " seconds");
} }
@ -130,23 +194,44 @@ function isBlank(canvas) {
function sendPhoto() { function sendPhoto() {
console.log("send photo"); console.log("send photo");
countdown.stop(); Countdown.stop();
document.querySelector("#sb-message").style.visibility = "hidden";
var canvas = document.querySelector("#sb-canvas"); var canvas = document.querySelector("#sb-canvas");
if (isBlank(canvas)) { if (!canvas || isBlank(canvas)) {
var msg = "I cowardly refuse to send a blank image."; var msg = "I cowardly refuse to send a blank image.";
console.log(msg) console.log(msg);
M.toast({"html": msg}); iziToast.warning({
"title": msg,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
return; return;
} }
return sendData(canvas.toDataURL("image/jpeg")); return sendData(canvas.toDataURL("image/jpeg"));
} }
function _takePhoto() { function _takePhoto(message) {
console.log("take photo"); console.log("take photo");
document.querySelector("#sb-message").style.visibility = "visible";
var video = document.querySelector("#sb-video"); var video = document.querySelector("#sb-video");
if (!(video.offsetWidth && video.offsetHeight)) {
var msg = "missing video";
console.log(msg);
iziToast.warning({
"title": msg,
"message": "please grant camera permissions",
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
return;
}
var canvas = document.querySelector("#sb-canvas"); var canvas = document.querySelector("#sb-canvas");
var context = canvas.getContext("2d"); var context = canvas.getContext("2d");
canvas.width = video.offsetWidth; canvas.width = video.offsetWidth;
@ -171,12 +256,22 @@ function _takePhoto() {
context.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight); context.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
document.querySelector("#send-photo-btn").classList.remove("disabled"); document.querySelector("#send-photo-btn").classList.remove("disabled");
document.querySelector("#cancel-photo-btn").classList.remove("disabled"); document.querySelector("#cancel-photo-btn").classList.remove("disabled");
countdown.start(5, cancelPhoto, updateSendCountdown); iziToast.question({
"title": "do you like what you see?",
"message": message || "press \"share photo\" to publish!",
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
Countdown.start(5, cancelPhoto);
} }
function takePhoto() { function takePhoto(msg) {
window.setTimeout(_takePhoto, 2000); window.setTimeout(function() { _takePhoto(msg); }, 1000);
} }
@ -198,7 +293,16 @@ function initCamera() {
}).catch(function(err) { }).catch(function(err) {
console.log("unable to open camera"); console.log("unable to open camera");
console.log(err); console.log(err);
M.toast({"html": "unable to open camera; please reload this page: " + err}); iziToast.error({
"title": "unable to open camera",
"message": "please reload this page: " + err,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
}); });
} }

View file

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""toot-my-t-shirt """toot-my-t-shirt
Copyright 2018 Davide Alberani <da@mimante.net> Copyright 2018-2019 Davide Alberani <da@mimante.net>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,6 +16,8 @@ limitations under the License.
""" """
import os import os
import re
import json
import base64 import base64
import logging import logging
import tempfile import tempfile
@ -25,13 +27,17 @@ import tornado.httpserver
import tornado.ioloop import tornado.ioloop
from tornado.options import define, options from tornado.options import define, options
import tornado.web import tornado.web
import tornado.websocket
from tornado import gen, escape from tornado import gen, escape
from mastodon import Mastodon from mastodon import Mastodon
API_VERSION = '1.0' API_VERSION = '1.0'
# Keep track of WebSocket connections.
_ws_clients = {}
re_slashes = re.compile(r'//+')
class Socialite: class Socialite:
def __init__(self, options, logger=None): def __init__(self, options, logger=None):
@ -190,6 +196,85 @@ class PublishHandler(BaseHandler):
self.write(reply) self.write(reply)
class ButtonHandler(BaseHandler):
@gen.coroutine
def post(self, **kwargs):
reply = {'success': True}
self.send_ws_message('/ws', json.dumps({"source": "button", "action": "clicked"}))
self.write(reply)
@gen.coroutine
def send_ws_message(self, path, message):
"""Send a WebSocket message to all the connected clients.
:param path: partial path used to build the WebSocket url
:type path: str
:param message: message to send
:type message: str
"""
try:
url = '%s://localhost:%s/ws?uuid=bigredbutton' % ('wss' if self.ssl_options else 'ws',
self.global_options.port)
self.logger.info(url)
req = tornado.httpclient.HTTPRequest(url, validate_cert=False)
ws = yield tornado.websocket.websocket_connect(req)
ws.write_message(message)
ws.close()
except Exception as e:
self.logger.error('Error yielding WebSocket message: %s', e)
class WSHandler(tornado.websocket.WebSocketHandler):
def initialize(self, **kwargs):
"""Add every passed (key, value) as attributes of the instance."""
for key, value in kwargs.items():
setattr(self, key, value)
def _clean_url(self, url):
url = re_slashes.sub('/', url)
ridx = url.rfind('?')
if ridx != -1:
url = url[:ridx]
return url
def open(self, *args, **kwargs):
try:
self.uuid = self.get_argument('uuid')
except:
self.uuid = None
url = self._clean_url(self.request.uri)
_ws_clients.setdefault(url, {})
if self.uuid and self.uuid not in _ws_clients[url]:
_ws_clients[url][self.uuid] = self
self.logger.debug('WSHandler.open %s clients connected' % len(_ws_clients[url]))
def on_message(self, message):
url = self._clean_url(self.request.uri)
self.logger.debug('WSHandler.on_message url: %s' % url)
count = 0
_to_delete = set()
current_uuid = None
try:
current_uuid = self.get_argument('uuid')
except:
pass
for uuid, client in _ws_clients.get(url, {}).items():
if uuid and uuid == current_uuid:
continue
try:
client.write_message(message)
except:
_to_delete.add(uuid)
continue
count += 1
for uuid in _to_delete:
try:
del _ws_clients[url][uuid]
except KeyError:
pass
self.logger.debug('WSHandler.on_message sent message to %d clients' % count)
def run(): def run():
"""Run the Tornado web application.""" """Run the Tornado web application."""
# command line arguments; can also be written in a configuration file, # command line arguments; can also be written in a configuration file,
@ -226,12 +311,16 @@ def run():
ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key) ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
socialite = Socialite(options, logger=logger) socialite = Socialite(options, logger=logger)
init_params = dict(global_options=options, socialite=socialite) init_params = dict(global_options=options, ssl_options=ssl_options, socialite=socialite, logger=logger)
_publish_path = r"/publish/?" _publish_path = r"/publish/?"
_button_path = r"/button/?"
application = tornado.web.Application([ application = tornado.web.Application([
(r'/ws', WSHandler, init_params),
(_publish_path, PublishHandler, init_params), (_publish_path, PublishHandler, init_params),
(r'/v%s%s' % (API_VERSION, _publish_path), PublishHandler, init_params), (r'/v%s%s' % (API_VERSION, _publish_path), PublishHandler, init_params),
(_button_path, ButtonHandler, init_params),
(r'/v%s%s' % (API_VERSION, _button_path), ButtonHandler, init_params),
(r"/(?:index.html)?", RootHandler, init_params), (r"/(?:index.html)?", RootHandler, init_params),
(r'/?(.*)', tornado.web.StaticFileHandler, {"path": "static"}) (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "static"})
], ],