diff --git a/unity-client/Assets/Scripts/UI/ScannerController.cs b/unity-client/Assets/Scripts/UI/ScannerController.cs
index 3f6b748..a9b3943 100644
--- a/unity-client/Assets/Scripts/UI/ScannerController.cs
+++ b/unity-client/Assets/Scripts/UI/ScannerController.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
@@ -11,218 +12,345 @@ using CommanderAILab.Services;
namespace CommanderAILab.UI
{
- ///
- /// Scanner scene — live camera feed, capture frame, POST /api/scanner/scan,
- /// display result card overlay, add to collection or deck.
- ///
- public class ScannerController : MonoBehaviour
- {
- [Header("Navigation")]
- [SerializeField] private Button backButton;
-
- [Header("Camera Feed")]
- [SerializeField] private RawImage cameraFeedImage;
- [SerializeField] private Button captureButton;
- [SerializeField] private Button toggleCameraButton;
- [SerializeField] private TMP_Text cameraStatusText;
- [SerializeField] private GameObject scanOverlay; // animated scan line
-
- [Header("Result Panel")]
- [SerializeField] private GameObject resultPanel;
- [SerializeField] private Image resultCardImage;
- [SerializeField] private TMP_Text resultCardName;
- [SerializeField] private TMP_Text resultManaCost;
- [SerializeField] private TMP_Text resultType;
- [SerializeField] private TMP_Text resultSet;
- [SerializeField] private TMP_Text resultOracleText;
- [SerializeField] private TMP_Text resultConfidence;
- [SerializeField] private Button addToCollectionButton;
- [SerializeField] private Button addToDeckButton;
- [SerializeField] private Button scanAgainButton;
-
- [Header("Scan History")]
- [SerializeField] private Transform historyParent;
- [SerializeField] private GameObject historyRowPrefab;
-
- [Header("Error")]
- [SerializeField] private GameObject errorPanel;
- [SerializeField] private TMP_Text errorText;
- [SerializeField] private Button retryButton;
-
- private WebCamTexture _webcam;
- private int _cameraIndex = 0;
- private CardModel _scannedCard;
- private bool _isScanning = false;
- private readonly List _history = new();
- private readonly List _historyRows = new();
-
- private void Start()
+ ///
+ /// Scanner scene — live camera feed, capture frame, POST /api/scanner/scan,
+ /// display result card overlay, add to collection or deck.
+ /// WebGL fallback: file upload when camera is unavailable.
+ ///
+ public class ScannerController : MonoBehaviour
{
- backButton.onClick.AddListener(OnBack);
- captureButton.onClick.AddListener(OnCapture);
- toggleCameraButton.onClick.AddListener(ToggleCamera);
- addToCollectionButton.onClick.AddListener(OnAddToCollection);
- addToDeckButton.onClick.AddListener(OnAddToDeck);
- scanAgainButton.onClick.AddListener(OnScanAgain);
- retryButton.onClick.AddListener(OnCapture);
+ [Header("Navigation")]
+ [SerializeField] private Button backButton;
- resultPanel.SetActive(false);
- errorPanel.SetActive(false);
- scanOverlay.SetActive(false);
+ [Header("Camera Feed")]
+ [SerializeField] private RawImage cameraFeedImage;
+ [SerializeField] private Button captureButton;
+ [SerializeField] private Button toggleCameraButton;
+ [SerializeField] private TMP_Text cameraStatusText;
+ [SerializeField] private GameObject scanOverlay; // animated scan line
- StartCamera(_cameraIndex);
- }
+ [Header("WebGL File Upload Fallback")]
+ [SerializeField] private GameObject fileUploadPanel;
+ [SerializeField] private Button fileUploadButton;
+ [SerializeField] private TMP_Text fileUploadStatusText;
+ [SerializeField] private RawImage uploadPreviewImage;
- private void OnDestroy()
- {
- if (_webcam != null && _webcam.isPlaying) _webcam.Stop();
- }
+ [Header("Result Panel")]
+ [SerializeField] private GameObject resultPanel;
+ [SerializeField] private Image resultCardImage;
+ [SerializeField] private TMP_Text resultCardName;
+ [SerializeField] private TMP_Text resultManaCost;
+ [SerializeField] private TMP_Text resultType;
+ [SerializeField] private TMP_Text resultSet;
+ [SerializeField] private TMP_Text resultOracleText;
+ [SerializeField] private TMP_Text resultConfidence;
+ [SerializeField] private Button addToCollectionButton;
+ [SerializeField] private Button addToDeckButton;
+ [SerializeField] private Button scanAgainButton;
- // ── Camera ────────────────────────────────────────────────────────
- private void StartCamera(int idx)
- {
- if (_webcam != null && _webcam.isPlaying) _webcam.Stop();
+ [Header("Scan History")]
+ [SerializeField] private Transform historyParent;
+ [SerializeField] private GameObject historyRowPrefab;
- var devices = WebCamTexture.devices;
- if (devices.Length == 0)
- {
- cameraStatusText.text = "No camera found";
- captureButton.interactable = false;
- return;
- }
+ [Header("Error")]
+ [SerializeField] private GameObject errorPanel;
+ [SerializeField] private TMP_Text errorText;
+ [SerializeField] private Button retryButton;
- idx = Mathf.Clamp(idx, 0, devices.Length - 1);
- _webcam = new WebCamTexture(devices[idx].name, 1280, 720, 30);
- _webcam.Play();
- cameraFeedImage.texture = _webcam;
- cameraStatusText.text = $"Camera: {devices[idx].name}";
- captureButton.interactable = true;
- }
+ private WebCamTexture _webcam;
+ private int _cameraIndex = 0;
+ private CardModel _scannedCard;
+ private bool _isScanning = false;
+ private bool _isWebGL = false;
+ private readonly List _history = new();
+ private readonly List _historyRows = new();
- private void ToggleCamera()
- {
- var devices = WebCamTexture.devices;
- if (devices.Length < 2) return;
- _cameraIndex = (_cameraIndex + 1) % devices.Length;
- StartCamera(_cameraIndex);
- }
+ // WebGL JS interop for file upload
+#if UNITY_WEBGL && !UNITY_EDITOR
+ [DllImport("__Internal")]
+ private static extern void WebGLFileUploadInit(string objectName, string methodName);
- // ── Capture & Scan ────────────────────────────────────────────────
- private void OnCapture()
- {
- if (_isScanning || _webcam == null || !_webcam.isPlaying) return;
- _isScanning = true;
- errorPanel.SetActive(false);
- resultPanel.SetActive(false);
- scanOverlay.SetActive(true);
- captureButton.interactable = false;
+ [DllImport("__Internal")]
+ private static extern void WebGLFileUploadClick();
+#else
+ private static void WebGLFileUploadInit(string objectName, string methodName) { }
+ private static void WebGLFileUploadClick() { }
+#endif
- // Grab frame and encode to JPEG
- var tex = new Texture2D(_webcam.width, _webcam.height);
- tex.SetPixels(_webcam.GetPixels());
- tex.Apply();
- byte[] jpegBytes = tex.EncodeToJPG(85);
- Destroy(tex);
-
- ApiClient.Instance.ScanCard(jpegBytes,
- json =>
+ private void Start()
{
- scanOverlay.SetActive(false);
- _isScanning = false;
- captureButton.interactable = true;
- try
- {
- var result = JsonConvert.DeserializeObject(json);
- if (result?.card != null) ShowResult(result.card, result.confidence);
- else ShowError("Card not recognized. Try again.");
- }
- catch (Exception e) { ShowError($"Parse error: {e.Message}"); }
- },
- err =>
+ backButton.onClick.AddListener(OnBack);
+ captureButton.onClick.AddListener(OnCapture);
+ toggleCameraButton.onClick.AddListener(ToggleCamera);
+ addToCollectionButton.onClick.AddListener(OnAddToCollection);
+ addToDeckButton.onClick.AddListener(OnAddToDeck);
+ scanAgainButton.onClick.AddListener(OnScanAgain);
+ retryButton.onClick.AddListener(OnCapture);
+ if (fileUploadButton != null)
+ fileUploadButton.onClick.AddListener(OnFileUploadClick);
+
+ resultPanel.SetActive(false);
+ errorPanel.SetActive(false);
+ scanOverlay.SetActive(false);
+
+ // Detect WebGL and show appropriate UI
+#if UNITY_WEBGL && !UNITY_EDITOR
+ _isWebGL = true;
+#endif
+ if (_isWebGL)
+ InitWebGLFallback();
+ else
+ StartCamera(_cameraIndex);
+ }
+
+ private void OnDestroy()
{
- scanOverlay.SetActive(false);
- _isScanning = false;
- captureButton.interactable = true;
- ShowError($"Scan failed:\n{err}");
- });
- }
+ if (_webcam != null && _webcam.isPlaying) _webcam.Stop();
+ }
- // ── Result ────────────────────────────────────────────────────────
- private void ShowResult(CardModel card, float confidence)
- {
- _scannedCard = card;
- resultPanel.SetActive(true);
-
- resultCardName.text = card.name;
- resultManaCost.text = card.manaCost;
- resultType.text = card.typeLine;
- resultSet.text = card.set;
- resultOracleText.text = card.oracleText;
- resultConfidence.text = $"Confidence: {confidence:P0}";
-
- if (!string.IsNullOrEmpty(card.imageUrl))
- ImageCache.Instance.GetSprite(card.imageUrl, s =>
+ // ── WebGL File Upload Fallback ──────────────────────────────
+ private void InitWebGLFallback()
{
- if (resultCardImage != null) resultCardImage.sprite = s;
- });
+ // Hide camera UI, show file upload panel
+ cameraFeedImage.gameObject.SetActive(false);
+ toggleCameraButton.gameObject.SetActive(false);
+ captureButton.gameObject.SetActive(false);
+ if (fileUploadPanel != null) fileUploadPanel.SetActive(true);
+ if (fileUploadStatusText != null)
+ fileUploadStatusText.text = "Camera not available in browser.\nUpload a card image to scan.";
+ cameraStatusText.text = "WebGL Mode: File Upload";
- AddToHistory(card);
- }
+ // Initialize JS file input
+ WebGLFileUploadInit(gameObject.name, "OnWebGLFileUploaded");
+ }
- private void AddToHistory(CardModel card)
- {
- _history.Add(card);
- var row = Instantiate(historyRowPrefab, historyParent);
- _historyRows.Add(row);
- var lbl = row.GetComponentInChildren();
- if (lbl) lbl.text = card.name;
- var btn = row.GetComponentInChildren