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
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");
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="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/iziToast.min.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/iziToast.min.js"></script>
<script type="text/javascript" src="/static/js/sb.js"></script>
</head>
<body>
<div id="main" class="container">
<div id="header" class="row">
<div id="header" class="row center-align">
<h1>toot-my-t-shirt</h1>
Share the love for your geek t-shirt.
</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()">
<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 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
@ -32,10 +34,7 @@
</button>
</div>
<div class="row">
<div id="sb-message">
<span id="sb-message-text">will be gone in few seconds! <span id="sb-countdown">&nbsp;</span></span>
</div>
<div class="row center-align">
<div id="canvas-container">
<video id="sb-video" autoplay="true" muted="muted"></video>
<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,
_stepCb: null,
_timeoutCb: null,
@ -7,48 +7,89 @@ var countdown = {
_initial_seconds: 5,
start: function(seconds, timeoutCb, stepCb) {
countdown.stop();
countdown.seconds = countdown._initial_seconds = seconds || 5;
countdown._timeoutCb = timeoutCb || countdown._timeoutCb;
countdown._stepCb = stepCb || countdown._stepCb;
countdown.running = true;
countdown._step();
Countdown.stop();
Countdown.seconds = Countdown._initial_seconds = seconds || 5;
Countdown._timeoutCb = timeoutCb || Countdown._timeoutCb;
Countdown._stepCb = stepCb || Countdown._stepCb;
Countdown.running = true;
Countdown._step();
},
stop: function() {
if (countdown._timeout) {
window.clearTimeout(countdown._timeout);
if (Countdown._timeout) {
window.clearTimeout(Countdown._timeout);
}
countdown.running = false;
Countdown.running = false;
},
restart: function() {
countdown.start(countdown._initial_seconds);
Countdown.start(Countdown._initial_seconds);
},
_step: function() {
if (countdown._stepCb) {
countdown._stepCb();
if (Countdown._stepCb) {
Countdown._stepCb();
}
if (countdown.seconds === 0) {
if (countdown._timeoutCb) {
countdown._timeoutCb();
if (Countdown.seconds === 0) {
if (Countdown._timeoutCb) {
Countdown._timeoutCb();
}
countdown.stop();
Countdown.stop();
} else {
countdown._decrement();
Countdown._decrement();
}
},
_decrement: function() {
countdown.seconds = countdown.seconds - 1;
countdown._timeout = window.setTimeout(function() {
countdown._step();
Countdown.seconds = Countdown.seconds - 1;
Countdown._timeout = window.setTimeout(function() {
Countdown._step();
}, 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) {
console.log("initialize the camera");
var video = document.querySelector("#sb-video");
@ -62,8 +103,6 @@ function runCamera(stream) {
function sendData(data) {
var xhr = new XMLHttpRequest();
var boundary = "youarenotsupposedtolookatthis";
var formData = new FormData();
var msg = "";
formData.append("selfie", new Blob([data]), "selfie.jpeg");
@ -72,9 +111,18 @@ function sendData(data) {
body: formData
}).then(function(response) {
if (response.status !== 200) {
msg = "something went wrong sending the data: " + response.status;
msg = response.status;
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();
return response.json();
@ -84,19 +132,42 @@ function sendData(data) {
if (json && json.success) {
msg = "❤ ❤ ❤ photo sent successfully! ❤ ❤ ❤";
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 {
msg = "😭 😭 😭 something wrong on the backend 😭 😭 😭";
msg = json.message;
console.log(msg);
M.toast({"html": msg});
msg = "the server says: " + json.message;
console.log(msg);
M.toast({"html": msg});
iziToast.error({
"title": "😭 backend error 😭",
"message": msg,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
}
}).catch(function(err) {
msg = "something went wrong connecting to server: " + err;
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();
});
}
@ -104,19 +175,12 @@ function sendData(data) {
function cancelPhoto() {
console.log("cancel photo");
document.querySelector("#sb-message").style.visibility = "hidden";
document.querySelector("#send-photo-btn").classList.add("disabled");
document.querySelector("#cancel-photo-btn").classList.add("disabled");
var canvas = document.querySelector("#sb-canvas");
var context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
countdown.stop();
}
function updateSendCountdown() {
document.querySelector("#sb-countdown").innerText = "" + countdown.seconds;
console.log("deleting photo in " + countdown.seconds + " seconds");
Countdown.stop();
}
@ -130,23 +194,44 @@ function isBlank(canvas) {
function sendPhoto() {
console.log("send photo");
countdown.stop();
document.querySelector("#sb-message").style.visibility = "hidden";
Countdown.stop();
var canvas = document.querySelector("#sb-canvas");
if (isBlank(canvas)) {
if (!canvas || isBlank(canvas)) {
var msg = "I cowardly refuse to send a blank image.";
console.log(msg)
M.toast({"html": msg});
console.log(msg);
iziToast.warning({
"title": msg,
"titleSize": "3em",
"messageSize": "2em",
"close": false,
"drag": false,
"pauseOnHover": false,
"position": "topCenter"
});
return;
}
return sendData(canvas.toDataURL("image/jpeg"));
}
function _takePhoto() {
function _takePhoto(message) {
console.log("take photo");
document.querySelector("#sb-message").style.visibility = "visible";
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 context = canvas.getContext("2d");
canvas.width = video.offsetWidth;
@ -171,12 +256,22 @@ function _takePhoto() {
context.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
document.querySelector("#send-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() {
window.setTimeout(_takePhoto, 2000);
function takePhoto(msg) {
window.setTimeout(function() { _takePhoto(msg); }, 1000);
}
@ -198,7 +293,16 @@ function initCamera() {
}).catch(function(err) {
console.log("unable to open camera");
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 -*-
"""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");
you may not use this file except in compliance with the License.
@ -16,6 +16,8 @@ limitations under the License.
"""
import os
import re
import json
import base64
import logging
import tempfile
@ -25,13 +27,17 @@ import tornado.httpserver
import tornado.ioloop
from tornado.options import define, options
import tornado.web
import tornado.websocket
from tornado import gen, escape
from mastodon import Mastodon
API_VERSION = '1.0'
# Keep track of WebSocket connections.
_ws_clients = {}
re_slashes = re.compile(r'//+')
class Socialite:
def __init__(self, options, logger=None):
@ -190,6 +196,85 @@ class PublishHandler(BaseHandler):
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():
"""Run the Tornado web application."""
# 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)
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/?"
_button_path = r"/button/?"
application = tornado.web.Application([
(r'/ws', WSHandler, init_params),
(_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'/?(.*)', tornado.web.StaticFileHandler, {"path": "static"})
],