fix #1 introduce the /button API to control the workflow with a single big red button
This commit is contained in:
parent
d4cd19cca4
commit
1576411103
6 changed files with 267 additions and 63 deletions
|
@ -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
6
static/css/iziToast.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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"> </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
6
static/js/iziToast.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
208
static/js/sb.js
208
static/js/sb.js
|
@ -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"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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"})
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue