@* Scan du code-barres ISBN par la caméra. Le décodage se fait dans wwwroot/js/scanner-camera.js, par zbar compilé en WebAssembly. ⚠️ Il était en C# (ZXing.Net) jusqu'au 2026-08-19 : voir CLAUDE.md, ZXing.Net ne lisait pas des codes-barres que zbar lit sur le même livre. Ce composant ne voit plus passer un seul pixel — seulement la valeur décodée. On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée (voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la main, et cela ménage la batterie du téléphone. *@ @using System.Diagnostics @using MaBibli.Shared.Isbn @implements IAsyncDisposable @inject IJSRuntime JS
@if (_etat == Etat.Erreur) { } else {

@if (_etat == Etat.Demarrage) { Ouverture de la caméra… } else { Visez le code-barres au dos du livre, bien à plat et bien éclairé. }

@if (_avertissementFormat is not null) {

@_avertissementFormat

} @if (_framesDecodees > 0) { @* Mesure sur l'appareil de l'utilisateur. ⚠️ Elle porte maintenant sur la capture, le décodage zbar ET l'aller-retour d'interopérabilité : elle n'est pas comparable aux chiffres ZXing.Net de CLAUDE.md, qui ne mesuraient que le décodage C#. *@

Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)

} }
@code { /// Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé. [Parameter] public EventCallback OnCodeDetecte { get; set; } /// Appelé quand l'utilisateur renonce au scan. [Parameter] public EventCallback OnAnnuler { get; set; } private enum Etat { Demarrage, Actif, Erreur } // Chemin nu : il n'y a plus d'import map depuis que les empreintes WASM sont désactivées // (voir MaBibli.Client.csproj), donc plus de réécriture vers un nom empreinté que l'hôte // ne servirait pas. Le contournement « ?m=1 » qui neutralisait cette réécriture n'a plus // lieu d'être. Vérifié en développement et en publication. private const string CheminModule = "./js/scanner-camera.js"; // ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps. private const int IntervalleMs = 80; // L'image est réduite avant décodage. Elle ne traverse plus le pont JS→C# — c'est zbar, // côté JS, qui la lit — mais 960 px de large restent un bon compromis entre le détail // nécessaire à un EAN-13 dense et le temps de décodage. // // ⚠️ Plus large qu'avec ZXing.Net (640 px), et surtout l'image ENTIÈRE est analysée : le // recadrage sur la bande centrale n'existait que pour alléger le transfert de pixels. private const int LargeurMax = 960; private ElementReference _video; private IJSObjectReference? _module; private CancellationTokenSource? _boucle; private Etat _etat = Etat.Demarrage; private string? _erreur; private string? _avertissementFormat; private long _framesDecodees; private double _totalMs; private double _msMoyen => _framesDecodees == 0 ? 0 : _totalMs / _framesDecodees; protected override async Task OnAfterRenderAsync(bool premierRendu) { if (!premierRendu) { return; } try { _module = await JS.InvokeAsync("import", CheminModule); var diagnostic = await _module.InvokeAsync("diagnostic"); if (diagnostic != "ok") { Echouer(diagnostic switch { // Piège garanti en développement : depuis un téléphone, pointer l'IP locale // du PC (http://192.168.x.x) n'est PAS un contexte sécurisé pour le navigateur. "contexte-non-securise" => "La caméra n'est accessible qu'en HTTPS (ou sur localhost). " + "En développement, ouvrir l'application par l'adresse IP du PC ne suffit pas. " + "Saisissez l'ISBN à la main.", _ => "Ce navigateur ne donne pas accès à la caméra. Saisissez l'ISBN à la main.", }); return; } // On ne laisse jamais un écran noir sans explication : chaque code dit précisément // ce qui manque à l'utilisateur pour que le scan fonctionne. var statut = await _module.InvokeAsync("demarrer", _video); if (statut != "ok") { Echouer(statut switch { "permission-refusee" => "L'accès à la caméra a été refusé. Autorisez-le dans les réglages du navigateur, " + "ou saisissez l'ISBN à la main.", "aucune-camera" => "Aucune caméra utilisable n'a été trouvée sur cet appareil. Saisissez l'ISBN à la main.", "camera-occupee" => "La caméra est déjà utilisée par une autre application. Fermez-la, ou saisissez l'ISBN à la main.", _ => $"La caméra n'a pas pu démarrer ({statut[(statut.IndexOf(':') + 1)..].Trim()}). " + "Saisissez l'ISBN à la main.", }); return; } _etat = Etat.Actif; StateHasChanged(); _boucle = new CancellationTokenSource(); _ = BoucleAsync(_boucle.Token); } catch (Exception ex) { Echouer($"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main."); } } private async Task BoucleAsync(CancellationToken jeton) { try { while (!jeton.IsCancellationRequested) { await Task.Delay(IntervalleMs, jeton); if (_module is null || jeton.IsCancellationRequested) { return; } var depart = Stopwatch.GetTimestamp(); var lecture = await _module.InvokeAsync("decoder", LargeurMax); var code = lecture?.Code; _totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds; _framesDecodees++; if (code is null) { // Une frame sur dix suffit à rafraîchir la mesure sans repeindre en boucle. if (_framesDecodees % 10 == 0) { StateHasChanged(); } continue; } var isbn = IsbnUtils.Normaliser(code); if (isbn is null || !IsbnUtils.EstIsbn13Valide(isbn)) { // Un EAN-8, ou l'EAN d'un produit non-livre : on le dit et on continue. _avertissementFormat = $"Code lu (« {code} »), mais ce n'est pas un ISBN. Continuez à viser le code-barres du livre."; StateHasChanged(); continue; } await ArreterCameraAsync(); await OnCodeDetecte.InvokeAsync(new CodeScanne(isbn, lecture?.Parution)); return; } } catch (OperationCanceledException) { // Sortie normale : le composant a été quitté. } catch (JSDisconnectedException) { // La page se ferme, plus rien à libérer côté JS. } } private async Task AnnulerAsync() { await ArreterCameraAsync(); await OnAnnuler.InvokeAsync(); } private void Echouer(string message) { _erreur = message; _etat = Etat.Erreur; StateHasChanged(); } private async Task ArreterCameraAsync() { _boucle?.Cancel(); _boucle = null; if (_module is null) { return; } try { await _module.InvokeVoidAsync("arreter"); } catch (JSDisconnectedException) { } } // Quitter l'écran doit libérer la caméra, pas seulement arrêter la boucle. public async ValueTask DisposeAsync() { await ArreterCameraAsync(); if (_module is not null) { try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { } _module = null; } } }