Files
Levyraati26_go/static/player.js
T
Esa Kataja f51dcd743e Rebuild the review page as a channel strip
The app is about operating something — playing a track and setting a level on
it — but every screen looked like a form. One metaphor now does three jobs.

- Reviewing: a vertical fader beside the text, so the two things you do at
  once stop being a screen apart. Native range input, so keyboard, focus and
  form submission are unchanged; on mobile it lies down and the ticks reverse
- The reveal: everyone's scores as a row of channels. The silhouette of that
  row is the spread, which the stats page can only tell you as a number
- Profiles: given versus received as two faders, the one comparison that says
  something about a person

The player is now a transport: play/pause, a range input for seeking so arrow
keys come free, and a stereo level meter driven by a real AnalyserNode. It is
progressive enhancement — the page ships native audio controls and the script
takes over, so no JS means the browser's own player. The meter is dark until
audio actually plays and stops when it does; reduced motion skips it entirely.

Also: hidden scores are hatched rather than blank, the nav carries the queue
count, "Seuraava jonossa" keeps the loop going after a review, leaderboards
gained level bars and a range bar where divisive is the point, durations read
3:54, both lists can get back to the start, and the admin invite table lists
unused codes instead of silently truncating at 50.

Slogan restored from the original app, three decades on.
2026-07-31 23:17:40 +03:00

144 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Progressive enhancement: the page ships <audio controls>. If this script runs, it takes the
// controls off and drives the same element itself — playback, buffering, seeking and Range
// requests are untouched, because the element never changes.
(function () {
'use strict'
const fmt = (s) => {
if (!isFinite(s)) return ':––'
const m = Math.floor(s / 60)
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0')
}
const SEGMENTS = 18
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function enhance(wrap) {
const audio = wrap.querySelector('audio')
if (!audio) return
audio.removeAttribute('controls')
const total = Number(wrap.dataset.duration) || 0
wrap.insertAdjacentHTML('beforeend', `
<div class="transport">
<button type="button" class="tp-play" aria-label="Toista">
<span class="tp-icon" aria-hidden="true"></span>
</button>
<div class="tp-mid">
<input type="range" class="tp-seek" min="0" max="${total || 100}" step="0.1" value="0"
aria-label="Kelaus">
<div class="meter" aria-hidden="true">
<div class="meter-row"><span class="lbl">L</span><span class="segs"></span></div>
<div class="meter-row"><span class="lbl">R</span><span class="segs"></span></div>
</div>
</div>
<span class="tp-time"><b>0:00</b> / ${fmt(total)}</span>
</div>`)
const play = wrap.querySelector('.tp-play')
const seek = wrap.querySelector('.tp-seek')
const time = wrap.querySelector('.tp-time b')
const rows = wrap.querySelectorAll('.meter .segs')
for (const row of rows) {
row.innerHTML = '<span class="seg"></span>'.repeat(SEGMENTS)
}
const segs = [...rows].map((r) => [...r.children])
// --- transport ---
play.addEventListener('click', () => (audio.paused ? audio.play() : audio.pause()))
const setPlaying = (playing) => {
wrap.classList.toggle('playing', playing)
play.setAttribute('aria-label', playing ? 'Tauko' : 'Toista')
}
audio.addEventListener('play', () => { setPlaying(true); startMeter() })
audio.addEventListener('pause', () => setPlaying(false))
audio.addEventListener('ended', () => setPlaying(false))
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration)) {
seek.max = audio.duration
wrap.querySelector('.tp-time').lastChild.textContent = ' / ' + fmt(audio.duration)
}
})
let scrubbing = false
seek.addEventListener('input', () => {
scrubbing = true
time.textContent = fmt(Number(seek.value))
})
seek.addEventListener('change', () => {
audio.currentTime = Number(seek.value)
scrubbing = false
})
audio.addEventListener('timeupdate', () => {
if (scrubbing) return
seek.value = audio.currentTime
time.textContent = fmt(audio.currentTime)
seek.style.setProperty('--pct', (audio.currentTime / (Number(seek.max) || 1)) * 100 + '%')
})
// --- meter ---
//
// A real analyser, not a decorative loop: it is dark until the audio actually plays, and it
// stops the moment playback does. The AudioContext can only start from a gesture, so it is
// created on first play. MediaElementSource reroutes the audio, so the graph must reach the
// destination or the sound stops.
let ctx, analysers, raf
function startMeter() {
if (quiet || raf) return
if (!ctx) {
try {
ctx = new (window.AudioContext || window.webkitAudioContext)()
const src = ctx.createMediaElementSource(audio)
const split = ctx.createChannelSplitter(2)
analysers = [ctx.createAnalyser(), ctx.createAnalyser()]
analysers.forEach((a, i) => {
a.fftSize = 256
split.connect(a, i)
})
src.connect(split)
src.connect(ctx.destination)
} catch (e) {
return // no Web Audio: the transport still works, the meter simply never lights
}
}
ctx.resume()
const buf = new Uint8Array(analysers[0].fftSize)
const held = [0, 0]
const draw = () => {
if (audio.paused) {
segs.forEach((row) => row.forEach((s) => (s.className = 'seg')))
raf = null
return
}
analysers.forEach((a, ch) => {
a.getByteTimeDomainData(buf)
let peak = 0
for (let i = 0; i < buf.length; i++) {
const v = Math.abs(buf[i] - 128) / 128
if (v > peak) peak = v
}
// Fall slower than it rises, the way a real meter behaves.
held[ch] = peak > held[ch] ? peak : held[ch] * 0.88
const lit = Math.round(held[ch] * SEGMENTS)
segs[ch].forEach((s, i) => {
s.className = 'seg' +
(i < lit ? ' on' + (i >= SEGMENTS - 3 ? ' peak' : i >= SEGMENTS - 7 ? ' hot' : '') : '')
})
})
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.playerwrap').forEach(enhance)
})
})()