OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / static / app.js

441 lines · javascript ref:
// Cthuloid Lights page. Server state arrives over /api/events (SSE) and is the
// only source of truth; controls send changes and wait to be told the result.
import { hsvToRgb, hsvToSlot, slotToHsv, toHex } from './color.js'
import { Picker } from './picker.js'

const $ = (sel, el) => (el || document).querySelector(sel)

// ---- who is at this page ------------------------------------------------------
// A session id per browser (shared by its tabs: same person) and a display name.
// Units with shared control (firmware proto 3) show the name to everyone while
// this browser holds a slot. Storage can be unavailable (private mode): then
// both just last for this page load.

function stored (key, make) {
  try {
    let v = localStorage.getItem(key)
    if (!v) { v = make(); localStorage.setItem(key, v) }
    return v
  } catch {
    return make()
  }
}
const SESSION = stored('cthuloid.session', () => Math.random().toString(36).slice(2, 10))
let myName = stored('cthuloid.name', () => 'web-' + SESSION.slice(0, 4))

// ---- talking to the server --------------------------------------------------

async function post (path, body) {
  const res = await fetch(path, {
    method: 'POST',
    // The custom header is the cross-site guard: the server refuses POSTs
    // without it, and a foreign page cannot add it without a CORS preflight.
    headers: { 'content-type': 'application/json', 'x-cthuloid': '1' },
    body: JSON.stringify(body || {})
  })
  const out = await res.json().catch(() => ({}))
  if (!res.ok) {
    const err = new Error(out.error || (out.busy ? 'held by ' + out.busy : res.statusText))
    err.status = res.status
    err.body = out
    throw err
  }
  return out
}

// Latest-wins sender per control: at most one request in flight, and only the
// newest value is sent next. A drag can't queue a backlog, and a slow reply
// can never land an older value after a newer one.
const senders = new Map()
function sender (key) {
  let s = senders.get(key)
  if (!s) {
    s = { busy: false, next: null, after: [], onError: null }
    senders.set(key, s)
  }
  return s
}
function sendLatest (key, path, body, onError = null) {
  const s = sender(key)
  s.next = { path, body }
  s.onError = onError
  if (!s.busy) pump(s)
}
// Run fn once everything queued on `key` has been sent -- e.g. a release must
// follow the slot's last value, never overtake it.
function afterSends (key, fn) {
  const s = sender(key)
  if (!s.busy && !s.next) return fn()
  s.after.push(fn)
}
async function pump (s) {
  s.busy = true
  while (s.next) {
    const { path, body } = s.next
    s.next = null
    try {
      await post(path, body)
    } catch (err) {
      if (s.onError) s.onError(err)
      else console.warn(path, err.message)
    }
  }
  s.busy = false
  const after = s.after.splice(0)
  for (const fn of after) fn()
}

function act (fn) {
  return (ev) => {
    if (ev) ev.preventDefault()
    fn().catch((err) => alert(err.message))
  }
}

// ---- colour helpers ---------------------------------------------------------

// A unit's readback (BGR per slot, red capped) as a CSS colour.
function actualCss (values, i, redMax) {
  const red = Math.min(redMax, 45000) || 1
  const b = values[i * 3] / 65535
  const g = values[i * 3 + 1] / 65535
  const r = Math.min(1, values[i * 3 + 2] / red)
  return 'rgb(' + [r, g, b].map((v) => Math.round(v * 255)).join(',') + ')'
}

// A slot's colour from the unit's live values (BGR, red capped), for mirroring a
// slot someone else is moving.
function slotFromValues (v, i, redMax) {
  const red = Math.min(redMax, 45000) || 1
  const b = v[i * 3] / 65535
  const g = v[i * 3 + 1] / 65535
  const r = Math.min(1, v[i * 3 + 2] / red)
  const level = Math.max(r, g, b)
  return level > 0 ? { r: r / level, g: g / level, b: b / level, level } : { r: 1, g: 1, b: 1, level: 0 }
}

// Same words the Blender addon uses, so the two read alike.
function stateWords (st, u) {
  if (!u.enabled) return ['off', 'off']
  if (!st.connected) return ['not connected', 'off']
  if (u.status === 'connected') return ['connected', 'connected']
  if (u.status === 'unreachable') return ['unreachable', 'unreachable']
  return ['connecting…', 'connecting']
}

// Don't overwrite a control the user is touching with an echo of an older value.
function setValue (el, v) {
  if (document.activeElement === el) return
  if (String(el.value) !== String(v)) el.value = v
}

// ---- rendering ----------------------------------------------------------------

const cards = new Map()
let state = null

function render (st) {
  state = st
  const connect = $('#connect')
  connect.textContent = st.connected ? '● CONNECTED — live (click to disconnect)' : 'CONNECT'
  connect.classList.toggle('on', st.connected)
  const banner = $('#banner')
  banner.textContent = st.connected
    ? 'Changes go straight to the included lights.'
    : 'Not connected: nothing is sent to the lights.'
  banner.classList.toggle('on', st.connected)

  const disc = $('#discovery')
  disc.hidden = st.discovery.available
  disc.textContent = 'Automatic discovery is not working on the server: ' + st.discovery.reason

  const seen = new Set()
  for (const u of st.units) {
    seen.add(u.id)
    let card = cards.get(u.id)
    if (!card) {
      card = makeCard(u.id)
      cards.set(u.id, card)
      $('#units').appendChild(card.el)
    }
    updateCard(card, u, st)
  }
  for (const [id, card] of cards) {
    if (!seen.has(id)) {
      card.el.remove()
      cards.delete(id)
    }
  }
  $('#no-units').hidden = st.units.length > 0

  const list = $('#looks')
  list.replaceChildren(...st.looks.map((name) => {
    const li = document.createElement('li')
    const label = document.createElement('span')
    label.textContent = name
    const recall = document.createElement('button')
    recall.type = 'button'
    recall.textContent = 'Recall'
    recall.disabled = !st.connected
    recall.title = st.connected ? 'Fade the included units to this look' : 'Connect first'
    recall.onclick = act(() => post('/api/looks/' + encodeURIComponent(name) + '/recall',
      { fadeMs: Math.round(Number($('#recall-fade').value) * 1000) }))
    const del = document.createElement('button')
    del.type = 'button'
    del.className = 'danger'
    del.textContent = 'Delete'
    del.onclick = act(async () => {
      if (confirm('Delete the look "' + name + '"?')) await post('/api/looks/' + encodeURIComponent(name) + '/delete')
    })
    li.append(label, recall, del)
    return li
  }))
}

function makeCard (id) {
  const el = $('#unit-tpl').content.firstElementChild.cloneNode(true)
  const base = '/api/units/' + encodeURIComponent(id)
  const slots = []
  for (let i = 0; i < 4; i++) {
    const el2 = $('#slot-tpl').content.firstElementChild.cloneNode(true)
    $('.slot-n', el2).textContent = 'Slot ' + (i + 1)
    // The slot as the user is editing it. V is the brightness: the picker's
    // value bar and the row's slider are the same control.
    const row = {
      el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null,
      claim: 'idle', // proto 3: idle | asking | held
      wantSend: false, pointerDown: false, releaseTimer: null
    }
    const level = $('.level', el2)
    const panel = $('.slot-picker', el2)
    const swatch = $('.swatch', el2)
    const key = id + ':' + i
    const shared = () => { const u = unitState(id); return u && u.proto >= 3 }

    const sendValue = () => {
      row.wantSend = false
      sendLatest(key, base + '/slot', { index: i, session: SESSION, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) },
        (err) => {
          // The unit let go (idle / 30 s max / reconnect): grab again on the next change.
          if (err.status === 409) row.claim = 'idle'
        })
    }
    const release = () => {
      clearTimeout(row.releaseTimer)
      if (row.claim !== 'held') return
      row.claim = 'idle'
      afterSends(key, () => post(base + '/release', { index: i, session: SESSION }).catch(() => {}))
    }
    // Take the slot (proto 3), then send. First touch asks once; frames that
    // arrive meanwhile are kept as the latest and sent when it's granted.
    const claim = () => {
      if (row.claim !== 'idle') return
      row.claim = 'asking'
      post(base + '/grab', { index: i, session: SESSION, name: myName }).then(() => {
        row.claim = 'held'
        if (row.wantSend) sendValue()
        if (!row.pointerDown) armRelease()
      }, (err) => {
        row.claim = 'idle'
        refused(row, err.body && err.body.busy ? err.body.busy : err.message)
      })
    }
    // Let go after a second without changes (keyboard input, or a lifted
    // finger the browser didn't report) -- well inside the unit's 2 s idle.
    const armRelease = () => {
      clearTimeout(row.releaseTimer)
      row.releaseTimer = setTimeout(release, 1000)
    }
    const changed = () => {
      paintSlot(row)
      if (!shared()) return sendValue()   // proto 2: no grants
      if (!row.pointerDown) armRelease()
      if (row.claim === 'held') return sendValue()
      row.wantSend = true
      claim()
    }
    const start = () => {
      row.pointerDown = true
      clearTimeout(row.releaseTimer)
      if (shared()) claim()
    }
    const end = () => {
      row.pointerDown = false
      if (shared()) release()
    }

    level.addEventListener('pointerdown', start)
    level.addEventListener('pointerup', end)
    level.addEventListener('pointercancel', end)
    level.addEventListener('input', () => {
      row.hsv = { ...row.hsv, v: Number(level.value) / 100 }
      if (row.picker) row.picker.set(row.hsv)
      changed()
    })
    swatch.onclick = () => {
      const opening = panel.hidden
      closePicker()
      if (!opening) return
      if (!row.picker) {
        row.picker = new Picker(panel, {
          onChange: (hsv) => {
            row.hsv = hsv
            changed()
          },
          onStart: start,
          onEnd: end
        })
      }
      row.picker.set(row.hsv)
      panel.hidden = false
      swatch.classList.add('open')
      openRow = row
    }
    $('.slots', el).appendChild(el2)
    slots.push(row)
  }

  $('.enabled', el).addEventListener('change', (ev) => sendLatest(id + ':enabled', base, { enabled: ev.target.checked }))
  $('.fade', el).addEventListener('change', (ev) => sendLatest(id + ':fade', base, { fadeMs: Number(ev.target.value) }))
  $('.label-in', el).addEventListener('change', (ev) => sendLatest(id + ':label', base, { label: ev.target.value }))
  $('.redmax', el).addEventListener('change', (ev) => sendLatest(id + ':redmax', base, { redMax: Number(ev.target.value) }))
  for (const k of ['gb', 'gg', 'gr']) {
    $('.' + k, el).addEventListener('change', () => sendLatest(id + ':gamma', base, {
      gamma: { b: Number($('.gb', el).value), g: Number($('.gg', el).value), r: Number($('.gr', el).value) }
    }))
  }
  $('.freq', el).addEventListener('change', (ev) => sendLatest(id + ':freq', base, { freq: Number(ev.target.value) }))
  $('.push-settings', el).onclick = act(() => post(base + '/settings'))
  $('.adopt', el).onclick = act(() => post(base + '/adopt'))
  $('.blackout', el).onclick = act(() => post(base + '/blackout'))
  return { el, slots }
}

function updateCard (card, u, st) {
  const el = card.el
  el.classList.toggle('off', !u.enabled)
  $('.label', el).textContent = u.label
  $('.enabled', el).checked = u.enabled
  const [words, cls] = stateWords(st, u)
  const status = $('.status', el)
  status.textContent = words
  status.className = 'status ' + cls
  status.title = u.error || ''

  const where = u.online ? 'on the network at ' + u.addr : (u.manual ? 'added by hand: ' + u.host : 'not seen on the network')
  $('.where', el).textContent = u.id + ' — ' + where

  const note = $('.note', el)
  const oldFw = u.online && u.proto < 2
  note.hidden = !oldFw && u.slots !== null
  note.textContent = oldFw
    ? 'Older firmware: no readback, so the dots show nothing.'
    : 'No look yet: include this unit and connect, and it will pick up what the light is showing now.'

  const actual = u.actual ? u.actual.values : null
  for (let i = 0; i < 4; i++) {
    const row = card.slots[i]
    const s = u.slots ? u.slots[i] : null
    row.el.hidden = !u.slots
    const hold = u.holds ? u.holds[i] : null
    const other = !!hold && hold.session !== SESSION
    const mine = !!hold && hold.session === SESSION
    if (other && row.claim === 'held') row.claim = 'idle'   // the unit gave it away
    // Someone else's slot: no input, and the controls mirror the lamp live.
    row.el.classList.toggle('held-other', other)
    $('.level', row.el).disabled = other
    if (row.picker) row.picker.setEnabled(!other)
    const tag = $('.slot-holder', row.el)
    tag.hidden = !hold
    tag.textContent = other ? 'held by ' + hold.holder : (mine ? 'you have this slot' : '')
    // Skip the echo while the user is moving this slot's controls, or an
    // older value from the server would yank the handle back mid-drag.
    const busy = row.pointerDown || document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging)
    const shown = other && actual ? slotFromValues(actual, i, u.redMax) : s
    if (shown && !busy) {
      row.hsv = slotToHsv(shown, row.hsv.h)
      paintSlot(row)
      if (row.picker) row.picker.set(row.hsv)
    }
    $('.actual', row.el).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent'
  }

  setValue($('.fade', el), u.fadeMs)
  setValue($('.label-in', el), u.label)
  setValue($('.redmax', el), u.redMax)
  setValue($('.gb', el), u.gamma.b)
  setValue($('.gg', el), u.gamma.g)
  setValue($('.gr', el), u.gamma.r)
  setValue($('.freq', el), u.freq)
  const live = st.connected && u.status === 'connected'
  $('.push-settings', el).disabled = !live
  $('.adopt', el).disabled = !u.actual
}

function unitState (id) {
  return state ? state.units.find((u) => u.id === id) : null
}

// A grab was refused: put the controls back to what the lamp shows and say why.
function refused (row, who) {
  row.wantSend = false
  const tag = $('.slot-holder', row.el)
  tag.hidden = false
  tag.textContent = 'held by ' + who
  if (state) render(state)
}

// One picker open at a time, page-wide.
let openRow = null
function closePicker () {
  if (!openRow) return
  $('.slot-picker', openRow.el).hidden = true
  $('.swatch', openRow.el).classList.remove('open')
  openRow = null
}

function paintSlot (row) {
  const { h, s, v } = row.hsv
  $('.swatch', row.el).style.background = toHex(hsvToRgb(h, s, v))
  setValue($('.level', row.el), Math.round(v * 100))
  $('.pct', row.el).textContent = Math.round(v * 100) + '%'
}

// ---- wiring -------------------------------------------------------------------

$('#my-name').value = myName
$('#my-name').addEventListener('change', (ev) => {
  myName = ev.target.value.trim() || myName
  ev.target.value = myName
  try { localStorage.setItem('cthuloid.name', myName) } catch {}
})

$('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) }))
$('#blackout-all').onclick = act(() => post('/api/blackout'))
$('#save-look').onsubmit = act(async () => {
  await post('/api/looks', { name: $('#look-name').value })
  $('#look-name').value = ''
})
$('#add-unit').onsubmit = act(async () => {
  const out = $('#add-result')
  out.textContent = 'Asking the unit its name…'
  try {
    const r = await post('/api/units', { host: $('#add-host').value })
    out.textContent = 'Added ' + r.id + '.'
    $('#add-host').value = ''
  } catch (err) {
    out.textContent = err.message
  }
})

function listen () {
  const es = new EventSource('/api/events')
  es.addEventListener('state', (ev) => {
    $('#offline').hidden = true
    render(JSON.parse(ev.data))
  })
  // EventSource reconnects by itself; just say so meanwhile.
  es.onerror = () => { $('#offline').hidden = false }
}
listen()