445 lines
20 KiB
JavaScript
445 lines
20 KiB
JavaScript
(function () {
|
|
var form = document.getElementById('onboarding-pair-form');
|
|
var pairingCard = document.getElementById('onboarding-pair-card');
|
|
var pairingProgress = document.getElementById('onboarding-pair-progress');
|
|
var message = document.getElementById('onboarding-pair-message');
|
|
var codeInputsContainer = document.getElementById('onboarding-pair-code-inputs');
|
|
var codeField = document.getElementById('onboarding-pair-code');
|
|
var clientIdField = document.getElementById('onboarding-pair-client-id');
|
|
var anotherButton = document.getElementById('onboarding-pair-another');
|
|
var anotherButtonLabel = document.getElementById('onboarding-pair-another-label');
|
|
var manualButton = document.getElementById('onboarding-pair-manual');
|
|
var connectButton = document.getElementById('onboarding-pair-connect');
|
|
var clientListButton = document.getElementById('onboarding-pair-client-list');
|
|
var scanner = document.getElementById('onboarding-scanner');
|
|
var scannerVideo = document.getElementById('onboarding-scanner-video');
|
|
var scannerCapture = document.getElementById('onboarding-scanner-capture');
|
|
var scannerClose = document.getElementById('onboarding-scanner-close');
|
|
var scannerMessage = document.getElementById('onboarding-scanner-message');
|
|
var scannerDebugOutput = document.getElementById('onboarding-scanner-debug');
|
|
var scannerDebug = new URLSearchParams(window.location.search).get('scanner-debug') === '1';
|
|
if (!form || !message) {
|
|
return;
|
|
}
|
|
function getClientId() {
|
|
var storageKey = 'pulse-signage-player-client-id';
|
|
var clientId = clientIdField ? String(clientIdField.value || '').trim() : '';
|
|
if (!clientId) {
|
|
try { clientId = String(window.sessionStorage.getItem(storageKey) || '').trim(); } catch (_error) {}
|
|
}
|
|
if (!clientId) {
|
|
clientId = window.crypto && typeof window.crypto.randomUUID === 'function'
|
|
? window.crypto.randomUUID()
|
|
: 'client-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12);
|
|
try { window.sessionStorage.setItem(storageKey, clientId); } catch (_error) {}
|
|
}
|
|
if (clientIdField) { clientIdField.value = clientId; }
|
|
return clientId;
|
|
}
|
|
getClientId();
|
|
function traceScanner(value) {
|
|
if (!scannerDebug || !scannerDebugOutput) { return; }
|
|
scannerDebugOutput.classList.remove('d-none');
|
|
var lines = (scannerDebugOutput.textContent ? scannerDebugOutput.textContent.split('\n') : []).filter(Boolean);
|
|
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
|
|
scannerDebugOutput.textContent = lines.slice(-8).join('\n');
|
|
}
|
|
function wait(milliseconds) {
|
|
return new Promise(function (resolve) { window.setTimeout(resolve, milliseconds); });
|
|
}
|
|
function submitPairing(body, attempt) {
|
|
return fetch(form.action, {
|
|
method: 'POST',
|
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
body: body
|
|
}).then(function (response) {
|
|
return response.text().then(function (text) {
|
|
var payload = null;
|
|
try { payload = JSON.parse(text); } catch (_error) {}
|
|
var transientCodeError = response.status === 401 && payload && String(payload.error || '').indexOf('valid kiosk pairing code') !== -1;
|
|
var transientTransportError = [502, 503, 504].indexOf(response.status) !== -1;
|
|
if ((!response.ok && (transientCodeError || transientTransportError)) && attempt < 2) {
|
|
return wait(300 * (attempt + 1)).then(function () { return submitPairing(body, attempt + 1); });
|
|
}
|
|
return { response: response, payload: payload };
|
|
});
|
|
});
|
|
}
|
|
|
|
var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
|
|
function normalizeCode(value) {
|
|
return String(value || '').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, codeInputs.length);
|
|
}
|
|
function syncCodeField() {
|
|
if (codeField) {
|
|
codeField.value = codeInputs.map(function (input) { return input.value; }).join('').toUpperCase();
|
|
}
|
|
}
|
|
function setCode(value, startIndex) {
|
|
var normalized = normalizeCode(value);
|
|
var index = startIndex || 0;
|
|
normalized.split('').forEach(function (character) {
|
|
if (index < codeInputs.length) {
|
|
codeInputs[index].value = character;
|
|
index += 1;
|
|
}
|
|
});
|
|
syncCodeField();
|
|
if (index < codeInputs.length) {
|
|
codeInputs[index].focus();
|
|
} else if (codeInputs.length) {
|
|
codeInputs[codeInputs.length - 1].focus();
|
|
}
|
|
}
|
|
var scannerStream = null;
|
|
var scannerFrame = null;
|
|
var scannerLastDecodeAt = 0;
|
|
var scannerCanvas = document.createElement('canvas');
|
|
var scannerWorkCanvas = document.createElement('canvas');
|
|
var scannerDecodeCanvas = document.createElement('canvas');
|
|
|
|
function decodeQrCanvas(canvas) {
|
|
if (typeof window.jsQR !== 'function') { return null; }
|
|
var workCanvas = canvas;
|
|
var maximumDimension = 1024;
|
|
if (Math.max(canvas.width, canvas.height) > maximumDimension) {
|
|
var scale = maximumDimension / Math.max(canvas.width, canvas.height);
|
|
scannerWorkCanvas.width = Math.round(canvas.width * scale);
|
|
scannerWorkCanvas.height = Math.round(canvas.height * scale);
|
|
scannerWorkCanvas.getContext('2d').drawImage(canvas, 0, 0, scannerWorkCanvas.width, scannerWorkCanvas.height);
|
|
workCanvas = scannerWorkCanvas;
|
|
}
|
|
var context = workCanvas.getContext('2d', { willReadFrequently: true });
|
|
var imageData = context.getImageData(0, 0, workCanvas.width, workCanvas.height);
|
|
var result = window.jsQR(imageData.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
|
|
if (result) { return result; }
|
|
|
|
scannerDecodeCanvas.width = workCanvas.width;
|
|
scannerDecodeCanvas.height = workCanvas.height;
|
|
var decodeContext = scannerDecodeCanvas.getContext('2d', { willReadFrequently: true });
|
|
var source = new Uint8ClampedArray(imageData.data);
|
|
var cleaned = new Uint8ClampedArray(source.length);
|
|
for (var cleanY = 0; cleanY < workCanvas.height; cleanY += 1) {
|
|
for (var cleanX = 0; cleanX < workCanvas.width; cleanX += 1) {
|
|
var cleanSamples = [];
|
|
for (var cleanOffsetY = -1; cleanOffsetY <= 1; cleanOffsetY += 1) {
|
|
for (var cleanOffsetX = -1; cleanOffsetX <= 1; cleanOffsetX += 1) {
|
|
var cleanSampleX = Math.max(0, Math.min(workCanvas.width - 1, cleanX + cleanOffsetX));
|
|
var cleanSampleY = Math.max(0, Math.min(workCanvas.height - 1, cleanY + cleanOffsetY));
|
|
cleanSamples.push(source[(cleanSampleY * workCanvas.width + cleanSampleX) * 4]);
|
|
}
|
|
}
|
|
cleanSamples.sort(function (left, right) { return left - right; });
|
|
var cleanIndex = (cleanY * workCanvas.width + cleanX) * 4;
|
|
cleaned[cleanIndex] = cleaned[cleanIndex + 1] = cleaned[cleanIndex + 2] = cleanSamples[4];
|
|
cleaned[cleanIndex + 3] = 255;
|
|
}
|
|
}
|
|
source = cleaned;
|
|
var expanded = decodeContext.createImageData(workCanvas.width, workCanvas.height);
|
|
var radius = Math.max(3, Math.round(workCanvas.width / 205));
|
|
for (var y = 0; y < workCanvas.height; y += 1) {
|
|
for (var x = 0; x < workCanvas.width; x += 1) {
|
|
var samples = [];
|
|
for (var offsetY = -radius; offsetY <= radius; offsetY += 2) {
|
|
for (var offsetX = -radius; offsetX <= radius; offsetX += 2) {
|
|
var sampleX = Math.max(0, Math.min(workCanvas.width - 1, x + offsetX));
|
|
var sampleY = Math.max(0, Math.min(workCanvas.height - 1, y + offsetY));
|
|
samples.push(source[(sampleY * workCanvas.width + sampleX) * 4]);
|
|
}
|
|
}
|
|
var maximum = Math.max.apply(null, samples);
|
|
var index = (y * workCanvas.width + x) * 4;
|
|
expanded.data[index] = maximum;
|
|
expanded.data[index + 1] = maximum;
|
|
expanded.data[index + 2] = maximum;
|
|
expanded.data[index + 3] = 255;
|
|
}
|
|
}
|
|
decodeContext.putImageData(expanded, 0, 0);
|
|
return window.jsQR(expanded.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
|
|
}
|
|
function closeScanner() {
|
|
if (scannerFrame) { window.cancelAnimationFrame(scannerFrame); scannerFrame = null; }
|
|
if (scannerStream) {
|
|
scannerStream.getTracks().forEach(function (track) { track.stop(); });
|
|
scannerStream = null;
|
|
}
|
|
if (scannerVideo) {
|
|
scannerVideo.srcObject = null;
|
|
scannerVideo.classList.remove('d-none');
|
|
}
|
|
if (scannerCapture) { scannerCapture.value = ''; }
|
|
if (scanner) {
|
|
scanner.classList.add('d-none');
|
|
scanner.setAttribute('aria-hidden', 'true');
|
|
}
|
|
}
|
|
function resetPairingForm() {
|
|
form.reset();
|
|
codeInputsContainer.setAttribute('data-pairing-code', '');
|
|
codeInputs.forEach(function (input) { input.value = ''; input.disabled = false; });
|
|
if (codeField) { codeField.value = ''; }
|
|
Array.prototype.slice.call(form.elements).forEach(function (element) {
|
|
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
|
|
});
|
|
if (connectButton) { connectButton.classList.remove('d-none'); }
|
|
if (clientListButton) { clientListButton.classList.add('d-none'); }
|
|
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
|
|
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
|
|
message.className = 'alert d-none';
|
|
message.textContent = '';
|
|
if (anotherButton) {
|
|
anotherButton.classList.remove('btn-secondary');
|
|
anotherButton.classList.add('btn-outline-secondary');
|
|
anotherButton.setAttribute('aria-label', 'Scan player QR code');
|
|
anotherButton.setAttribute('title', 'Scan player QR code');
|
|
}
|
|
if (anotherButtonLabel) { anotherButtonLabel.classList.add('d-none'); }
|
|
if (manualButton) { manualButton.classList.add('d-none'); }
|
|
}
|
|
function codeFromScan(rawValue) {
|
|
try {
|
|
var scannedUrl = new URL(String(rawValue || ''), window.location.origin);
|
|
if (scannedUrl.pathname === '/pairing') {
|
|
return normalizeCode(scannedUrl.searchParams.get('code'));
|
|
}
|
|
} catch (_error) {}
|
|
return normalizeCode(rawValue);
|
|
}
|
|
function applyScannedCode(rawValue) {
|
|
var code = codeFromScan(rawValue);
|
|
if (code.length !== codeInputs.length) {
|
|
scannerMessage.textContent = 'That QR code is not a Pulse Signage pairing code.';
|
|
return false;
|
|
}
|
|
closeScanner();
|
|
resetPairingForm();
|
|
setCode(code, 0);
|
|
return true;
|
|
}
|
|
function scanFrame() {
|
|
if (!scannerVideo || !scannerStream) { return; }
|
|
if (Date.now() - scannerLastDecodeAt < 400) {
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
return;
|
|
}
|
|
scannerLastDecodeAt = Date.now();
|
|
if (!window.BarcodeDetector && typeof window.jsQR === 'function') {
|
|
var width = scannerVideo.videoWidth;
|
|
var height = scannerVideo.videoHeight;
|
|
if (width && height) {
|
|
traceScanner('frame ' + width + 'x' + height);
|
|
scannerCanvas.width = width;
|
|
scannerCanvas.height = height;
|
|
var context = scannerCanvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(scannerVideo, 0, 0, width, height);
|
|
var result = decodeQrCanvas(scannerCanvas);
|
|
traceScanner(result ? 'decoded' : 'no QR');
|
|
if (result && applyScannedCode(result.data)) { return; }
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
return;
|
|
}
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
return;
|
|
}
|
|
scannerVideo.__pairingBarcodeDetector.detect(scannerVideo).then(function (barcodes) {
|
|
if (barcodes.length) {
|
|
if (applyScannedCode(barcodes[0].rawValue)) { return; }
|
|
}
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
}).catch(function () {
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
});
|
|
}
|
|
function openScanner() {
|
|
if (!scanner || !scannerVideo || (!window.BarcodeDetector && typeof window.jsQR !== 'function')) {
|
|
message.className = 'alert alert-warning';
|
|
message.textContent = 'Camera scanning is not supported by this browser.';
|
|
return;
|
|
}
|
|
scannerMessage.textContent = 'Point your camera at the player QR code.';
|
|
traceScanner('open BarcodeDetector=' + Boolean(window.BarcodeDetector) + ' jsQR=' + (typeof window.jsQR === 'function'));
|
|
scannerVideo.classList.remove('d-none');
|
|
scanner.classList.remove('d-none');
|
|
scanner.setAttribute('aria-hidden', 'false');
|
|
if (window.BarcodeDetector) {
|
|
scannerVideo.__pairingBarcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] });
|
|
}
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
traceScanner('getUserMedia unavailable');
|
|
scannerVideo.classList.add('d-none');
|
|
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
|
|
if (scannerCapture) { scannerCapture.click(); }
|
|
return;
|
|
}
|
|
navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
facingMode: { ideal: 'environment' },
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
focusMode: { ideal: 'continuous' }
|
|
},
|
|
audio: false
|
|
}).then(function (stream) {
|
|
scannerStream = stream;
|
|
traceScanner('stream opened');
|
|
traceScanner(JSON.stringify(stream.getVideoTracks()[0].getSettings ? stream.getVideoTracks()[0].getSettings() : {}));
|
|
scannerVideo.srcObject = stream;
|
|
return scannerVideo.play();
|
|
}).then(function () {
|
|
scannerFrame = window.requestAnimationFrame(scanFrame);
|
|
}).catch(function () {
|
|
traceScanner('stream failed');
|
|
scannerVideo.classList.add('d-none');
|
|
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
|
|
if (scannerCapture) { scannerCapture.click(); }
|
|
});
|
|
}
|
|
if (codeInputs.length) {
|
|
setCode(codeInputsContainer.getAttribute('data-pairing-code'), 0);
|
|
codeInputs.forEach(function (input, index) {
|
|
input.addEventListener('input', function () {
|
|
var value = normalizeCode(input.value);
|
|
input.value = value.slice(0, 1);
|
|
if (value.length > 1) {
|
|
setCode(value, index);
|
|
} else {
|
|
syncCodeField();
|
|
if (input.value && index < codeInputs.length - 1) {
|
|
codeInputs[index + 1].focus();
|
|
}
|
|
}
|
|
});
|
|
input.addEventListener('paste', function (event) {
|
|
event.preventDefault();
|
|
setCode(event.clipboardData ? event.clipboardData.getData('text') : '', index);
|
|
});
|
|
input.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Backspace' && !input.value && index > 0) {
|
|
codeInputs[index - 1].focus();
|
|
codeInputs[index - 1].value = '';
|
|
syncCodeField();
|
|
} else if (event.key === 'ArrowLeft' && index > 0) {
|
|
event.preventDefault();
|
|
codeInputs[index - 1].focus();
|
|
} else if (event.key === 'ArrowRight' && index < codeInputs.length - 1) {
|
|
event.preventDefault();
|
|
codeInputs[index + 1].focus();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
form.addEventListener('submit', function (event) {
|
|
event.preventDefault();
|
|
getClientId();
|
|
syncCodeField();
|
|
if (!codeField || codeField.value.length !== codeInputs.length) {
|
|
message.className = 'alert alert-warning';
|
|
message.textContent = 'Enter the complete six-character pairing code.';
|
|
var firstEmptyInput = codeInputs.filter(function (input) { return !input.value; })[0] || codeInputs[0];
|
|
if (firstEmptyInput) { firstEmptyInput.focus(); }
|
|
return;
|
|
}
|
|
message.className = 'alert d-none';
|
|
message.textContent = '';
|
|
var pairingBody = new URLSearchParams(new FormData(form)).toString();
|
|
if (pairingCard) { pairingCard.classList.add('is-pairing'); }
|
|
if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
|
|
Array.prototype.slice.call(form.elements).forEach(function (element) {
|
|
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
|
|
});
|
|
submitPairing(pairingBody, 0).then(function (result) {
|
|
var response = result.response;
|
|
var payload = result.payload;
|
|
if (!response.ok) {
|
|
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
|
|
}
|
|
if (payload && payload.queued) {
|
|
return;
|
|
}
|
|
message.className = 'alert alert-success';
|
|
message.textContent = 'Pairing saved. The player is loading your screen.';
|
|
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
|
|
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
|
|
Array.prototype.slice.call(form.elements).forEach(function (element) {
|
|
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
|
|
});
|
|
if (connectButton) { connectButton.classList.add('d-none'); }
|
|
if (clientListButton) { clientListButton.classList.remove('d-none'); }
|
|
if (anotherButton) {
|
|
anotherButton.classList.remove('d-none');
|
|
anotherButton.classList.remove('btn-outline-secondary');
|
|
anotherButton.classList.add('btn-secondary');
|
|
anotherButton.setAttribute('aria-label', 'Pair another screen');
|
|
anotherButton.setAttribute('title', 'Pair another screen');
|
|
}
|
|
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
|
|
if (manualButton) { manualButton.classList.remove('d-none'); }
|
|
}).catch(function (error) {
|
|
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
|
|
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
|
|
Array.prototype.slice.call(form.elements).forEach(function (element) {
|
|
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
|
|
});
|
|
message.className = 'alert alert-danger';
|
|
message.textContent = error && error.message ? error.message : 'Unable to pair player.';
|
|
});
|
|
});
|
|
|
|
if (anotherButton) { anotherButton.addEventListener('click', openScanner); }
|
|
if (manualButton) {
|
|
manualButton.addEventListener('click', function () {
|
|
resetPairingForm();
|
|
if (codeInputs[0]) { codeInputs[0].focus(); }
|
|
});
|
|
}
|
|
if (scannerClose) { scannerClose.addEventListener('click', closeScanner); }
|
|
if (scannerCapture) {
|
|
scannerCapture.addEventListener('change', function () {
|
|
var file = scannerCapture.files && scannerCapture.files[0];
|
|
if (!file) { return; }
|
|
scannerMessage.textContent = 'Reading QR code...';
|
|
var image = new Image();
|
|
image.onload = function () {
|
|
if (window.BarcodeDetector) {
|
|
new window.BarcodeDetector({ formats: ['qr_code'] }).detect(image).then(function (barcodes) {
|
|
if (!barcodes.length || !applyScannedCode(barcodes[0].rawValue)) {
|
|
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
|
|
}
|
|
}).catch(function () {
|
|
scannerMessage.textContent = 'Could not read that QR image. Try again.';
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
var context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
var result = decodeQrCanvas(canvas);
|
|
if (!result || !applyScannedCode(result.data)) {
|
|
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
|
|
}
|
|
} catch (_error) {
|
|
scannerMessage.textContent = 'Could not read that QR image. Try again.';
|
|
}
|
|
};
|
|
image.onerror = function () {
|
|
scannerMessage.textContent = 'Could not read that QR image. Try again.';
|
|
};
|
|
var reader = new FileReader();
|
|
reader.onload = function () { image.src = reader.result; };
|
|
reader.onerror = function () { scannerMessage.textContent = 'Could not read that QR image. Try again.'; };
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
if (scanner) {
|
|
scanner.addEventListener('click', function (event) {
|
|
if (event.target === scanner) { closeScanner(); }
|
|
});
|
|
}
|
|
}());
|