quiz/public/buzzer.js
ducklet 4908b1fc6e add user session reclaiming
In its current state the implementation should allow a user to resume
their session if the websocket connection is reset, for whatever reason.
This could be expanded to allow session sharing (multiple agents logging
in to the same client), or manual session resume via some sort of
password (encode uid & key to some pass-phrase kinda thing, or QR code).
2021-01-31 00:19:35 +01:00

270 lines
6.5 KiB
JavaScript

"use strict"
/* global document, window */
const crypto = window.crypto
const location = document.location
const performance = window.performance
const storage = window.sessionStorage
// TODOs
// - measure/report latency
// - use server reported time to find winner
import config from "./config.js"
const buzzer_key = {
code: 0x20,
name: "space bar",
}
const q = (selector, root) => (root || document).querySelector(selector)
const on = (event, cb) => document.addEventListener(event, cb)
function node(type, { appendTo, cls, text, data, style, ...attrs } = {}) {
let elem = typeof type === "string" ? document.createElement(type) : type
if (cls) {
elem.className = cls
}
if (text) {
elem.textContent = text
}
Object.assign(elem.dataset, data ?? {})
Object.assign(elem.style, style ?? {})
for (const name in attrs) {
elem.setAttribute(name, attrs[name])
}
if (appendTo) {
elem = appendTo.appendChild(elem)
}
return elem
}
/**
* Some duration conversion constants.
*/
const ms_ns = 1_000_000 // nanoseconds in a millisecond
const s_ms = 1_000 // milliseconds in a second
const s_ns = 1_000_000_000 // nanoseconds in a second
let socket,
servertime,
toffset_ms,
clients = [],
me,
session_key
function hide(e) {
e = typeof e === "string" ? q(`#${e}`) : e
e.style.display = "none"
}
function show(e) {
e = typeof e === "string" ? q(`#${e}`) : e
e.style.display = "block"
}
function session_id() {
const match = /^#?(.+)/.exec(document.location.hash)
return match ? match[1] : null
}
function new_session_id() {
if (!crypto) {
return Math.random().toString(36).substr(2)
}
const data = new Uint8Array(10)
crypto.getRandomValues(data)
return Array.from(data, (v) => v.toString(36)).join("")
}
function setup_url() {
const sid = session_id() || new_session_id()
document.location.hash = sid
}
function send(type, value) {
// console.debug('sending', value)
socket.send(JSON.stringify({ type, value }))
}
function clear(container) {
while (container.children.length > 0) {
const child = container.children[0]
child.remove()
}
}
let ul
function redraw_clients(me, clients) {
if (!me) {
return
}
clear(ul)
const player_tpl = q("template#player").content.firstElementChild
for (const c of clients) {
node(player_tpl.cloneNode(), {
text: c.name,
data: { cid: c.id },
appendTo: ul,
cls: c.id === me.id ? "me" : "",
})
}
}
const highlights = {}
function highlight(client_id, until_ns) {
if (highlights[client_id]) {
return
}
const timeout_ms = (until_ns - servertime_now_ns()) / ms_ns
if (timeout_ms <= 10) {
console.warn("That highlight timeout was ridiculously low:", client_id, timeout_ms)
return
}
for (const li of ul.children) {
if (li.dataset.cid === client_id) {
li.classList.add("buzzing")
if (Object.keys(highlights).length) {
li.classList.add("too-late")
} else {
li.classList.add("first")
}
highlights[client_id] = setTimeout(() => {
delete highlights[client_id]
li.classList.remove("buzzing")
li.classList.remove("too-late")
li.classList.remove("first")
}, timeout_ms)
return
}
}
}
/**
* Guess the exact current server time.
*/
function servertime_now_ns() {
const now_ms = performance.now()
const delta_ns = ms_ns * (now_ms - toffset_ms)
return servertime + delta_ns
}
function setup_ui() {
on("focus", (event) => {
hide("active")
hide("inactive")
show("ready")
})
on("blur", (event) => {
hide("active")
show("inactive")
hide("ready")
})
if (document.hasFocus()) {
hide("inactive")
} else {
hide("ready")
}
q("#bkey").textContent = buzzer_key.name
let buzzing = false
on("keydown", (event) => {
if (!buzzing && event.keyCode === buzzer_key.code) {
buzzing = true
send("buzz", servertime_now_ns())
show("active")
hide("ready")
}
})
on("keyup", (event) => {
if (event.keyCode === buzzer_key.code) {
buzzing = false
hide("active")
show("ready")
}
})
const username_el = q("#username")
if (storage["my_name"]) {
username_el.value = storage["my_name"]
}
username_el.addEventListener("change", (event) => {
set_name(event.target.value)
})
ul = q("#info ul")
}
function set_name(name) {
storage["my_name"] = name
send("name", name)
}
function session_url(sid) {
return `${config.wsurl}/${sid}`
}
function session_id_from_url(url) {
const wsurl = new URL(config.wsurl)
const match = RegExp(`${wsurl.pathname}/([^/]+)$`).exec(url)
return !match ? null : match[1]
}
function setup_ws() {
const sid = session_id()
const credentials = { id: storage["my_uid"], key: storage["my_key"] }
socket = new WebSocket(`${session_url(sid)}`)
socket.addEventListener("open", function (event) {
if (sid === storage["my_sid"]) {
send("login", credentials)
}
set_name(q("#username").value)
})
socket.addEventListener("message", function (event) {
const msg = JSON.parse(event.data)
if (msg.type === "time") {
servertime = msg.value
toffset_ms = performance.now()
} else if (msg.type === "id") {
me = { id: msg.id, key: msg.key, path: msg.path }
storage["my_uid"] = me.id
storage["my_key"] = me.key
storage["my_sid"] = session_id_from_url(me.path)
redraw_clients(me, clients)
} else if (msg.type === "session_key") {
session_key = { path: msg.path, key: msg.key }
storage["session_path"] = session_key.path
storage["session_key"] = session_key.key
} else if (msg.type === "buzz") {
const buzztime_ns = msg.time
const client_id = msg.client
const duration_ns = 3 * s_ns
const until_ns = buzztime_ns + duration_ns
highlight(client_id, until_ns)
} else if (msg.type === "clients") {
clients = msg.value
redraw_clients(me, clients)
} else if (msg.type === "client") {
const client = { name: msg.name, id: msg.id, active: msg.active }
for (const c of clients) {
if (c.id === client.id) {
c.name = client.name
redraw_clients(me, clients)
return
}
}
clients.push(client)
redraw_clients(me, clients)
} else if (msg.type === "error") {
console.error(`Error: ${msg.reason}`)
const errorbox = q("#error")
q("code", errorbox).textContent = JSON.stringify(msg, null, 2)
show(errorbox)
} else {
console.error(`Unknown message: ${event.data}`)
}
})
}
setup_url()
setup_ui()
setup_ws()