flutter_ffi_uvc 1.0.0 copy "flutter_ffi_uvc: ^1.0.0" to clipboard
flutter_ffi_uvc: ^1.0.0 copied to clipboard

Control USB(UVC) cameras. Preview, capture, record, adjust settings, and read raw frames, for one camera or several.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ffi_uvc/flutter_ffi_uvc.dart';
import 'package:flutter_ffi_uvc_example/android_bridge.dart';

import 'app_theme.dart';
import 'camera_slots_page.dart';
import 'widgets/controls_panel.dart';
import 'widgets/stream_stats_card.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'UVC Preview Demo',
      debugShowCheckedModeBanner: false,
      theme: buildExampleTheme(),
      home: const CameraSlotsPage(),
    );
  }
}

class UvcPreviewPage extends StatefulWidget {
  UvcPreviewPage({
    super.key,
    UvcCamera? camera,
    this.ownsCamera = false,
    this.title = 'UVC Camera Preview',
    this.isOpenElsewhere,
    this.openRevision,
    this.onOpenChanged,
  }) : camera = camera ?? uvcCamera;

  final UvcCamera camera;

  /// When true the page disposes [camera] after its own teardown.
  final bool ownsCamera;
  final String title;

  /// Whether another page holds the device open. Such devices are not
  /// offered here.
  final bool Function(int deviceId)? isOpenElsewhere;

  /// Bumped by the host when any page opens or closes a device.
  final ValueListenable<int>? openRevision;

  /// Called after this page opened or closed a device.
  final VoidCallback? onOpenChanged;

  @override
  State<UvcPreviewPage> createState() => _UvcPreviewPageState();
}

class _UvcPreviewPageState extends State<UvcPreviewPage>
    with WidgetsBindingObserver {
  static const AndroidBridge _androidBridge = AndroidBridge();
  static const String _logPrefix = '@@@@UVC_EXAMPLE';
  static const Duration _startupProbeTimeout = Duration(seconds: 2);
  static const Duration _fpsSampleInterval = Duration(milliseconds: 1000);
  static const Duration _streamErrorSnackbarCooldown = Duration(seconds: 3);
  UvcCamera get _camera => widget.camera;

  // Device refresh, open, and disconnect may finish after the page was
  // removed, for example when a slot is deleted mid-open.
  void _safeSetState(VoidCallback fn) {
    if (mounted) setState(fn);
  }

  List<UvcUsbDevice> _devices = const <UvcUsbDevice>[];
  List<UvcCameraMode> _cameraModes = const <UvcCameraMode>[];
  // Format filter for the mode dropdown (null = show all formats). Useful on
  // Windows, where Media Foundation reports every format/resolution/fps
  // combination and the flat list gets long.
  String? _modeFormatFilter;

  /// Where desktop builds save captures. Shown in the save toggle subtitle so
  /// the destination is visible before capturing; the write path falls back to
  /// the working directory if this folder does not exist.
  // Video recording has no Linux backend yet; hide the record button there
  // instead of surfacing a button that always fails.
  bool get _platformSupportsRecording => !Platform.isLinux;

  Directory get _desktopCaptureDirectory {
    final String? profile =
        Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'];
    if (profile == null) {
      return Directory.current;
    }
    return Directory('$profile${Platform.pathSeparator}Pictures');
  }

  List<String> get _modeFormatNames => _cameraModes
      .map((UvcCameraMode mode) => mode.formatName)
      .toSet()
      .toList();

  List<UvcCameraMode> get _filteredCameraModes => _modeFormatFilter == null
      ? _cameraModes
      : _cameraModes
            .where((UvcCameraMode mode) => mode.formatName == _modeFormatFilter)
            .toList();
  List<UvcCameraControl> _cameraControls = const <UvcCameraControl>[];
  UvcUsbDevice? _selectedDevice;
  UvcCameraMode? _selectedMode;
  int? _previewTextureId;
  ui.Image? _previewImage;
  Timer? _previewStatsTimer;
  bool _loadingDevices = true;
  bool _openingDevice = false;
  bool _afTriggering = false;
  bool _previewFrozen = false;
  bool _savingPhoto = false;
  bool _recording = false;
  String? _recordingPath;
  bool _saveToGallery = false;
  bool _transformControlsExpanded = false;
  bool _manualFocusControlsVisible = false;
  StreamSubscription<UvcStreamError>? _streamErrorSub;
  StreamSubscription<UvcDeviceEvent>? _deviceEventSub;
  StreamSubscription<UvcStallEvent>? _stallEventSub;
  bool _stallAutoRecover = true;
  Timer? _focusRepeatTimer;
  Timer? _focusValueHideTimer;
  bool _focusValueVisible = false;
  String? _status;
  String? _lastSnackBarErrorKey;
  Duration? _lastSnackBarErrorAt;
  double _previewFps = 0;
  int _lastPreviewSequence = 0;
  Duration? _lastPreviewSequenceSampleAt;

  // Monotonic clock for FPS sampling and snackbar cooldowns; unlike
  // DateTime.now() it is immune to wall-clock adjustments.
  final Stopwatch _monotonicClock = Stopwatch()..start();
  UvcStreamStats _streamStats = const UvcStreamStats.zero();

  @override
  void initState() {
    super.initState();
    _camera.setLogLevel(UvcLogLevel.debug);
    WidgetsBinding.instance.addObserver(this);
    _streamErrorSub = _camera.streamErrors.listen(_onStreamError);
    // USB hot-plug notifications: attach refreshes the list, detach of the
    // active device tears the session down.
    _deviceEventSub = _camera.deviceEvents.listen(_onDeviceEvent);
    // Watchdog: report (and, when enabled, auto-recover from) silent stalls.
    _stallEventSub = _camera.stallEvents.listen(_onStallEvent);
    _camera.enableStallDetection(_stallDetectionConfig());
    widget.openRevision?.addListener(_onOpenRevision);
    unawaited(_initializePermissionsAndDevices());
  }

  void _onOpenRevision() => _safeSetState(() {});

  bool _isOpenElsewhere(UvcUsbDevice device) =>
      widget.isOpenElsewhere?.call(device.deviceId) ?? false;

  // Library defaults (2s stall timeout, 500ms checks, 3 restart attempts)
  // are fine for the demo; only the auto-restart switch is ours.
  UvcStallDetectionConfig _stallDetectionConfig() =>
      UvcStallDetectionConfig(autoRestart: _stallAutoRecover);

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      unawaited(_disconnectSelectedDevice());
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    widget.openRevision?.removeListener(_onOpenRevision);
    _streamErrorSub?.cancel();
    _deviceEventSub?.cancel();
    _stallEventSub?.cancel();
    _camera.disableStallDetection();
    _previewStatsTimer?.cancel();
    _focusRepeatTimer?.cancel();
    _focusValueHideTimer?.cancel();
    _previewImage?.dispose();
    unawaited(_teardownCamera());
    super.dispose();
  }

  Future<void> _teardownCamera() async {
    await _stopCurrentPreview();
    await _disposePreviewTexture();
    await _camera.closeUsbDevice();
    if (widget.ownsCamera) {
      await _camera.dispose();
    }
    widget.onOpenChanged?.call();
  }

  Future<void> _initializePermissionsAndDevices() async {
    try {
      final bool granted = await _camera.ensureCameraPermission();
      if (!granted) {
        _setStatus('Camera permission is required.', loadingDevices: false);
        return;
      }
      await _refreshDevices();
    } on PlatformException catch (error) {
      _setStatus(
        'Failed to request camera permission: ${error.message ?? error.code}',
        loadingDevices: false,
        error: error,
      );
    }
  }

  Future<void> _refreshDevices() async {
    _log('Refreshing device list');
    _safeSetState(() {
      _loadingDevices = true;
      _status = null;
    });

    try {
      final List<UvcUsbDevice> devices = await _camera.listUsbDevices();

      _safeSetState(() {
        _devices = devices;
        _selectedDevice =
            devices.any(
              (UvcUsbDevice device) =>
                  device.deviceId == _selectedDevice?.deviceId,
            )
            ? devices.firstWhere(
                (UvcUsbDevice device) =>
                    device.deviceId == _selectedDevice?.deviceId,
              )
            : null;
        _loadingDevices = false;
        if (devices.isEmpty) {
          _status = 'No USB camera found.';
        }
      });
      _log('Loaded ${devices.length} device(s)');
    } on PlatformException catch (error) {
      _setStatus(
        'Failed to load device list: ${error.message ?? error.code}',
        loadingDevices: false,
        error: error,
      );
    }
  }

  Future<void> _openSelectedDevice(UvcUsbDevice device) async {
    if (_isOpenElsewhere(device)) {
      _setStatus('${device.displayName} is open in another camera slot.');
      return;
    }
    _setStatus('Opening device...', openingDevice: true);
    _log('Open device requested: ${device.displayName}');

    _previewImage?.dispose();
    _previewImage = null;

    try {
      await _ensurePreviewTexture();
      // _stopCurrentPreview() does app-level UI teardown (stats timer, FPS,
      // image); openUsbDevice() tears down the native session itself before
      // opening. Both are needed — they clean up different layers.
      await _stopCurrentPreview();
      final Stopwatch timing = Stopwatch()..start();
      await _camera.openUsbDevice(device.deviceId);
      final int openMs = timing.elapsedMilliseconds;

      final List<UvcCameraMode> libuvcModes = _camera.supportedModes();
      final int modesMs = timing.elapsedMilliseconds - openMs;
      final List<UvcCameraControl> controls = _camera.supportedControls();
      final int controlsMs = timing.elapsedMilliseconds - openMs - modesMs;
      _log(
        'Controls: ${controls.map((UvcCameraControl c) => '${c.name}(id=${c.id.nativeValue},cur=${c.cur})').join(', ')}',
      );

      // Debug-only: logs controls that are advertised in bmControls but fail GET_CUR probing.
      final List<UvcBmControlInfo> bmControls = _camera.debugBmControls();
      _log(
        'Open timings: open=${openMs}ms modes=${modesMs}ms '
        'controls=${controlsMs}ms '
        'bm=${timing.elapsedMilliseconds - openMs - modesMs - controlsMs}ms',
      );
      final Set<int> controlIds = controls
          .map((UvcCameraControl c) => c.id.nativeValue)
          .toSet();
      final List<UvcBmControlInfo> bmOnlyControls = bmControls
          .where((UvcBmControlInfo c) => !controlIds.contains(c.id.nativeValue))
          .toList();
      if (controls.length != bmControls.length && bmOnlyControls.isNotEmpty) {
        _log(
          'bmControls-only: ${bmOnlyControls.map((UvcBmControlInfo c) => '${c.name}(id=${c.id.nativeValue})').join(', ')}',
        );
      }

      if (libuvcModes.isEmpty) {
        throw Exception('No supported camera modes were found.');
      }

      final List<UvcCameraMode> sortedModes = _sortModesByPreference(
        libuvcModes,
      );
      _setStatus(
        'Opening device... Auto-selecting a working mode...',
        openingDevice: true,
      );
      // startPreviewAuto() runs the MJPEG-first fallback loop this example
      // used to implement by hand: each candidate is verified like
      // startPreview and rejected on failure, keeping the first mode that
      // actually delivers frames. The default reliability preference probes
      // smaller, safer modes first; pass
      // preference: UvcAutoPreviewPreference.quality to try larger
      // resolutions first instead.
      final Stopwatch previewTiming = Stopwatch()..start();
      final UvcAutoPreviewResult autoResult = await _camera.startPreviewAuto(
        perModeTimeout: _startupProbeTimeout,
        maxCandidates: 3,
      );
      _log(
        'Preview auto-select took ${previewTiming.elapsedMilliseconds}ms '
        'over ${autoResult.attempts.length} attempt(s)',
      );
      final UvcCameraMode? startedMode = autoResult.mode;
      if (startedMode != null) {
        await _onPreviewStarted(startedMode);
      }
      final UvcPreviewStartResult? lastProbeResult = autoResult.attempts.isEmpty
          ? null
          : autoResult.attempts.last;

      final String statusMessage;
      if (startedMode != null) {
        statusMessage = 'Preview running: ${startedMode.label} / Texture';
      } else if (autoResult.attempts.isEmpty) {
        statusMessage =
            'Opened device. No modes were available for automatic probe.';
      } else {
        statusMessage =
            'Opened device. Automatic probe tried ${autoResult.attempts.length} mode(s) and found no working preview. Try a mode manually.';
      }

      widget.onOpenChanged?.call();
      _safeSetState(() {
        _selectedDevice = device;
        _cameraModes = _sortModesForDisplay(libuvcModes);
        _modeFormatFilter = null;
        _cameraControls = controls;
        _selectedMode = startedMode ?? sortedModes.first;
        _openingDevice = false;
        _previewFrozen = false;
        _manualFocusControlsVisible = false;
        _status = statusMessage;
      });
      if (startedMode != null) {
        _log(
          'Preview running: ${device.displayName} / ${startedMode.label} / Texture',
        );
      } else {
        _log(
          'Device opened without working preview mode: ${device.displayName} / ${lastProbeResult == null ? "no probe result" : _startFailureMessage(lastProbeResult)}',
        );
      }
    } on PlatformException catch (error) {
      _setStatus(
        'Failed to open device: ${error.message ?? error.code}',
        openingDevice: false,
        error: error,
      );
    } catch (error) {
      _setStatus(error.toString(), openingDevice: false, error: error);
    }
  }

  Future<void> _disconnectSelectedDevice() async {
    if (_openingDevice) {
      return;
    }

    final String deviceTitle =
        _selectedDevice?.displayName ?? 'Connected device';
    _setStatus('Disconnecting device...', openingDevice: true);
    _log('Disconnect requested: $deviceTitle');

    try {
      await _stopCurrentPreview(clearPreviewImage: true);
      await _camera.closeUsbDevice();
      await _disposePreviewTexture();
      widget.onOpenChanged?.call();
      _safeSetState(() {
        _selectedDevice = null;
        _selectedMode = null;
        _cameraModes = const <UvcCameraMode>[];
        _modeFormatFilter = null;
        _cameraControls = const <UvcCameraControl>[];
        _previewFrozen = false;
        _transformControlsExpanded = false;
        _manualFocusControlsVisible = false;
        _openingDevice = false;
        _status = 'Device disconnected.';
        _previewFps = 0;
      });
      _log('Device disconnected: $deviceTitle');
    } on PlatformException catch (error) {
      _setStatus(
        'Failed to disconnect device: ${error.message ?? error.code}',
        openingDevice: false,
        error: error,
      );
    } catch (error) {
      _setStatus(
        'Failed to disconnect device.',
        openingDevice: false,
        error: error,
      );
    }
  }

  Future<void> _switchMode(UvcCameraMode mode) async {
    if (_openingDevice) {
      return;
    }
    _previewFrozen = false;
    _setStatus('Switching mode: ${mode.label}', openingDevice: true);
    await _stopCurrentPreview(clearPreviewImage: true);

    final UvcPreviewStartResult probeResult = await _startPreview(
      mode,
      policy: UvcPreviewPolicy.sequenceOnly,
    );
    if (!probeResult.success) {
      _setStatus(
        'Failed to switch mode: ${_startFailureMessage(probeResult)}',
        openingDevice: false,
      );
      return;
    }

    _safeSetState(() {
      _selectedMode = mode;
      _openingDevice = false;
      _previewFrozen = false;
      _manualFocusControlsVisible = false;
      _status = 'Preview running: ${mode.label} / Texture';
    });
    _log('Preview mode changed: ${mode.label} / Texture');
  }

  /// Dropdown display order: highest resolution first, then fps, with the
  /// format name as a stable tiebreak.
  List<UvcCameraMode> _sortModesForDisplay(List<UvcCameraMode> modes) {
    final List<UvcCameraMode> sorted = List<UvcCameraMode>.from(modes);
    sorted.sort((UvcCameraMode a, UvcCameraMode b) {
      final int areaCompare = (b.width * b.height).compareTo(
        a.width * a.height,
      );
      if (areaCompare != 0) {
        return areaCompare;
      }
      final int fpsCompare = b.fps.compareTo(a.fps);
      if (fpsCompare != 0) {
        return fpsCompare;
      }
      return a.formatName.compareTo(b.formatName);
    });
    return sorted;
  }

  List<UvcCameraMode> _sortModesByPreference(List<UvcCameraMode> modes) {
    final List<UvcCameraMode> sorted = List<UvcCameraMode>.from(modes);
    sorted.sort((UvcCameraMode a, UvcCameraMode b) {
      final int aIsMjpeg = a.formatName == 'MJPEG' ? 1 : 0;
      final int bIsMjpeg = b.formatName == 'MJPEG' ? 1 : 0;
      if (aIsMjpeg != bIsMjpeg) {
        return bIsMjpeg - aIsMjpeg;
      }

      final int areaCompare = (a.width * a.height).compareTo(
        b.width * b.height,
      );
      if (areaCompare != 0) {
        return areaCompare;
      }

      return b.fps.compareTo(a.fps);
    });
    return sorted;
  }

  String _startFailureMessage(UvcPreviewStartResult result) {
    // When the native stream failed to start, startPreview now reports a typed
    // error code (e.g. noDevice on mid-session disconnect, notSupported for an
    // unusable mode). Surface it so failures are actionable.
    final UvcErrorCode? code = result.errorCode;
    final String codeSuffix = code == null
        ? ''
        : ' [${code.name} (${result.nativeErrorCode})]';
    final String? error = result.lastError;
    if (error != null && error.isNotEmpty) {
      return '$error$codeSuffix';
    }
    return 'No valid frame sequence was observed for this mode within '
        '${_startupProbeTimeout.inSeconds}s.$codeSuffix';
  }

  void _onDeviceEvent(UvcDeviceEvent event) {
    _log('Device event: $event');
    final bool isActiveDevice =
        _selectedDevice != null &&
        event.device.deviceId == _selectedDevice!.deviceId;
    // The package closes its own device on detach. _disconnectSelectedDevice
    // still runs to release the texture and reset the page state.
    switch (event.type) {
      case UvcDeviceEventType.attached:
        _setStatus('USB camera attached: ${event.device.displayName}');
        unawaited(_refreshDevices());
      case UvcDeviceEventType.detached:
        if (isActiveDevice) {
          // The active device lost its transport; the native session is dead.
          _setStatus('Active camera detached: ${event.device.displayName}');
          unawaited(_disconnectSelectedDevice());
        } else {
          _setStatus('USB camera detached: ${event.device.displayName}');
          unawaited(_refreshDevices());
        }
    }
  }

  void _onStallEvent(UvcStallEvent event) {
    _log('Stall event: $event');
    final String message;
    final Color background;
    switch (event.type) {
      case UvcStallEventType.stalled:
        message =
            'Preview stalled: no frames for ${event.silence.inMilliseconds}ms'
            '${_stallAutoRecover ? ' — recovering...' : '.'}';
        background = Colors.orange.shade900;
      case UvcStallEventType.restartSucceeded:
        message =
            'Preview recovered after ${event.restartAttempt} restart '
            'attempt(s).';
        background = Colors.green.shade800;
      case UvcStallEventType.restartFailed:
        final UvcPreviewStartResult? restart = event.restartResult;
        message =
            'Preview restart attempt ${event.restartAttempt} failed'
            '${restart == null ? '' : ': ${_startFailureMessage(restart)}'}.';
        background = Colors.red.shade800;
    }
    if (!mounted) {
      _status = message;
      return;
    }
    _safeSetState(() => _status = message);
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(
        SnackBar(
          content: Text(message),
          backgroundColor: background,
          duration: const Duration(seconds: 3),
        ),
      );
  }

  void _setStallAutoRecover(bool value) {
    _safeSetState(() => _stallAutoRecover = value);
    // Reconfigure the watchdog in place: detection stays on either way, only
    // the automatic stop/restart behaviour is toggled.
    _camera.enableStallDetection(_stallDetectionConfig());
  }

  void _resetPreviewFps() {
    _previewFps = 0;
    _lastPreviewSequence = 0;
    _lastPreviewSequenceSampleAt = null;
  }

  void _resetStreamStats() {
    _streamStats = const UvcStreamStats.zero();
  }

  bool get _hasLivePreview => _previewTextureId != null && _camera.isPreviewing;

  double? get _previewAspectRatio {
    final UvcCameraMode? mode = _selectedMode;
    if (mode == null || mode.width <= 0 || mode.height <= 0) {
      return null;
    }
    final (int w, int h) = _camera.previewTransform.applyToSize(
      mode.width,
      mode.height,
    );
    return w / h;
  }

  void _samplePreviewFps() {
    final Duration now = _monotonicClock.elapsed;
    final Duration? previousAt = _lastPreviewSequenceSampleAt;
    final int latestSequence = _camera.latestFrameSequence();
    final UvcStreamStats streamStats = _camera.getStreamStats();
    if (previousAt == null) {
      _lastPreviewSequence = latestSequence;
      _lastPreviewSequenceSampleAt = now;
      _streamStats = streamStats;
      return;
    }

    final double seconds =
        (now - previousAt).inMicroseconds / Duration.microsecondsPerSecond;
    if (seconds <= 0) {
      return;
    }

    final int frameDelta = latestSequence - _lastPreviewSequence;
    _lastPreviewSequence = latestSequence;
    _lastPreviewSequenceSampleAt = now;
    if (!mounted) {
      _previewFps = frameDelta <= 0 ? 0 : frameDelta / seconds;
      _streamStats = streamStats;
      return;
    }
    _safeSetState(() {
      _previewFps = frameDelta <= 0 ? 0 : frameDelta / seconds;
      _streamStats = streamStats;
    });
  }

  Future<void> _ensurePreviewTexture() async {
    if (_previewTextureId != null) {
      return;
    }

    final int textureId = await _camera.createPreviewTexture();
    if (!mounted) {
      _previewTextureId = textureId;
      return;
    }
    _safeSetState(() {
      _previewTextureId = textureId;
    });
  }

  Future<void> _disposePreviewTexture() async {
    final int? textureId = _previewTextureId;
    if (textureId == null) {
      return;
    }

    _previewTextureId = null;
    await _camera.disposePreviewTexture(textureId);
  }

  Future<UvcPreviewStartResult> _startPreview(
    UvcCameraMode mode, {
    UvcPreviewPolicy policy = UvcPreviewPolicy.stableFrames,
  }) async {
    _log('libuvc preview start attempt: ${mode.label} / Texture');
    final UvcPreviewStartResult result = await _camera.startPreview(
      mode,
      policy: policy,
      consecutiveValidFrames: 3,
      timeout: _startupProbeTimeout,
    );
    if (result.success) {
      await _onPreviewStarted(mode);
      return result;
    }
    _previewStatsTimer?.cancel();
    _previewStatsTimer = null;
    return result;
  }

  /// Attaches the preview texture and (re)starts FPS/stats sampling after any
  /// successful preview start, whether via [_startPreview] or the library's
  /// [UvcCamera.startPreviewAuto].
  Future<void> _onPreviewStarted(UvcCameraMode mode) async {
    final int? textureId = _previewTextureId;
    if (textureId != null) {
      await _camera.attachPreviewTexture(
        textureId,
        width: mode.width,
        height: mode.height,
      );
    }
    _previewStatsTimer?.cancel();
    _resetPreviewFps();
    _resetStreamStats();
    _lastPreviewSequence = _camera.latestFrameSequence();
    _lastPreviewSequenceSampleAt = _monotonicClock.elapsed;
    _previewStatsTimer = Timer.periodic(
      _fpsSampleInterval,
      (_) => _samplePreviewFps(),
    );
  }

  Future<void> _stopCurrentPreview({bool clearPreviewImage = false}) async {
    // Stopping the preview finalizes any in-flight recording natively; go
    // through the app-level stop first so the finished file gets saved.
    if (_recording) {
      await _stopRecordingAndSave();
    }
    _previewStatsTimer?.cancel();
    _previewStatsTimer = null;
    _resetPreviewFps();

    if (clearPreviewImage) {
      final ui.Image? previousImage = _previewImage;
      if (mounted) {
        _safeSetState(() {
          _previewImage = null;
        });
      } else {
        _previewImage = null;
      }
      previousImage?.dispose();
    }

    await _camera.stopPreview();
  }

  void _setStatus(
    String status, {
    bool? loadingDevices,
    bool? openingDevice,
    Object? error,
  }) {
    _log(status, error: error);
    _safeSetState(() {
      _status = status;
      if (loadingDevices != null) {
        _loadingDevices = loadingDevices;
      }
      if (openingDevice != null) {
        _openingDevice = openingDevice;
      }
    });
  }

  bool get _hasFocusAuto =>
      _cameraControls.any((UvcCameraControl c) => c.name == 'focus_auto');

  UvcCameraControl? get _focusAbsControl => _cameraControls
      .where((UvcCameraControl c) => c.name == 'focus_abs')
      .firstOrNull;

  void _stepFocus(int direction) {
    final UvcCameraControl? ctrl = _focusAbsControl;
    if (ctrl == null) return;
    final int step = ctrl.res > 0 ? ctrl.res : 1;
    final int next = (ctrl.cur + direction * step).clamp(ctrl.min, ctrl.max);
    if (next == ctrl.cur) return;
    try {
      _camera.setControl(ctrl.id, next);
    } on UvcException catch (error) {
      _log('setControl failed: $error');
      return;
    }
    _focusValueHideTimer?.cancel();
    _focusValueHideTimer = Timer(const Duration(seconds: 2), () {
      if (mounted) _safeSetState(() => _focusValueVisible = false);
    });
    _safeSetState(() {
      _focusValueVisible = true;
      _cameraControls = _cameraControls
          .map(
            (UvcCameraControl c) =>
                c.name == 'focus_abs' ? c.copyWithCur(next) : c,
          )
          .toList();
    });
  }

  Future<void> _toggleManualFocusControls() async {
    if (_manualFocusControlsVisible) {
      _safeSetState(() {
        _manualFocusControlsVisible = false;
        _focusValueVisible = false;
      });
      return;
    }

    final UvcCameraControl? ctrl = _focusAbsControl;
    if (ctrl == null) {
      return;
    }

    final int? currentValue = _camera.getControl(ctrl.id);
    if (currentValue != null) {
      _safeSetState(() {
        _cameraControls = _cameraControls
            .map(
              (UvcCameraControl c) =>
                  c.id == ctrl.id ? c.copyWithCur(currentValue) : c,
            )
            .toList();
        _focusValueVisible = true;
        _manualFocusControlsVisible = true;
      });
      _focusValueHideTimer?.cancel();
      _focusValueHideTimer = Timer(const Duration(seconds: 2), () {
        if (mounted) _safeSetState(() => _focusValueVisible = false);
      });
      return;
    }

    _safeSetState(() {
      _manualFocusControlsVisible = true;
    });
  }

  void _startFocusRepeat(int direction) {
    _stepFocus(direction);
    _focusRepeatTimer = Timer.periodic(
      const Duration(milliseconds: 100),
      (_) => _stepFocus(direction),
    );
  }

  void _stopFocusRepeat() {
    _focusRepeatTimer?.cancel();
    _focusRepeatTimer = null;
  }

  Future<void> _triggerOneShutAF() async {
    _safeSetState(() => _afTriggering = true);
    try {
      _camera.setControl(UvcControlId.focusAuto, 1);
      await Future<void>.delayed(const Duration(milliseconds: 600));
      _camera.setControl(UvcControlId.focusAuto, 0);
    } on UvcException catch (error) {
      _log('One-shot AF failed: $error');
    } finally {
      if (mounted) _safeSetState(() => _afTriggering = false);
    }
  }

  Future<void> _capturePhoto() async {
    if (_savingPhoto || _previewFrozen) {
      return;
    }

    _safeSetState(() => _savingPhoto = true);
    ui.Image? capturedImage;
    try {
      // takePicture() encodes JPEG in the native layer and defaults to
      // previewTransform, so the capture matches what the preview shows. For
      // raw pixel access (ML inference, custom encoding) use
      // copyLatestFrame() instead — see the package README.
      final UvcStillPicture? picture = _camera.takePicture();
      if (picture == null) {
        throw Exception('No preview frame available to capture.');
      }
      final ui.Codec codec = await ui.instantiateImageCodec(picture.jpegBytes);
      try {
        capturedImage = (await codec.getNextFrame()).image;
      } finally {
        codec.dispose();
      }
      final Uint8List imageBytes = picture.jpegBytes;
      const String fileExtension = 'jpg';
      const String mimeType = 'image/jpeg';

      if (_saveToGallery) {
        final String timestamp = DateTime.now()
            .toIso8601String()
            .replaceAll(':', '-')
            .replaceAll('.', '-');
        final String fileName = 'uvc_capture_$timestamp.$fileExtension';
        if (Platform.isAndroid) {
          final String? savedUri = await _androidBridge.saveImageToGallery(
            imageBytes,
            displayName: fileName,
            mimeType: mimeType,
          );
          _setStatus(
            savedUri == null || savedUri.isEmpty
                ? 'Saved capture to gallery.'
                : 'Saved capture to gallery: $savedUri',
          );
        } else {
          // Desktop platforms have no media store; write into the user's
          // Pictures folder (falling back to the working directory).
          final Directory targetDir = _desktopCaptureDirectory;
          final Directory dir = await targetDir.exists()
              ? targetDir
              : Directory.current;
          final File file = File(
            '${dir.path}${Platform.pathSeparator}$fileName',
          );
          await file.writeAsBytes(imageBytes, flush: true);
          _setStatus('Saved capture to ${file.path}');
        }
      }
      await _stopCurrentPreview();
      final ui.Image? previousImage = _previewImage;
      if (mounted) {
        _safeSetState(() {
          _previewImage = capturedImage;
          _previewFrozen = true;
        });
      } else {
        _previewImage = capturedImage;
        _previewFrozen = true;
      }
      previousImage?.dispose();
      capturedImage = null;
      _setStatus('Preview paused on captured frame.');
    } on PlatformException catch (error) {
      _setStatus(
        'Failed to save capture: ${error.message ?? error.code}',
        error: error,
      );
    } catch (error) {
      _setStatus('Failed to save capture.', error: error);
    } finally {
      capturedImage?.dispose();
      if (mounted) {
        _safeSetState(() => _savingPhoto = false);
      } else {
        _savingPhoto = false;
      }
    }
  }

  Future<void> _toggleRecording() async {
    if (_recording) {
      await _stopRecordingAndSave();
      return;
    }
    if (!_hasLivePreview || _previewFrozen) {
      return;
    }

    final String timestamp = DateTime.now()
        .toIso8601String()
        .replaceAll(':', '-')
        .replaceAll('.', '-');
    final String fileName = 'uvc_video_$timestamp.mp4';
    final String path;
    if (Platform.isAndroid) {
      // Record into the app cache; moved to the gallery on stop.
      path = '${Directory.systemTemp.path}/$fileName';
    } else {
      final Directory targetDir = _desktopCaptureDirectory;
      final Directory dir = await targetDir.exists()
          ? targetDir
          : Directory.current;
      path = '${dir.path}${Platform.pathSeparator}$fileName';
    }

    try {
      _camera.startVideoRecording(path);
    } on UvcException catch (error) {
      _setStatus('Failed to start recording: $error');
      return;
    }
    _safeSetState(() {
      _recording = true;
      _recordingPath = path;
    });
    _setStatus('Recording video...');
  }

  Future<void> _stopRecordingAndSave() async {
    final String? path = _recordingPath;
    UvcException? failure;
    try {
      _camera.stopVideoRecording();
    } on UvcException catch (error) {
      failure = error;
    }
    if (mounted) {
      _safeSetState(() {
        _recording = false;
        _recordingPath = null;
      });
    } else {
      _recording = false;
      _recordingPath = null;
    }
    if (failure != null) {
      _setStatus('Failed to finalize recording: $failure');
      return;
    }
    if (path == null) {
      return;
    }
    if (Platform.isAndroid) {
      try {
        final String? savedUri = await _androidBridge.saveVideoToGallery(
          path,
          displayName: path.split('/').last,
        );
        _setStatus(
          savedUri == null || savedUri.isEmpty
              ? 'Saved recording to gallery.'
              : 'Saved recording to gallery: $savedUri',
        );
      } on PlatformException catch (error) {
        _setStatus(
          'Recording kept at $path — gallery move failed: '
          '${error.message ?? error.code}',
          error: error,
        );
      }
    } else {
      _setStatus('Saved recording to $path');
    }
  }

  Future<void> _resumePreview() async {
    final UvcCameraMode? mode = _selectedMode;
    if (mode == null || _openingDevice) {
      return;
    }

    _previewFrozen = false;
    _setStatus('Resuming preview...', openingDevice: true);
    final ui.Image? previousImage = _previewImage;
    _previewImage = null;
    final UvcPreviewStartResult probeResult = await _startPreview(
      mode,
      policy: UvcPreviewPolicy.sequenceOnly,
    );
    if (!probeResult.success) {
      _previewImage = previousImage;
      _setStatus(
        'Failed to resume preview: ${_startFailureMessage(probeResult)}',
        openingDevice: false,
      );
      return;
    }
    previousImage?.dispose();

    if (!mounted) {
      _previewFrozen = false;
      _openingDevice = false;
      _status = 'Preview running: ${mode.label} / Texture';
      return;
    }

    _safeSetState(() {
      _previewFrozen = false;
      _openingDevice = false;
      _status = 'Preview running: ${mode.label} / Texture';
    });
  }

  void _showControlsPanel() {
    showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      barrierColor: Colors.transparent,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (BuildContext context) {
        return CameraControlsPanel(
          controls: _cameraControls,
          onChanged: (UvcControlId id, int value) {
            try {
              _camera.setControl(id, value);
            } on UvcException catch (error) {
              _log(
                'setControl failed id=${id.nativeValue} value=$value: $error',
              );
              return;
            }
            _safeSetState(() {
              _cameraControls = _cameraControls
                  .map(
                    (UvcCameraControl c) =>
                        c.id == id ? c.copyWithCur(value) : c,
                  )
                  .toList();
            });
          },
          onReset: () {
            for (final UvcCameraControl ctrl in _cameraControls) {
              if (ctrl.id == UvcControlId.focusAbs ||
                  ctrl.id == UvcControlId.focusAuto ||
                  ctrl.id == UvcControlId.focusSimple) {
                continue;
              }
              try {
                _camera.setControl(ctrl.id, ctrl.def);
              } on UvcException catch (_) {
                // Some controls refuse their default. Keep going.
              }
            }
            final List<UvcCameraControl> refreshed = _camera
                .supportedControls();
            _safeSetState(() {
              _cameraControls = refreshed;
            });
            Navigator.of(context).pop();
            _showControlsPanel();
          },
        );
      },
    );
  }

  void _onStreamError(UvcStreamError error) {
    _log('Stream error: ${error.message}');
    _status = 'Stream error: ${error.message}';
    if (!mounted) {
      return;
    }

    final Duration now = _monotonicClock.elapsed;
    final String errorKey = _normaliseStreamErrorKey(error.message);
    final bool isRepeatedMessage = _lastSnackBarErrorKey == errorKey;
    final bool withinCooldown =
        _lastSnackBarErrorAt != null &&
        now - _lastSnackBarErrorAt! < _streamErrorSnackbarCooldown;

    if (isRepeatedMessage && withinCooldown) {
      _safeSetState(() {
        _status = 'Stream error: ${error.message}';
      });
      return;
    }

    _lastSnackBarErrorKey = errorKey;
    _lastSnackBarErrorAt = now;
    _safeSetState(() {
      _status = 'Stream error: ${error.message}';
    });
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(
        SnackBar(
          content: Text(error.message),
          backgroundColor: Colors.red.shade800,
          duration: const Duration(seconds: 5),
          action: SnackBarAction(
            label: 'Dismiss',
            textColor: Colors.white,
            onPressed: () =>
                ScaffoldMessenger.of(context).hideCurrentSnackBar(),
          ),
        ),
      );
  }

  String _normaliseStreamErrorKey(String message) {
    String normalized = message.trim();
    normalized = normalized.replaceAll(RegExp(r'width=\d+'), 'width=*');
    normalized = normalized.replaceAll(RegExp(r'height=\d+'), 'height=*');
    normalized = normalized.replaceAll(RegExp(r'bytes=\d+'), 'bytes=*');
    normalized = normalized.replaceAll(RegExp(r'expected>=\d+'), 'expected>=*');
    normalized = normalized.replaceAll(RegExp(r'actual=\d+'), 'actual=*');
    normalized = normalized.replaceAll(RegExp(r'callback=\d+'), 'callback=*');
    normalized = normalized.replaceAll(RegExp(r'format=\d+'), 'format=*');
    normalized = normalized.replaceAll(RegExp(r'err=[^,\s]+'), 'err=*');
    return normalized;
  }

  void _log(String message, {Object? error}) {
    debugPrint('$_logPrefix $message');
    if (error != null) {
      debugPrint('$_logPrefix error=$error');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        systemOverlayStyle: const SystemUiOverlayStyle(
          statusBarColor: Colors.transparent,
          systemNavigationBarColor: Color(0xFF000000),
          systemNavigationBarIconBrightness: Brightness.light,
          statusBarIconBrightness: Brightness.dark,
          statusBarBrightness: Brightness.light,
        ),
        backgroundColor: Colors.transparent,
        scrolledUnderElevation: 0,
        title: Text(widget.title),
        actions: <Widget>[
          if (_cameraControls.isNotEmpty)
            IconButton(
              onPressed: () => _showControlsPanel(),
              icon: const Icon(Icons.tune),
              tooltip: 'Camera controls',
            ),
          IconButton(
            onPressed: _loadingDevices
                ? null
                : () => unawaited(_refreshDevices()),
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
      body: Stack(
        children: <Widget>[
          Column(
            children: <Widget>[
              Expanded(
                flex: 3,
                child: Stack(
                  children: <Widget>[
                    Container(
                      width: double.infinity,
                      color: Colors.black,
                      alignment: Alignment.center,
                      child: _previewFrozen && _previewImage != null
                          ? RawImage(image: _previewImage, fit: BoxFit.contain)
                          : !_hasLivePreview
                          ? const Text(
                              'No preview',
                              style: TextStyle(
                                color: Colors.white70,
                                fontSize: 18,
                              ),
                            )
                          : _previewAspectRatio == null
                          ? Texture(
                              textureId: _previewTextureId!,
                              filterQuality: FilterQuality.none,
                            )
                          : Center(
                              child: AspectRatio(
                                aspectRatio: _previewAspectRatio!,
                                child: Texture(
                                  textureId: _previewTextureId!,
                                  filterQuality: FilterQuality.none,
                                ),
                              ),
                            ),
                    ),
                    if (_focusValueVisible && _focusAbsControl != null)
                      Positioned(
                        left: 0,
                        right: 0,
                        top: 16,
                        child: Center(
                          child: AnimatedOpacity(
                            opacity: _focusValueVisible ? 1 : 0,
                            duration: const Duration(milliseconds: 300),
                            child: Container(
                              padding: const EdgeInsets.symmetric(
                                horizontal: 16,
                                vertical: 6,
                              ),
                              decoration: BoxDecoration(
                                color: Colors.black54,
                                borderRadius: BorderRadius.circular(20),
                              ),
                              child: Text(
                                'Focus: ${_focusAbsControl!.cur}',
                                style: const TextStyle(
                                  color: Colors.white,
                                  fontSize: 14,
                                ),
                              ),
                            ),
                          ),
                        ),
                      ),
                    if (_hasLivePreview || _previewFrozen)
                      Positioned(
                        top: 16,
                        right: 16,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 6,
                          ),
                          decoration: BoxDecoration(
                            color: Colors.black54,
                            borderRadius: BorderRadius.circular(20),
                          ),
                          child: Text(
                            '${_previewFps.toStringAsFixed(0)} fps',
                            style: const TextStyle(
                              color: Colors.white,
                              fontSize: 14,
                              fontWeight: FontWeight.w600,
                            ),
                          ),
                        ),
                      ),
                    if (_recording)
                      Positioned(
                        top: 16,
                        left: 16,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 6,
                          ),
                          decoration: BoxDecoration(
                            color: Colors.red.withValues(alpha: 0.85),
                            borderRadius: BorderRadius.circular(20),
                          ),
                          child: const Row(
                            mainAxisSize: MainAxisSize.min,
                            children: <Widget>[
                              Icon(
                                Icons.fiber_manual_record,
                                color: Colors.white,
                                size: 14,
                              ),
                              SizedBox(width: 4),
                              Text(
                                'REC',
                                style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 14,
                                  fontWeight: FontWeight.w600,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    Positioned(
                      left: 0,
                      right: 0,
                      bottom: 16,
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: <Widget>[
                          FilledButton(
                            onPressed: _savingPhoto
                                ? null
                                : _previewFrozen
                                ? () => unawaited(_resumePreview())
                                : !_hasLivePreview
                                ? null
                                : () => unawaited(_capturePhoto()),
                            style: FilledButton.styleFrom(
                              backgroundColor: Colors.white.withValues(
                                alpha: 0.85,
                              ),
                              foregroundColor: Colors.black87,
                              minimumSize: const Size(44, 44),
                              padding: const EdgeInsets.all(10),
                              shape: const CircleBorder(),
                            ),
                            child: Tooltip(
                              message: _savingPhoto
                                  ? 'Saving'
                                  : _previewFrozen
                                  ? 'Resume preview'
                                  : 'Capture',
                              child: _savingPhoto
                                  ? const SizedBox(
                                      width: 20,
                                      height: 20,
                                      child: CircularProgressIndicator(
                                        strokeWidth: 2,
                                      ),
                                    )
                                  : Icon(
                                      _previewFrozen
                                          ? Icons.play_arrow
                                          : Icons.camera_alt,
                                      size: 24,
                                    ),
                            ),
                          ),
                          if (_platformSupportsRecording) ...<Widget>[
                            const SizedBox(width: 16),
                            FilledButton(
                              onPressed: !_hasLivePreview || _previewFrozen
                                  ? null
                                  : () => unawaited(_toggleRecording()),
                              style: FilledButton.styleFrom(
                                backgroundColor: _recording
                                    ? Colors.red.withValues(alpha: 0.85)
                                    : Colors.white.withValues(alpha: 0.85),
                                foregroundColor: _recording
                                    ? Colors.white
                                    : Colors.red,
                                minimumSize: const Size(44, 44),
                                padding: const EdgeInsets.all(10),
                                shape: const CircleBorder(),
                              ),
                              child: Tooltip(
                                message: _recording
                                    ? 'Stop recording'
                                    : 'Record video',
                                child: Icon(
                                  _recording
                                      ? Icons.stop
                                      : Icons.fiber_manual_record,
                                  size: 24,
                                ),
                              ),
                            ),
                          ],
                        ],
                      ),
                    ),
                    if (_hasLivePreview)
                      Positioned(
                        left: 12,
                        bottom: 16,
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            AnimatedSize(
                              duration: const Duration(milliseconds: 200),
                              curve: Curves.easeInOut,
                              alignment: Alignment.bottomLeft,
                              child: _transformControlsExpanded
                                  ? Column(
                                      mainAxisSize: MainAxisSize.min,
                                      crossAxisAlignment:
                                          CrossAxisAlignment.start,
                                      children: <Widget>[
                                        _TransformIconButton(
                                          icon: Icons.rotate_90_degrees_cw,
                                          tooltip: 'Rotate 90° CW',
                                          active: false,
                                          onTap: () {
                                            _camera.rotatePreviewClockwise();
                                            _safeSetState(() {});
                                          },
                                        ),
                                        const SizedBox(height: 8),
                                        _TransformIconButton(
                                          icon: Icons.flip,
                                          tooltip: 'Flip horizontal',
                                          active: _camera
                                              .previewTransform
                                              .flipHorizontal,
                                          onTap: () {
                                            _camera
                                                .togglePreviewFlipHorizontal();
                                            _safeSetState(() {});
                                          },
                                        ),
                                        const SizedBox(height: 8),
                                        _TransformIconButton(
                                          icon: Icons.flip,
                                          iconAngle: 90,
                                          tooltip: 'Flip vertical',
                                          active: _camera
                                              .previewTransform
                                              .flipVertical,
                                          onTap: () {
                                            _camera.togglePreviewFlipVertical();
                                            _safeSetState(() {});
                                          },
                                        ),
                                        const SizedBox(height: 8),
                                      ],
                                    )
                                  : const SizedBox.shrink(),
                            ),
                            _TransformIconButton(
                              icon: Icons.screen_rotation,
                              tooltip: _transformControlsExpanded
                                  ? 'Close transform controls'
                                  : 'Transform controls',
                              active:
                                  _transformControlsExpanded ||
                                  _camera.previewTransform !=
                                      UvcPreviewTransform.identity,
                              onTap: () => _safeSetState(
                                () => _transformControlsExpanded =
                                    !_transformControlsExpanded,
                              ),
                            ),
                          ],
                        ),
                      ),
                    if (_focusAbsControl != null)
                      Positioned(
                        right: 12,
                        bottom: 16,
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          crossAxisAlignment: CrossAxisAlignment.end,
                          children: <Widget>[
                            if (_hasFocusAuto)
                              Padding(
                                padding: const EdgeInsets.only(bottom: 8),
                                child: FilledButton.icon(
                                  onPressed: _afTriggering
                                      ? null
                                      : () => unawaited(_triggerOneShutAF()),
                                  style: FilledButton.styleFrom(
                                    backgroundColor: Colors.black54,
                                  ),
                                  icon: _afTriggering
                                      ? const SizedBox(
                                          width: 16,
                                          height: 16,
                                          child: CircularProgressIndicator(
                                            strokeWidth: 2,
                                            color: Colors.white,
                                          ),
                                        )
                                      : const Icon(Icons.center_focus_strong),
                                  label: const Text('AF'),
                                ),
                              ),
                            Padding(
                              padding: EdgeInsets.only(
                                bottom: _manualFocusControlsVisible ? 8 : 0,
                              ),
                              child: FilledButton.icon(
                                onPressed: () =>
                                    unawaited(_toggleManualFocusControls()),
                                style: FilledButton.styleFrom(
                                  backgroundColor: Colors.black54,
                                ),
                                icon: Icon(
                                  _manualFocusControlsVisible
                                      ? Icons.expand_more
                                      : Icons.tune,
                                ),
                                label: Text(
                                  _manualFocusControlsVisible
                                      ? 'Hide focus'
                                      : 'Manual focus',
                                ),
                              ),
                            ),
                            if (_manualFocusControlsVisible)
                              Row(
                                mainAxisSize: MainAxisSize.min,
                                children: <Widget>[
                                  FocusButton(
                                    icon: Icons.remove,
                                    onPressStart: () => _startFocusRepeat(-1),
                                    onPressEnd: _stopFocusRepeat,
                                  ),
                                  const SizedBox(width: 8),
                                  FocusButton(
                                    icon: Icons.add,
                                    onPressStart: () => _startFocusRepeat(1),
                                    onPressEnd: _stopFocusRepeat,
                                  ),
                                ],
                              ),
                          ],
                        ),
                      ),
                  ],
                ),
              ),
              Expanded(
                flex: 2,
                child: LayoutBuilder(
                  builder: (BuildContext context, BoxConstraints constraints) {
                    return SingleChildScrollView(
                      padding: const EdgeInsets.only(bottom: 96),
                      child: ConstrainedBox(
                        constraints: BoxConstraints(
                          minHeight: constraints.maxHeight,
                        ),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            if (_status != null)
                              Padding(
                                padding: const EdgeInsets.all(12),
                                child: Text(
                                  _status!,
                                  style: const TextStyle(fontSize: 14),
                                ),
                              ),
                            if (_cameraModes.isNotEmpty)
                              SwitchListTile(
                                contentPadding: const EdgeInsets.symmetric(
                                  horizontal: 12,
                                ),
                                title: const Text('Save capture to gallery'),
                                subtitle: Platform.isAndroid
                                    ? null
                                    : Text(
                                        _desktopCaptureDirectory.path,
                                        maxLines: 1,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                value: _saveToGallery,
                                onChanged: (bool value) =>
                                    _safeSetState(() => _saveToGallery = value),
                              ),
                            if (_cameraModes.isNotEmpty)
                              SwitchListTile(
                                contentPadding: const EdgeInsets.symmetric(
                                  horizontal: 12,
                                ),
                                title: const Text('Auto-recover on stall'),
                                subtitle: const Text(
                                  'Watchdog restarts the preview if frame '
                                  'delivery silently stops',
                                ),
                                value: _stallAutoRecover,
                                onChanged: _setStallAutoRecover,
                              ),
                            if (_modeFormatNames.length > 1)
                              Padding(
                                padding: const EdgeInsets.fromLTRB(
                                  12,
                                  8,
                                  12,
                                  0,
                                ),
                                child: Wrap(
                                  spacing: 8,
                                  children: <Widget>[
                                    ChoiceChip(
                                      label: const Text('All'),
                                      selected: _modeFormatFilter == null,
                                      onSelected: (_) => _safeSetState(
                                        () => _modeFormatFilter = null,
                                      ),
                                    ),
                                    for (final String format
                                        in _modeFormatNames)
                                      ChoiceChip(
                                        label: Text(format),
                                        selected: _modeFormatFilter == format,
                                        onSelected: (_) => _safeSetState(
                                          () => _modeFormatFilter = format,
                                        ),
                                      ),
                                  ],
                                ),
                              ),
                            if (_cameraModes.isNotEmpty)
                              Padding(
                                padding: const EdgeInsets.fromLTRB(
                                  12,
                                  8,
                                  12,
                                  0,
                                ),
                                child: DropdownButton<UvcCameraMode>(
                                  isExpanded: true,
                                  // A mode outside the active format filter
                                  // stays running; the dropdown just shows the
                                  // hint until a filtered mode is picked.
                                  value:
                                      _filteredCameraModes.contains(
                                        _selectedMode,
                                      )
                                      ? _selectedMode
                                      : null,
                                  hint: const Text('Select preview mode'),
                                  items: _filteredCameraModes
                                      .map(
                                        (UvcCameraMode mode) =>
                                            DropdownMenuItem<UvcCameraMode>(
                                              value: mode,
                                              child: Text(mode.label),
                                            ),
                                      )
                                      .toList(),
                                  onChanged: _openingDevice
                                      ? null
                                      : (UvcCameraMode? mode) {
                                          if (mode == null) {
                                            return;
                                          }
                                          unawaited(_switchMode(mode));
                                        },
                                ),
                              ),
                            if (_selectedDevice != null &&
                                (_selectedMode != null ||
                                    _streamStats.elapsed > Duration.zero))
                              StreamStatsCard(stats: _streamStats),
                            if (_loadingDevices)
                              const Padding(
                                padding: EdgeInsets.all(24),
                                child: Center(
                                  child: CircularProgressIndicator(),
                                ),
                              )
                            else
                              ListView.separated(
                                shrinkWrap: true,
                                physics: const NeverScrollableScrollPhysics(),
                                itemCount: _devices.length,
                                separatorBuilder:
                                    (BuildContext context, int index) =>
                                        const Divider(height: 1),
                                itemBuilder: (BuildContext context, int index) {
                                  final UvcUsbDevice device = _devices[index];
                                  final bool selected =
                                      _selectedDevice?.deviceId ==
                                      device.deviceId;
                                  final bool openElsewhere = _isOpenElsewhere(
                                    device,
                                  );
                                  return Container(
                                    decoration: BoxDecoration(
                                      color: selected
                                          ? brandGreenLight
                                          : Colors.white,
                                      border: Border.all(
                                        color: selected
                                            ? brandGreenBorder
                                            : surfaceNeutralBorder,
                                      ),
                                      borderRadius: BorderRadius.circular(12),
                                    ),
                                    margin: const EdgeInsets.symmetric(
                                      horizontal: 12,
                                      vertical: 6,
                                    ),
                                    padding: const EdgeInsets.symmetric(
                                      horizontal: 16,
                                      vertical: 12,
                                    ),
                                    child: Column(
                                      crossAxisAlignment:
                                          CrossAxisAlignment.start,
                                      children: <Widget>[
                                        Text(
                                          device.displayName,
                                          style: Theme.of(
                                            context,
                                          ).textTheme.titleMedium,
                                        ),
                                        const SizedBox(height: 4),
                                        Text(
                                          openElsewhere
                                              ? '${device.details}\nOpen in another camera slot'
                                              : device.details,
                                          style: Theme.of(
                                            context,
                                          ).textTheme.bodyMedium,
                                        ),
                                        const SizedBox(height: 12),
                                        _openingDevice && selected
                                            ? const SizedBox(
                                                width: 20,
                                                height: 20,
                                                child:
                                                    CircularProgressIndicator(
                                                      strokeWidth: 2,
                                                    ),
                                              )
                                            : Row(
                                                mainAxisSize: MainAxisSize.min,
                                                children: <Widget>[
                                                  ElevatedButton(
                                                    onPressed:
                                                        _openingDevice ||
                                                            openElsewhere
                                                        ? null
                                                        : () => unawaited(
                                                            _openSelectedDevice(
                                                              device,
                                                            ),
                                                          ),
                                                    child: Text(
                                                      selected
                                                          ? 'Reconnect'
                                                          : 'Open',
                                                    ),
                                                  ),
                                                  if (selected) ...<Widget>[
                                                    const SizedBox(width: 8),
                                                    ElevatedButton(
                                                      onPressed: _openingDevice
                                                          ? null
                                                          : () => unawaited(
                                                              _disconnectSelectedDevice(),
                                                            ),
                                                      child: const Text(
                                                        'Disconnect',
                                                      ),
                                                    ),
                                                  ],
                                                ],
                                              ),
                                      ],
                                    ),
                                  );
                                },
                              ),
                          ],
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _TransformIconButton extends StatelessWidget {
  const _TransformIconButton({
    required this.icon,
    required this.tooltip,
    required this.active,
    required this.onTap,
    this.iconAngle = 0,
  });

  final IconData icon;
  final String tooltip;
  final bool active;
  final VoidCallback onTap;

  /// Rotation in degrees applied to the icon (0 or 90).
  final double iconAngle;

  @override
  Widget build(BuildContext context) {
    return Tooltip(
      message: tooltip,
      child: GestureDetector(
        onTap: onTap,
        child: Material(
          color: active ? Colors.white.withValues(alpha: 0.9) : Colors.black54,
          shape: const CircleBorder(),
          child: Padding(
            padding: const EdgeInsets.all(10),
            child: Transform.rotate(
              angle: iconAngle * 3.141592653589793 / 180,
              child: Icon(
                icon,
                color: active ? Colors.black87 : Colors.white,
                size: 22,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
5
likes
160
points
1.48k
downloads

Documentation

API reference

Publisher

verified publishercornpip.dev

Weekly Downloads

Control USB(UVC) cameras. Preview, capture, record, adjust settings, and read raw frames, for one camera or several.

Repository (GitHub)
View/report issues
Contributing

Topics

#camera #usb #uvc #webcam

License

BSD-3-Clause (license)

Dependencies

ffi, flutter

More

Packages that depend on flutter_ffi_uvc

Packages that implement flutter_ffi_uvc