Feature: add simple mobile camera scan mode

This commit is contained in:
Ponte
2026-02-14 22:39:10 +01:00
parent e31ec831b3
commit 80a126bd6e
3 changed files with 217 additions and 0 deletions

160
app.js
View File

@@ -58,6 +58,11 @@ const googleRestoreBtn = document.getElementById("googleRestoreBtn");
const quickSearchInput = document.getElementById("quickSearchInput"); const quickSearchInput = document.getElementById("quickSearchInput");
const quickSearchResults = document.getElementById("quickSearchResults"); const quickSearchResults = document.getElementById("quickSearchResults");
const loanedFilterBtn = document.getElementById("loanedFilterBtn"); const loanedFilterBtn = document.getElementById("loanedFilterBtn");
const scannerStatus = document.getElementById("scannerStatus");
const scannerStartBtn = document.getElementById("scannerStartBtn");
const scannerStopBtn = document.getElementById("scannerStopBtn");
const scannerVideo = document.getElementById("scannerVideo");
const scannerLastCode = document.getElementById("scannerLastCode");
const gamesList = document.getElementById("gamesList"); const gamesList = document.getElementById("gamesList");
const gameCardTemplate = document.getElementById("gameCardTemplate"); const gameCardTemplate = document.getElementById("gameCardTemplate");
let editingGameId = null; let editingGameId = null;
@@ -65,6 +70,12 @@ let pendingRestoreMode = "merge";
let quickSearchTerm = ""; let quickSearchTerm = "";
let googleStatus = { configured: false, connected: false, email: "" }; let googleStatus = { configured: false, connected: false, email: "" };
let showLoanedOnly = false; let showLoanedOnly = false;
let scannerDetector = null;
let scannerStream = null;
let scannerRunning = false;
let scannerLoopId = null;
let scannerLastCodeValue = "";
let scannerLastCodeAt = 0;
toolsToggleBtn.addEventListener("click", () => { toolsToggleBtn.addEventListener("click", () => {
gamesDrawer.classList.remove("open"); gamesDrawer.classList.remove("open");
@@ -106,6 +117,9 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
toolsDrawer.classList.remove("open"); toolsDrawer.classList.remove("open");
gamesDrawer.classList.remove("open"); gamesDrawer.classList.remove("open");
if (scannerRunning) {
stopScanner("Camera arretee.");
}
} }
}); });
@@ -161,6 +175,18 @@ loanedFilterBtn.addEventListener("click", () => {
renderGames(); renderGames();
}); });
if (scannerStartBtn) {
scannerStartBtn.addEventListener("click", async () => {
await startScanner();
});
}
if (scannerStopBtn) {
scannerStopBtn.addEventListener("click", () => {
stopScanner("Camera arretee.");
});
}
platformForm.addEventListener("submit", async (event) => { platformForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
@@ -894,6 +920,131 @@ function resetEditMode() {
cancelEditBtn.classList.add("hidden"); cancelEditBtn.classList.add("hidden");
} }
function updateScannerStatus(message) {
if (scannerStatus) {
scannerStatus.textContent = message;
}
}
function scannerSupported() {
return typeof window !== "undefined" && "BarcodeDetector" in window && navigator.mediaDevices;
}
async function startScanner() {
if (!scannerVideo || !scannerStartBtn || !scannerStopBtn) {
return;
}
if (!scannerSupported()) {
updateScannerStatus("Scan non supporte sur ce navigateur. Utilise Chrome mobile recente.");
return;
}
if (scannerRunning) {
return;
}
try {
scannerDetector = new window.BarcodeDetector({
formats: ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "code_39", "qr_code"],
});
scannerStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: "environment" },
},
audio: false,
});
scannerVideo.srcObject = scannerStream;
await scannerVideo.play();
scannerRunning = true;
scannerVideo.classList.remove("hidden");
scannerStartBtn.classList.add("hidden");
scannerStopBtn.classList.remove("hidden");
updateScannerStatus("Scan en cours... vise le code-barres de la boite.");
scanLoop();
} catch (error) {
console.error(error);
updateScannerStatus("Impossible d'acceder a la camera. Verifie les permissions.");
stopScanner();
}
}
function stopScanner(message) {
if (scannerLoopId) {
cancelAnimationFrame(scannerLoopId);
scannerLoopId = null;
}
if (scannerVideo) {
scannerVideo.pause();
scannerVideo.srcObject = null;
scannerVideo.classList.add("hidden");
}
if (scannerStream) {
for (const track of scannerStream.getTracks()) {
track.stop();
}
scannerStream = null;
}
scannerRunning = false;
if (scannerStartBtn) {
scannerStartBtn.classList.remove("hidden");
}
if (scannerStopBtn) {
scannerStopBtn.classList.add("hidden");
}
updateScannerStatus(message || "Camera inactive.");
}
async function scanLoop() {
if (!scannerRunning || !scannerDetector || !scannerVideo) {
return;
}
try {
const barcodes = await scannerDetector.detect(scannerVideo);
if (barcodes.length > 0) {
const rawValue = normalizeText(barcodes[0].rawValue);
if (rawValue) {
const now = Date.now();
if (rawValue !== scannerLastCodeValue || now - scannerLastCodeAt > 1800) {
scannerLastCodeValue = rawValue;
scannerLastCodeAt = now;
applyScannedCode(rawValue);
stopScanner(`Code detecte: ${rawValue}`);
return;
}
}
}
} catch (error) {
console.error(error);
}
scannerLoopId = requestAnimationFrame(() => {
scanLoop();
});
}
function applyScannedCode(codeValue) {
if (scannerLastCode) {
scannerLastCode.textContent = `Dernier code detecte: ${codeValue}`;
scannerLastCode.classList.remove("hidden");
}
if (quickSearchInput) {
quickSearchInput.value = codeValue;
quickSearchTerm = codeValue;
renderSearchResults();
}
if (titleInput && !normalizeText(titleInput.value)) {
titleInput.value = codeValue;
}
}
function normalizeText(value) { function normalizeText(value) {
if (value == null) { if (value == null) {
return ""; return "";
@@ -1059,11 +1210,20 @@ async function bootstrap() {
await hydrateFromApi(); await hydrateFromApi();
normalizeState(); normalizeState();
render(); render();
if (scannerSupported()) {
updateScannerStatus("Camera inactive. Appuie sur Demarrer scan.");
} else {
updateScannerStatus("Scan non supporte sur ce navigateur.");
}
handleGoogleCallbackResult(); handleGoogleCallbackResult();
} }
bootstrap(); bootstrap();
window.addEventListener("beforeunload", () => {
stopScanner();
});
function handleGoogleCallbackResult() { function handleGoogleCallbackResult() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const googleParam = url.searchParams.get("google"); const googleParam = url.searchParams.get("google");

View File

@@ -123,6 +123,18 @@
<div class="games-actions-bar"> <div class="games-actions-bar">
<button id="loanedFilterBtn" type="button" class="btn-secondary">Voir jeux pretes</button> <button id="loanedFilterBtn" type="button" class="btn-secondary">Voir jeux pretes</button>
</div> </div>
<div class="scanner-zone">
<div class="scanner-header">
<strong>Scan camera (mobile)</strong>
<p id="scannerStatus" class="scanner-status">Camera inactive.</p>
</div>
<div class="scanner-actions">
<button id="scannerStartBtn" type="button" class="btn-secondary">Demarrer scan</button>
<button id="scannerStopBtn" type="button" class="btn-secondary hidden">Arreter scan</button>
</div>
<video id="scannerVideo" class="scanner-video hidden" autoplay playsinline muted></video>
<p id="scannerLastCode" class="scanner-last-code hidden"></p>
</div>
<div class="search-zone"> <div class="search-zone">
<label> <label>

View File

@@ -276,6 +276,51 @@ h1 {
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
.scanner-zone {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.75rem;
margin-bottom: 0.9rem;
background: #f7fbf8;
}
.scanner-header {
display: grid;
gap: 0.2rem;
}
.scanner-header strong {
font-size: 0.92rem;
color: #19334a;
}
.scanner-status {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.scanner-actions {
display: flex;
gap: 0.5rem;
margin: 0.65rem 0;
}
.scanner-video {
width: min(100%, 420px);
border-radius: 10px;
border: 1px solid #ccd8e6;
background: #101820;
display: block;
}
.scanner-last-code {
margin: 0.6rem 0 0;
font-size: 0.86rem;
color: #204564;
font-weight: 600;
}
.quick-search-results { .quick-search-results {
margin-top: 0.65rem; margin-top: 0.65rem;
display: grid; display: grid;