@*
Scan du code-barres ISBN par la caméra.
Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage
est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md.
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.Client.Scan
@using MaBibli.Shared.Isbn
@implements IAsyncDisposable
@inject IJSRuntime JS
@if (_etat == Etat.Erreur)
{
@_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 réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation
(~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté
sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@
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 }
// Le « ?m=1 » n'est pas cosmétique. En développement, l'import map généré par Blazor
// fait pointer « ./js/scanner-camera.js » vers un nom empreinté que l'API hôte ne sert
// pas (elle utilise UseStaticFiles, qui ignore les points d'entrée empreintés) : l'import
// échoue en 404 et le scan ne démarre jamais. Une chaîne de requête empêche l'import map
// de réécrire le chemin ; en publication, aucune réécriture n'est générée pour ce fichier,
// donc le comportement est identique. Vérifié dans les deux modes.
private const string CheminModule = "./js/scanner-camera.js?m=1";
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
private const int IntervalleMs = 80;
// La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser
// le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13.
private const int LargeurMax = 640;
// Seule la bande centrale est décodée : c'est là que l'utilisateur vise.
private const double PartHauteur = 0.45;
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 dimensions = await _module.InvokeAsync("capturer", LargeurMax, PartHauteur);
if (dimensions is null)
{
continue; // vidéo pas encore prête
}
var pixels = await _module.InvokeAsync("pixels");
var depart = Stopwatch.GetTimestamp();
var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]);
_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(isbn);
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;
}
}
}