Commit 51ee691c authored by nextime's avatar nextime

Bidirectional communication and status persistance implemented

parent 9ca09ce5
...@@ -37,9 +37,13 @@ config.read('shmcamstudio.conf') ...@@ -37,9 +37,13 @@ config.read('shmcamstudio.conf')
# Inter thread communication # Inter thread communication
qcore = queue.Queue() # IN -> core application (studio.py) qcore = queue.Queue() # IN -> core application (studio.py)
qobs = queue.Queue() # IN -> OBS module qobs = queue.Queue() # IN -> OBS module
qweb = queue.Queue() # IN -> Web Panel
qpnl = queue.Queue() # IN -> tkinter panel
builtins.qcore = qcore builtins.qcore = qcore
builtins.qobs = qobs builtins.qobs = qobs
builtins.qweb = qweb
builtins.qpnl = qpnl
builtins.config = config builtins.config = config
# Setup logging # Setup logging
......
...@@ -46,23 +46,29 @@ status.tease.enable = 5,3 ...@@ -46,23 +46,29 @@ status.tease.enable = 5,3
[OUTPUT:smstefy] [OUTPUT:smstefy]
obs = slut obs = slut
scene = live SFW scene = live SFW
source.title = 5 status.open.disable = 5,4,3
source.closed = 4 status.close.disable = 3
source.tease = 3 status.close.enable = 5,4
status.tease.disable = 4
status.tease.enable = 5,3
[OUTPUT:shine] [OUTPUT:shine]
obs = slut obs = slut
scene = SHINE scene = SHINE
source.title = 4 status.open.disable = 4,2,3
source.closed = 2 status.close.disable = 3
source.tease = 3 status.close.enable = 4,2
status.tease.disable = 2
status.tease.enable = 4,3
#[OUTPUT:livejasmin] #[OUTPUT:livejasmin]
#obs = slut #obs = slut
#scene = LIVE JM #scene = LIVE JM
#source_title = 4,5 #status.open.disable = 2,3,4,5
#source_closed = 3 ##status.close.disable = 2
#source_tease = 2 #status.close.enable = 3,4,5
#status.tease.disable = 3
#status.tease.enable = 2,4,5
# BUTTON:<rownum>:<button_name> # BUTTON:<rownum>:<button_name>
...@@ -145,13 +151,18 @@ action = open_all ...@@ -145,13 +151,18 @@ action = open_all
color = teal color = teal
[BUTTON:3:scene_manual] [BUTTON:3:scene_manual]
title = SCENE MANUAL title = MANUAL
action = scene_manual action = scene_manual
color = blue color = blue
color.manual = blue
color.automatic = red
title.manual = MANUAL
title.automatic = AUTO
feedback = status
[ACTION:scene_manual] [ACTION:scene_manual]
setstatus = manual setstatus = change
[ACTION:private_shine] [ACTION:private_shine]
...@@ -179,10 +190,10 @@ execute = /usr/local/bin/smblur_stefy ...@@ -179,10 +190,10 @@ execute = /usr/local/bin/smblur_stefy
#execute = /usr/local/bin/smblur_jasmin #execute = /usr/local/bin/smblur_jasmin
[ACTION:tease_all] [ACTION:tease_all]
execute = /usr/local/bin/smblur_tease execute = /usr/local/bin/smblur_teaseall
[ACTION:tease] [ACTION:tease]
execute = /usr/local/bin/smblur_teaseall execute = /usr/local/bin/smblur_tease
[ACTION:open_all] [ACTION:open_all]
execute = /usr/local/bin/smblur_clean execute = /usr/local/bin/smblur_clean
......
...@@ -71,10 +71,8 @@ class OBSOutput: ...@@ -71,10 +71,8 @@ class OBSOutput:
if hasattr(self, 'status.'+status+'.enable'): if hasattr(self, 'status.'+status+'.enable'):
self.enable(getattr(self, 'status.'+status+'.enable').split(',')) self.enable(getattr(self, 'status.'+status+'.enable').split(','))
def updateStatus(self): def _updateStatus(self):
if not self.obss.online: if not self.obss.online:
self.status = False
logging.info('FOUND False')
return False return False
for inp in self.inputs.keys(): for inp in self.inputs.keys():
self.inputs[inp] = self.obss.getInputStatus(self.scene, inp) self.inputs[inp] = self.obss.getInputStatus(self.scene, inp)
...@@ -90,13 +88,17 @@ class OBSOutput: ...@@ -90,13 +88,17 @@ class OBSOutput:
if self.inputs[disabled]: if self.inputs[disabled]:
found = False found = False
if found: if found:
self.status = found
logging.info("FOUND "+found)
return found return found
logging.info('FOUND False')
self.status = found
return found return found
def updateStatus(self):
status = self._updateStatus()
if self.status != status:
self.status = status
logging.info('OUTPUT '+self.output+" FOUND "+str(self.status))
self.obss.queue.put({ 'event': 'OUTPUTCHANGE', 'data': {'server': self.obss.server, 'output': self.output, 'status': self.status }})
return self.status
def getStatus(self): def getStatus(self):
if not self.status: if not self.status:
self.status = self.updateStatus() self.status = self.updateStatus()
...@@ -221,6 +223,10 @@ def run_obs_controller(): ...@@ -221,6 +223,10 @@ def run_obs_controller():
for output in obs_outputs.values(): for output in obs_outputs.values():
if output.obss.server == data['server'] and output.scene==data['scene'] and str(data['source']) in output.inputs.keys(): if output.obss.server == data['server'] and output.scene==data['scene'] and str(data['source']) in output.inputs.keys():
output.updateStatus() output.updateStatus()
if event == 'OUTPUTCHANGE':
#{ 'event': 'OUTPUTCHANGE', 'data': {'server': self.obss.server, 'output': self.output, 'status': self.status }}
logging.info("OUTPUTCHANGE: output "+data['output']+ " on obs "+data['server']+" is now "+str(data['status']))
qcore.put(task)
time.sleep(.01) time.sleep(.01)
......
...@@ -71,6 +71,9 @@ def create_panel_gui(): ...@@ -71,6 +71,9 @@ def create_panel_gui():
helv36 = tkFont.Font(family='Helvetica', size=config.get("Tkinter", "font_size", fallback=12), weight='bold') helv36 = tkFont.Font(family='Helvetica', size=config.get("Tkinter", "font_size", fallback=12), weight='bold')
# Interval in ms to read the queue
qinterval = 200
# Frame for the left side # Frame for the left side
fleft = tk.Frame(window) fleft = tk.Frame(window)
fleft.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) fleft.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
...@@ -86,11 +89,12 @@ def create_panel_gui(): ...@@ -86,11 +89,12 @@ def create_panel_gui():
# Buttons configuration # Buttons configuration
buttons, numrows = get_buttons() buttons, numrows = get_buttons()
bh = int(55/numrows) bh = int(55/numrows)
if numrows > 0: if numrows > 0:
row = 1 row = 1
bframes = {} bframes = {}
outputs = {}
feedbacks = {}
while row <= numrows: while row <= numrows:
# create the frame for this row # create the frame for this row
bframes[row] = tk.Frame(fright) bframes[row] = tk.Frame(fright)
...@@ -103,15 +107,27 @@ def create_panel_gui(): ...@@ -103,15 +107,27 @@ def create_panel_gui():
col=0 col=0
logging.info(buttons[row]) logging.info(buttons[row])
for b in buttons[row].keys(): for b in buttons[row].keys():
command=None #command=None
if config.has_section('ACTION:'+buttons[row][b]['action']): #if config.has_section('ACTION:'+buttons[row][b]['action']):
command=config.get('ACTION:'+buttons[row][b]['action'], 'execute', fallback=None) # command=config.get('ACTION:'+buttons[row][b]['action'], 'execute', fallback=None)
setstatus=config.get('ACTION:'+buttons[row][b]['action'], 'setstatus', fallback=None) # setstatus=config.get('ACTION:'+buttons[row][b]['action'], 'setstatus', fallback=None)
color=buttons[row][b]['color'] color=buttons[row][b]['color']
button = tk.Button(bframes[row], text=buttons[row][b]['title'], font=helv36, width=bw, height=bh, bg=color, fg="white", button = tk.Button(bframes[row], text=buttons[row][b]['title'], font=helv36, width=bw, height=bh, bg=color, fg="white",
command=lambda cmd=command,sts=setstatus: run_action(cmd,sts)) command=lambda action=buttons[row][b]['action']: run_action(action))
#command=lambda cmd=command,sts=setstatus: run_action(cmd,sts))
button.grid(row=0, column=col, sticky='nsew') button.grid(row=0, column=col, sticky='nsew')
if 'output' in buttons[row][b].keys():
outputs[buttons[row][b]['output']] = {'btn': button, 'cfg': buttons[row][b]}
elif 'feedback' in buttons[row][b].keys():
if not buttons[row][b]['feedback'] in feedbacks.keys():
feedbacks[buttons[row][b]['feedback']] = [{'btn': button, 'cfg': buttons[row][b]}]
else:
feedbacks[buttons[row][b]['feedback']].append({'btn': button, 'cfg': buttons[row][b]})
col = col+1 col = col+1
# Configure the columns in the frame # Configure the columns in the frame
...@@ -125,5 +141,37 @@ def create_panel_gui(): ...@@ -125,5 +141,37 @@ def create_panel_gui():
bg="purple", fg="white", font=helv36, height=2) bg="purple", fg="white", font=helv36, height=2)
web_button.grid(row=1, column=1, sticky='nsew') web_button.grid(row=1, column=1, sticky='nsew')
def read_queue():
if not qpnl.empty():
task=qpnl.get(block=True)
if task:
logging.info('TASK INCOMING FOR TK PANEL')
logging.info(task)
event = task['event']
data = task['data']
if event == 'OUTPUTCHANGE':
if data['output'] in outputs.keys():
logging.info('CHANGE THE COLOR OF THE BUTTON FOR '+str(data['output']))
btn = outputs[data['output']]['btn']
bcfg = outputs[data['output']]['cfg']
if 'color.'+str(data['status']) in bcfg.keys():
btn.config(bg=bcfg['color.'+str(data['status'])])
if 'title.'+str(data['status']) in bcfg.keys():
btn.config(text=bcfg['title.'+str(data['status'])])
elif event == 'STATUSCHANGE':
if 'status' in feedbacks.keys():
for b in feedbacks['status']:
btn = b['btn']
bcfg = b['cfg']
if 'color.'+str(data['status']) in bcfg.keys():
btn.config(bg=bcfg['color.'+str(data['status'])])
if 'title.'+str(data['status']) in bcfg.keys():
btn.config(text=bcfg['title.'+str(data['status'])])
window.after(qinterval, read_queue)
window.after(qinterval, read_queue) # ms
return window return window
...@@ -22,21 +22,44 @@ logging.getLogger(__name__) ...@@ -22,21 +22,44 @@ logging.getLogger(__name__)
STATUSES=[ STATUSES=[
'manual', 'manual',
'open', 'automatic'
'close'
] ]
class TaskEngine(): class TaskEngine():
status='init' status='manual'
scene='open'
def set_manual(self, manual=True):
nst = False
if manual and self.status != 'manual':
nst = 'manual'
elif not manual and self.status == 'manual':
nst = 'automatic'
if nst:
logging.info('CHANGE STATUS TO '+nst)
self.status=nst
evt={'event': 'STATUSCHANGE', 'data':{'status': nst}}
qpnl.put(evt)
qweb.put(evt)
def process_task(self, task): def process_task(self, task):
cmd, val = task.split(':', 1) event = task['event']
if cmd=='SETSTATUS' and val in STATUSES: if 'data' in task.keys():
logging.info('SETSTATUS TO '+val) data = task['data']
if self.status != val: if event=='SETSTATUS' and data['status'] in ['manual','automatic']:
self.status=val if self.status != data['status']:
self.set_manual(data['status']=='manual')
elif event=='CHANGESTATUS':
self.set_manual(self.status!='manual')
#if self.status == 'manual':
# self.set_manual(False)
#else:
# self.set_manual(True)
elif event=='OUTPUTCHANGE':
qpnl.put(task)
qweb.put(task)
......
...@@ -31,9 +31,20 @@ def run_command(command): ...@@ -31,9 +31,20 @@ def run_command(command):
return f"Error: {e}" return f"Error: {e}"
def run_action(command, setstatus=None): def run_action(action):
if not config.has_section('ACTION:'+action):
return False
command=config.get('ACTION:'+action, 'execute', fallback=None)
setstatus=config.get('ACTION:'+action, 'setstatus', fallback=None)
open_outputs=config.get('ACTION:'+action, 'open.outputs', fallback=None)
if setstatus: if setstatus:
qcore.put('SETSTATUS:'+str(setstatus), block=False) if setstatus in ['manual','automatic']:
qcore.put({'event': 'SETSTATUS', 'data': {'status': setstatus}})
elif setstatus in ['switch','change','flip']:
qcore.put({'event': 'CHANGESTATUS'})
if command: if command:
return run_command(command) return run_command(command)
......
...@@ -13,17 +13,48 @@ ...@@ -13,17 +13,48 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Flask, render_template, request from flask import Flask, render_template, request, session
from utils import check_port_available, run_command, create_daemon, run_action from utils import check_port_available, run_command, create_daemon, run_action
import sys import sys
import os import os
from guiutils import get_buttons from guiutils import get_buttons
import flask_restful as restful import flask_restful as restful
from flask_socketio import SocketIO, emit
import logging
logging.getLogger(__name__)
# Flask App Setup # Flask App Setup
flask_app = Flask('SHMCamStudio', template_folder=TEMPLATE_DIR) flask_app = Flask('SHMCamStudio', template_folder=TEMPLATE_DIR)
flask_api = restful.Api(flask_app) flask_api = restful.Api(flask_app)
# XXX BugFix in eventlet used in socketio
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
socketio = SocketIO(flask_app)
buttons, numrows = get_buttons()
outputs = {}
feedbacks = {}
if numrows > 0:
row = 1
while row <= numrows:
for b in buttons[row].keys():
if 'output' in buttons[row][b].keys():
outputs[buttons[row][b]['output']] = {'cfg': buttons[row][b]}
elif 'feedback' in buttons[row][b].keys():
if not buttons[row][b]['feedback'] in feedbacks.keys():
feedbacks[buttons[row][b]['feedback']] = [{'cfg': buttons[row][b]}]
else:
feedbacks[buttons[row][b]['feedback']].append({'btn': button, 'cfg': buttons[row][b]})
row = row+1
class PollAPI(restful.Resource): class PollAPI(restful.Resource):
...@@ -65,7 +96,7 @@ class AppData(restful.Resource): ...@@ -65,7 +96,7 @@ class AppData(restful.Resource):
@flask_app.route('/') @flask_app.route('/')
def index(): def index():
buttons, numrows = get_buttons() #buttons, numrows = get_buttons()
row = 1 row = 1
style_rows="" style_rows=""
htmlbuttons="" htmlbuttons=""
...@@ -82,6 +113,8 @@ def index(): ...@@ -82,6 +113,8 @@ def index():
pollclass='' pollclass=''
if 'output' in buttons[row][b].keys(): if 'output' in buttons[row][b].keys():
pollclass='output_'+buttons[row][b]['output'] pollclass='output_'+buttons[row][b]['output']
elif 'feedback'in buttons[row][b].keys():
pollclass='feedback_'+buttons[row][b]['feedback']
htmlbuttons=htmlbuttons+"""<button style="color:white;background-color:"""+color+""";" htmlbuttons=htmlbuttons+"""<button style="color:white;background-color:"""+color+""";"
class="button private button_row"""+str(row)+" "+pollclass+""" " onclick="executeCommand('"""+command+"""')"> class="button private button_row"""+str(row)+" "+pollclass+""" " onclick="executeCommand('"""+command+"""')">
"""+buttons[row][b]['title']+""" """+buttons[row][b]['title']+"""
...@@ -98,12 +131,7 @@ def execute(): ...@@ -98,12 +131,7 @@ def execute():
command_key = request.form.get('command') command_key = request.form.get('command')
if config.has_section('ACTION:'+command_key): if config.has_section('ACTION:'+command_key):
command = config.get('ACTION:'+command_key, 'execute', fallback=None) result = run_action(command_key)
setstatus=config.get('ACTION:'+command_key, 'setstatus', fallback=None)
if command or setstatus:
result = run_action(command, setstatus)
else:
return "No command available"
return result or 'OK' return result or 'OK'
else: else:
return "Invalid command", 400 return "Invalid command", 400
...@@ -115,6 +143,49 @@ def stream(): ...@@ -115,6 +143,49 @@ def stream():
@socketio.event
def my_event(message):
session['receive_count'] = session.get('receive_count', 0) + 1
emit('my_response',
{'data': message['data'], 'count': session['receive_count']})
@socketio.event
def my_ping():
emit('my_pong')
@socketio.event
def get_queue():
count=5
while not qweb.empty() and count > 0:
count = count-1
task=qweb.get(block=True)
if task:
logging.info('TASK INCOMING FOR WEB')
logging.info(task)
event = task['event']
data = task['data']
if event == 'OUTPUTCHANGE':
if data['output'] in outputs.keys():
logging.info('CHANGE THE COLOR OF THE WEB BUTTON FOR '+str(data['output']))
bcfg = outputs[data['output']]['cfg']
if 'color.'+str(data['status']) in bcfg.keys():
emit('change_output', {'button': data['output'], 'color': bcfg['color.'+str(data['status'])] }, broadcast=True)
if 'title.'+str(data['status']) in bcfg.keys():
emit('change_output', {'button': data['output'], 'title': bcfg['title.'+str(data['status'])] }, broadcast=True)
elif event == 'STATUSCHANGE':
if 'status' in feedbacks.keys():
for b in feedbacks['status']:
bcfg = b['cfg']
if 'color.'+str(data['status']) in bcfg.keys():
emit('change_feedback', {'feedback': 'status', 'color': bcfg['color.'+str(data['status'])] }, broadcast=True)
#btn.config(bg=bcfg['color.'+str(data['status'])])
if 'title.'+str(data['status']) in bcfg.keys():
emit('change_feedback', {'feedback': 'status', 'title': bcfg['title.'+str(data['status'])] }, broadcast=True)
def run_flask_app(port=5000, daemon_mode=False): def run_flask_app(port=5000, daemon_mode=False):
"""Run Flask app with optional daemon mode""" """Run Flask app with optional daemon mode"""
if not check_port_available(port): if not check_port_available(port):
...@@ -128,5 +199,9 @@ def run_flask_app(port=5000, daemon_mode=False): ...@@ -128,5 +199,9 @@ def run_flask_app(port=5000, daemon_mode=False):
flask_api.add_resource(PollAPI, '/update') flask_api.add_resource(PollAPI, '/update')
flask_api.add_resource(AppData, '/data') flask_api.add_resource(AppData, '/data')
flask_app.run(host='0.0.0.0', port=port, debug=False, use_reloader=False) #flask_app.run(host='0.0.0.0', port=port, debug=False, use_reloader=False)
# for socketio
socketio.run(flask_app, host='0.0.0.0', port=port, debug=True, use_reloader=False)
...@@ -157,6 +157,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -157,6 +157,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
} }
} }
</style> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" integrity="sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==" crossorigin="anonymous"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js" integrity="sha384-2huaZvOR9iDzHqslqwpR87isEmrfxqyWOF7hr7BY6KG0+hVKLoEXMPUJw3ynWuhO" crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div class="main-container"> <div class="main-container">
...@@ -200,13 +202,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -200,13 +202,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.then(result => { .then(result => {
console.log(result); console.log(result);
// Visual feedback // Visual feedback
event.target.style.backgroundColor = '#45a049'; /*event.target.style.backgroundColor = '#45a049';
setTimeout(() => { setTimeout(() => {
event.target.style.backgroundColor = event.target.style.backgroundColor =
event.target.classList.contains('private') ? '#4CAF50' : event.target.classList.contains('private') ? '#4CAF50' :
event.target.classList.contains('toggle') ? '#2196F3' : event.target.classList.contains('toggle') ? '#2196F3' :
'#FF9800'; '#FF9800';
}, 300); }, 300); */
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
...@@ -248,54 +250,75 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -248,54 +250,75 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
document.addEventListener('touchmove', function(event) { document.addEventListener('touchmove', function(event) {
if (event.scale !== 1) { event.preventDefault(); } if (event.scale !== 1) { event.preventDefault(); }
}, { passive: false }); }, { passive: false });
</script>
</body>
</html>
<script>
const sidebar = document.querySelector('.sidebar');
const resizeHandle = document.getElementById('resize-handle');
const buttonsContainer = document.querySelector('.buttons-container');
const containerWidth = document.querySelector('.main-container').clientWidth;
function adjustSidebarWidth(newWidth) {
sidebar.style.width = `${newWidth}px`;
buttonsContainer.style.width = `${containerWidth - newWidth}px`;
// Notify the iframe about the resize
var iframe = document.querySelector(".video-container iframe");
if (iframe) {
iframe.contentWindow.postMessage("resize", "*");
}
}
let isResizing = false; $(document).ready(function() {
// Connect to the Socket.IO server.
// The connection URL has the following format, relative to the current page:
// http[s]://<domain>:<port>[/<namespace>]
var socket = io();
// Event handler for new connections.
// The callback function is invoked when a connection with the
// server is established.
socket.on('connect', function() {
socket.emit('my_event', {data: 'I\'m connected!'});
});
resizeHandle.addEventListener('mousedown', (e) => { // Event handler for server sent data.
isResizing = true; // The callback function is invoked whenever the server emits data
document.addEventListener('mousemove', handleMouseMove); // to the client. The data is then displayed in the "Received"
document.addEventListener('mouseup', stopResize); // section of the page.
socket.on('my_response', function(msg, cb) {
$('#log').append('<br>' + $('<div/>').text('Received #' + msg.count + ': ' + msg.data).html());
if (cb)
cb();
}); });
function handleMouseMove(e) { // Interval function that tests message latency by sending a "ping"
if (!isResizing) return; // message. The server then responds with a "pong" message and the
let newWidth = e.clientX; // round trip time is measured.
var ping_pong_times = [];
var start_time;
window.setInterval(function() {
start_time = (new Date).getTime();
$('#transport').text(socket.io.engine.transport.name);
socket.emit('my_ping');
}, 1000);
// Handler for the "pong" message. When the pong is received, the
// time from the ping is stored, and the average of the last 30
// samples is average and displayed.
socket.on('my_pong', function() {
var latency = (new Date).getTime() - start_time;
ping_pong_times.push(latency);
ping_pong_times = ping_pong_times.slice(-30); // keep last 30 samples
var sum = 0;
for (var i = 0; i < ping_pong_times.length; i++)
sum += ping_pong_times[i];
$('#ping-pong').text(Math.round(10 * sum / ping_pong_times.length) / 10);
});
// Limit sidebar width between 50px and 50% of screen window.setInterval(function() {
newWidth = Math.max(50, Math.min(newWidth, containerWidth / 2)); socket.emit('get_queue');
}, 200);
adjustSidebarWidth(newWidth); socket.on('change_output', function(msg, cb) {
} //console.log('CHANGE OUTPUT');
//console.log(msg);
$('.output_'+msg.button).css('background-color', msg.color);
});
function stopResize() { socket.on('change_feedback', function(msg, cb) {
isResizing = false; if(msg.hasOwnProperty("color"))
document.removeEventListener('mousemove', handleMouseMove); $('.feedback_'+msg.feedback).css('background-color', msg.color);
document.removeEventListener('mouseup', stopResize); if(msg.hasOwnProperty("title"))
} $('.feedback_'+msg.feedback).text(msg.title);
});
// Prevent zoom on double-tap for mobile });
document.addEventListener('touchmove', function(event) {
if (event.scale !== 1) { event.preventDefault(); }
}, { passive: false });
</script> </script>
</body> <div id="log"></div>
<div id="ping-pong"></div>ms</b>
<div id="transport"></div>
</body>
</html> </html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment