diff --git a/server/player/index.html b/server/player/index.html index 501f67f..0f1ccdb 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -645,12 +645,39 @@ // Function used by connect button and auto-connect let autoContinueTimer; - function connectBtnFunc() { - if (autoContinueTimer) { - clearInterval(autoContinueTimer); - autoContinueTimer = null; - document.getElementById('connectBtn').textContent = _t('connect'); + + // Shared "editable URL + short countdown" behaviour. Used on first boot AND on + // recovery (server unpaired / rejected us). A display panel usually has no keyboard + // or pointer, so anything that WAITS for a click is a dead end there — the countdown + // is what lets an input-less screen heal itself. The field stays editable the whole + // time for the case where someone IS standing there with a remote and needs to point + // the player somewhere else; typing cancels the countdown so it can't yank the form + // out from under them mid-edit. + function cancelAutoContinue() { + if (!autoContinueTimer) return; + clearInterval(autoContinueTimer); + autoContinueTimer = null; + document.getElementById('connectBtn').textContent = _t('connect'); + } + function startAutoContinue(seconds) { + cancelAutoContinue(); + let countdown = seconds; + const connectBtn = document.getElementById('connectBtn'); + connectBtn.disabled = false; + connectBtn.textContent = `${_t('connect')} (${countdown})`; + autoContinueTimer = setInterval(() => { + countdown--; + if (countdown > 0) { + connectBtn.textContent = `${_t('connect')} (${countdown})`; + } else { + cancelAutoContinue(); + connectBtnFunc(); } + }, 1000); + } + + function connectBtnFunc() { + cancelAutoContinue(); unlockAudio(); const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, ''); if (!url) return; @@ -728,29 +755,7 @@ } } else { // Auto-Continue after 5s if not configured. If user interacts with form (typing in the box), stop the timer. - - let countdown = 5; - const connectBtn = document.getElementById('connectBtn'); - - connectBtn.textContent = `${_t('connect')} (${countdown})`; - - autoContinueTimer = setInterval(() => { - countdown--; - if (countdown > 0) { - connectBtn.textContent = `${_t('connect')} (${countdown})`; - } else { - clearInterval(autoContinueTimer); - connectBtn.textContent = _t('connect'); - connectBtnFunc() - } - }, 1000); - - document.getElementById('serverUrl').addEventListener('input', () => { - if (countdown > 0) { - clearInterval(autoContinueTimer); - connectBtn.textContent = _t('connect'); - } - }); + startAutoContinue(5); } } // #104: end preview-mode gate (else branch wrapping the normal boot) @@ -787,6 +792,10 @@ } document.getElementById('connectBtn').onclick = connectBtnFunc; + // Bound once, not per countdown: startAutoContinue() can run more than once in a + // session (first boot, then again if the server unpairs us), and re-binding here + // would stack a listener each time. + document.getElementById('serverUrl').addEventListener('input', cancelAutoContinue); // ==================== #104 Device-free preview ==================== // #104: device-free dashboard preview. Renders EITHER a draft playlist @@ -1044,29 +1053,37 @@ if (showIdle) showStatus('Waiting for content...'); }); - socket.on('device:unpaired', () => { - console.warn('Device not found on server — clearing credentials'); + // Server no longer accepts our identity (row deleted, or token rejected). Drop the + // stale credentials and get a NEW pairing code on screen without anyone touching the + // panel: config.serverUrl is known-good — we are talking to that server right now — + // so there is nothing for a human to re-enter. The old code showed the URL form and + // HID the pairing section, which on a screen-only display is a dead end: it asks for + // typing that cannot happen, and hides the one thing that would rescue it. Recovery + // then needed a physical reload. The Android player already does this correctly + // (ProvisioningActivity "repair mode"); this brings the web player in line. + function enterRepairMode(message) { delete config.deviceId; delete config.deviceToken; config.paired = false; saveConfig(config); - savePlaylistCache([]); document.getElementById('setupScreen').style.display = 'flex'; - document.getElementById('urlForm').style.display = 'block'; - document.getElementById('pairingSection').style.display = 'none'; - document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.'; + document.getElementById('urlForm').style.display = 'block'; // still editable + document.getElementById('pairingSection').style.display = 'none'; // until a code arrives + document.getElementById('setupStatus').textContent = message; + // Reconnecting re-registers with no device_id, so the server issues a fresh pairing + // code and the device:registered handler reveals pairingSection. + startAutoContinue(10); + } + + socket.on('device:unpaired', () => { + console.warn('Device not found on server — clearing credentials'); + savePlaylistCache([]); + enterRepairMode('Device was removed from the server. Re-pairing…'); }); socket.on('device:auth-error', (data) => { console.warn('Device auth rejected:', data?.error || 'unknown'); - delete config.deviceId; - delete config.deviceToken; - config.paired = false; - saveConfig(config); - document.getElementById('setupScreen').style.display = 'flex'; - document.getElementById('urlForm').style.display = 'block'; - document.getElementById('pairingSection').style.display = 'none'; - document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.'; + enterRepairMode('This device needs to be re-paired. Getting a new code…'); }); socket.on('device:playlist-update', (data) => { diff --git a/server/test/player-repair-mode.test.js b/server/test/player-repair-mode.test.js new file mode 100644 index 0000000..fccb517 --- /dev/null +++ b/server/test/player-repair-mode.test.js @@ -0,0 +1,128 @@ +'use strict'; + +// A display panel usually has no keyboard and no pointer. So any recovery path that WAITS +// for a click is not a recovery path at all — it is a dead end that needs someone to drive +// to the site and power-cycle the screen. +// +// That is what the unpaired / auth-error handlers used to do: they revealed the server-URL +// form (typing you cannot do) and HID the pairing section (the one thing that would rescue +// the screen). A real panel sat stuck on "Device was removed from the server" until it was +// physically reloaded, even though the player was still talking to the right server the +// whole time and could have asked for a new pairing code by itself. +// +// The rule pinned here: those handlers must hand off to something that recovers WITHOUT +// input, while leaving the URL editable for whoever does have a remote. The Android player +// already worked this way (ProvisioningActivity repair mode). +// +// The player is one big inline script with no jsdom in this repo, so this file does two +// things: structural checks on the wiring, and a real behavioural test of the countdown +// itself, which is lifted out of the HTML and run against a small shim. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); + +const bodyOf = (name) => { + const start = HTML.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name}() should exist`); + let i = HTML.indexOf('{', start), depth = 0; + for (let j = i; j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) return HTML.slice(start, j + 1); + } + throw new Error(`unbalanced braces reading ${name}`); +}; +const handlerOf = (event) => { + const start = HTML.indexOf(`socket.on('${event}'`); + assert.notEqual(start, -1, `a handler for ${event} should exist`); + return HTML.slice(start, HTML.indexOf('});', start)); +}; + +// ----------------------------------------------------------------- structural wiring + +test('THE BUG: neither recovery handler dead-ends waiting for input', () => { + for (const ev of ['device:unpaired', 'device:auth-error']) { + const h = handlerOf(ev); + assert.match(h, /enterRepairMode\(/, `${ev} routes into repair mode`); + assert.doesNotMatch(h, /pairingSection'\)\.style\.display\s*=\s*'none'/, + `${ev} must not hide the pairing section and then stop`); + } +}); + +test('repair mode recovers on its own AND leaves the URL editable', () => { + const b = bodyOf('enterRepairMode'); + assert.match(b, /startAutoContinue\(\s*\d+\s*\)/, 'it starts an unattended countdown'); + assert.match(b, /urlForm'\)\.style\.display\s*=\s*'block'/, 'the URL field stays available'); + assert.match(b, /config\.paired\s*=\s*false/, 'stale credentials are dropped'); + assert.match(b, /saveConfig\(config\)/, 'and persisted, so a reload agrees with memory'); +}); + +test('the cancel-on-typing listener is bound exactly once', () => { + // startAutoContinue() runs more than once per session (first boot, then repair), so + // binding inside it would stack a listener per countdown. + const bindings = HTML.match(/getElementById\('serverUrl'\)\.addEventListener\('input'/g) || []; + assert.equal(bindings.length, 1, 'one binding, outside the countdown'); + assert.doesNotMatch(bodyOf('startAutoContinue'), /addEventListener/, + 'the countdown itself binds nothing'); +}); + +// ----------------------------------------------------------------- behaviour + +// Run the real countdown source against a shim, so this tests the shipped code rather +// than a paraphrase of it. +function loadCountdown() { + const calls = { connect: 0 }; + const btn = { textContent: '', disabled: true }; + let timer = null, tickFn = null; + const scope = { + document: { getElementById: (id) => (id === 'connectBtn' ? btn : null) }, + _t: (k) => k, + connectBtnFunc: () => { calls.connect++; }, + setInterval: (fn) => { tickFn = fn; timer = {}; return timer; }, + clearInterval: () => { timer = null; tickFn = null; }, + }; + const src = `let autoContinueTimer; ${bodyOf('cancelAutoContinue')} ${bodyOf('startAutoContinue')} + return { startAutoContinue, cancelAutoContinue, get armed() { return !!autoContinueTimer; } };`; + const api = new Function(...Object.keys(scope), src)(...Object.values(scope)); + return { api, calls, btn, tick: (n = 1) => { for (let i = 0; i < n; i++) if (tickFn) tickFn(); } }; +} + +test('the countdown reaches zero and connects with nobody touching the screen', () => { + const { api, calls, btn, tick } = loadCountdown(); + api.startAutoContinue(3); + assert.match(btn.textContent, /\(3\)/, 'it shows the remaining seconds'); + assert.equal(btn.disabled, false, 'and the button is usable meanwhile'); + tick(2); + assert.equal(calls.connect, 0, 'not yet'); + tick(1); + assert.equal(calls.connect, 1, 'reconnects by itself — this is the whole fix'); + assert.equal(api.armed, false, 'and disarms'); +}); + +test('typing cancels it, so an operator mid-edit is never yanked', () => { + const { api, calls, tick } = loadCountdown(); + api.startAutoContinue(3); + api.cancelAutoContinue(); + tick(5); + assert.equal(calls.connect, 0, 'no auto-connect once cancelled'); + assert.equal(api.armed, false); +}); + +test('restarting the countdown does not leave the old one running', () => { + // Both timers would otherwise fire, double-connecting. + const { api, calls, tick } = loadCountdown(); + api.startAutoContinue(2); + api.startAutoContinue(2); + tick(2); + assert.equal(calls.connect, 1, 'exactly one connect'); +}); + +test('the countdown fires once, not every tick after zero', () => { + const { api, calls, tick } = loadCountdown(); + api.startAutoContinue(1); + tick(4); + assert.equal(calls.connect, 1); +});