Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
9.3 KiB
Plaintext
261 lines
9.3 KiB
Plaintext
@*
|
|
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
|
|
|
|
<div class="scanner">
|
|
@if (_etat == Etat.Erreur)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="scanner-cadre">
|
|
<video @ref="_video" class="scanner-video" muted playsinline autoplay></video>
|
|
<div class="scanner-viseur" aria-hidden="true"></div>
|
|
</div>
|
|
|
|
<p class="message-discret">
|
|
@if (_etat == Etat.Demarrage)
|
|
{
|
|
<text>Ouverture de la caméra…</text>
|
|
}
|
|
else
|
|
{
|
|
<text>Visez le code-barres au dos du livre, bien à plat et bien éclairé.</text>
|
|
}
|
|
</p>
|
|
|
|
@if (_avertissementFormat is not null)
|
|
{
|
|
<p class="message-avertissement">@_avertissementFormat</p>
|
|
}
|
|
|
|
@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#. *@
|
|
<p class="message-discret scanner-mesure">
|
|
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
|
|
</p>
|
|
}
|
|
}
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-discret" @onclick="AnnulerAsync">Annuler</button>
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
|
|
[Parameter] public EventCallback<CodeScanne> OnCodeDetecte { get; set; }
|
|
|
|
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
|
|
[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<IJSObjectReference>("import", CheminModule);
|
|
|
|
var diagnostic = await _module.InvokeAsync<string>("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<string>("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<LectureCodeBarres?>("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;
|
|
}
|
|
}
|
|
}
|