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);
+});