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

PlatformAndroid

Flutter UVC camera plugin using FFI (libuvc) — live preview, frame access, and device control

example/lib/main.dart

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

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 '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',
      theme: buildExampleTheme(),
      home: UvcPreviewPage(),
    );
  }
}

class UvcPreviewPage extends StatefulWidget {
  UvcPreviewPage({super.key, UvcCamera? camera}) : camera = camera ?? uvcCamera;

  final UvcCamera camera;

  @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;

  List<UvcUsbDevice> _devices = const <UvcUsbDevice>[];
  List<UvcCameraMode> _cameraModes = const <UvcCameraMode>[];
  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 _saveToGallery = false;
  bool _transformControlsExpanded = false;
  bool _manualFocusControlsVisible = false;
  StreamSubscription<UvcStreamError>? _streamErrorSub;
  Timer? _focusRepeatTimer;
  Timer? _focusValueHideTimer;
  bool _focusValueVisible = false;
  String? _status;
  String? _lastSnackBarErrorKey;
  DateTime? _lastSnackBarErrorAt;
  double _previewFps = 0;
  int _lastPreviewSequence = 0;
  DateTime? _lastPreviewSequenceSampleAt;
  UvcStreamStats _streamStats = const UvcStreamStats.zero();

  @override
  void initState() {
    super.initState();
    _camera.setLogLevel(UvcLogLevel.debug);
    WidgetsBinding.instance.addObserver(this);
    _streamErrorSub = _camera.streamErrors.listen(_onStreamError);
    unawaited(_initializePermissionsAndDevices());
  }

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

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _streamErrorSub?.cancel();
    _previewStatsTimer?.cancel();
    _focusRepeatTimer?.cancel();
    _focusValueHideTimer?.cancel();
    _previewImage?.dispose();
    unawaited(_stopCurrentPreview());
    unawaited(_disposePreviewTexture());
    unawaited(_camera.closeUsbDevice());
    super.dispose();
  }

  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');
    setState(() {
      _loadingDevices = true;
      _status = null;
    });

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

      setState(() {
        _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 {
    _setStatus('Opening device...', openingDevice: true);
    _log('Open device requested: ${device.displayName}');

    _previewImage?.dispose();
    _previewImage = null;

    try {
      await _ensurePreviewTexture();
      await _stopCurrentPreview();
      await _camera.closeUsbDevice();
      final int openResult = await _camera.openUsbDevice(device.deviceId);
      if (openResult != 0) {
        throw Exception('uvc_open_fd failed: ${_camera.lastError}');
      }

      final List<UvcCameraMode> libuvcModes = _camera.supportedModes();
      final List<UvcCameraControl> controls = _camera.supportedControls();
      _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();
      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,
      );
      final List<UvcCameraMode> autoProbeModes = _selectAutoProbeModes(
        libuvcModes,
      );
      UvcCameraMode? startedMode;
      UvcPreviewStartResult? lastProbeResult;
      for (int i = 0; i < autoProbeModes.length; i += 1) {
        final UvcCameraMode candidate = autoProbeModes[i];
        _setStatus(
          'Opening device... Probing mode ${i + 1}/${autoProbeModes.length}: ${candidate.label}',
          openingDevice: true,
        );
        final UvcPreviewStartResult probeResult = await _startPreview(candidate);
        if (probeResult.success) {
          startedMode = candidate;
          break;
        }
        lastProbeResult = probeResult;
      }

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

      setState(() {
        _selectedDevice = device;
        _cameraModes = libuvcModes;
        _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();
      setState(() {
        _selectedDevice = null;
        _selectedMode = null;
        _cameraModes = const <UvcCameraMode>[];
        _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;
    }

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

  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;
  }

  List<UvcCameraMode> _selectAutoProbeModes(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 a.fps.compareTo(b.fps);
    });
    return sorted.take(3).toList();
  }

  String _startFailureMessage(UvcPreviewStartResult result) {
    final String? error = result.lastError;
    if (error != null && error.isNotEmpty) {
      return error;
    }
    return 'No valid frame sequence was observed for this mode within ${_startupProbeTimeout.inSeconds}s.';
  }

  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 DateTime now = DateTime.now();
    final DateTime? 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.difference(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;
    }
    setState(() {
      _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;
    }
    setState(() {
      _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) {
      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 = DateTime.now();
      _previewStatsTimer = Timer.periodic(
        _fpsSampleInterval,
        (_) => _samplePreviewFps(),
      );
      return result;
    }
    _previewStatsTimer?.cancel();
    _previewStatsTimer = null;
    return result;
  }

  Future<ui.Image> _decodeRgbaFrame(UvcPreviewFrame frame) {
    return _decodeRgbaFrameWithDescriptor(frame);
  }

  Future<ui.Image> _decodeRgbaFrameWithDescriptor(UvcPreviewFrame frame) async {
    final ui.ImmutableBuffer buffer = await ui.ImmutableBuffer.fromUint8List(
      frame.rgbaBytes,
    );
    final ui.ImageDescriptor descriptor = ui.ImageDescriptor.raw(
      buffer,
      width: frame.width,
      height: frame.height,
      pixelFormat: ui.PixelFormat.rgba8888,
      rowBytes: frame.width * 4,
    );
    final ui.Codec codec = await descriptor.instantiateCodec();
    try {
      final ui.FrameInfo frameInfo = await codec.getNextFrame();
      return frameInfo.image;
    } finally {
      codec.dispose();
      descriptor.dispose();
      buffer.dispose();
    }
  }

  Future<void> _stopCurrentPreview({bool clearPreviewImage = false}) async {
    _previewStatsTimer?.cancel();
    _previewStatsTimer = null;
    _resetPreviewFps();

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

    _camera.stopPreview();
  }

  void _setStatus(
    String status, {
    bool? loadingDevices,
    bool? openingDevice,
    Object? error,
  }) {
    _log(status, error: error);
    setState(() {
      _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;
    _camera.setControl(ctrl.id, next);
    _focusValueHideTimer?.cancel();
    _focusValueHideTimer = Timer(const Duration(seconds: 2), () {
      if (mounted) setState(() => _focusValueVisible = false);
    });
    setState(() {
      _focusValueVisible = true;
      _cameraControls = _cameraControls
          .map(
            (UvcCameraControl c) =>
                c.name == 'focus_abs' ? c.copyWithCur(next) : c,
          )
          .toList();
    });
  }

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

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

    final int? currentValue = _camera.getControl(ctrl.id);
    if (currentValue != null) {
      setState(() {
        _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) setState(() => _focusValueVisible = false);
      });
      return;
    }

    setState(() {
      _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 {
    setState(() => _afTriggering = true);
    try {
      _camera.setControl(UvcControlId.focusAuto, 1);
      await Future<void>.delayed(const Duration(milliseconds: 600));
      _camera.setControl(UvcControlId.focusAuto, 0);
    } finally {
      if (mounted) setState(() => _afTriggering = false);
    }
  }

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

    setState(() => _savingPhoto = true);
    ui.Image? capturedImage;
    try {
      final UvcPreviewFrame? frame = _camera.copyLatestFrameTransformed(
        _camera.previewTransform,
      );
      if (frame == null) {
        throw Exception('No preview frame available to capture.');
      }
      capturedImage = await _decodeRgbaFrame(frame);
      final ByteData? pngData = await capturedImage.toByteData(
        format: ui.ImageByteFormat.png,
      );
      if (pngData == null) {
        throw Exception('Failed to encode PNG from the current preview frame.');
      }

      final Uint8List pngBytes = pngData.buffer.asUint8List();
      if (_saveToGallery) {
        final String timestamp = DateTime.now()
            .toIso8601String()
            .replaceAll(':', '-')
            .replaceAll('.', '-');
        final String? savedUri = await _androidBridge.saveImageToGallery(
          pngBytes,
          displayName: 'uvc_capture_$timestamp.png',
        );
        _setStatus(
          savedUri == null || savedUri.isEmpty
              ? 'Saved capture to gallery.'
              : 'Saved capture to gallery: $savedUri',
        );
      }
      await _stopCurrentPreview();
      final ui.Image? previousImage = _previewImage;
      if (mounted) {
        setState(() {
          _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) {
        setState(() => _savingPhoto = false);
      } else {
        _savingPhoto = false;
      }
    }
  }

  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;
    }

    setState(() {
      _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) {
            final int result = _camera.setControl(id, value);
            if (result == 0) {
              setState(() {
                _cameraControls = _cameraControls
                    .map(
                      (UvcCameraControl c) =>
                          c.id == id ? c.copyWithCur(value) : c,
                    )
                    .toList();
              });
            } else {
              _log(
                'setControl failed id=${id.nativeValue} value=$value err=$result',
              );
            }
          },
          onReset: () {
            for (final UvcCameraControl ctrl in _cameraControls) {
              if (ctrl.id == UvcControlId.focusAbs ||
                  ctrl.id == UvcControlId.focusAuto ||
                  ctrl.id == UvcControlId.focusSimple) {
                continue;
              }
              _camera.setControl(ctrl.id, ctrl.def);
            }
            final List<UvcCameraControl> refreshed = _camera
                .supportedControls();
            setState(() {
              _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 DateTime now = DateTime.now();
    final String errorKey = _normaliseStreamErrorKey(error.message);
    final bool isRepeatedMessage = _lastSnackBarErrorKey == errorKey;
    final bool withinCooldown =
        _lastSnackBarErrorAt != null &&
        now.difference(_lastSnackBarErrorAt!) < _streamErrorSnackbarCooldown;

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

    _lastSnackBarErrorKey = errorKey;
    _lastSnackBarErrorAt = now;
    setState(() {
      _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: const Text('UVC Camera Preview'),
        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,
                            ),
                          ),
                        ),
                      ),
                    Positioned(
                      left: 0,
                      right: 0,
                      bottom: 16,
                      child: Center(
                        child: 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 (_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();
                                            setState(() {});
                                          },
                                        ),
                                        const SizedBox(height: 8),
                                        _TransformIconButton(
                                          icon: Icons.flip,
                                          tooltip: 'Flip horizontal',
                                          active: _camera
                                              .previewTransform.flipHorizontal,
                                          onTap: () {
                                            _camera
                                                .togglePreviewFlipHorizontal();
                                            setState(() {});
                                          },
                                        ),
                                        const SizedBox(height: 8),
                                        _TransformIconButton(
                                          icon: Icons.flip,
                                          iconAngle: 90,
                                          tooltip: 'Flip vertical',
                                          active: _camera
                                              .previewTransform.flipVertical,
                                          onTap: () {
                                            _camera.togglePreviewFlipVertical();
                                            setState(() {});
                                          },
                                        ),
                                        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: () => setState(
                                () => _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'),
                                value: _saveToGallery,
                                onChanged: (bool value) =>
                                    setState(() => _saveToGallery = value),
                              ),
                            if (_cameraModes.isNotEmpty)
                              Padding(
                                padding: const EdgeInsets.fromLTRB(
                                  12,
                                  8,
                                  12,
                                  0,
                                ),
                                child: DropdownButton<UvcCameraMode>(
                                  isExpanded: true,
                                  value: _selectedMode,
                                  hint: const Text('Select preview mode'),
                                  items: _cameraModes
                                      .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;
                                  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(
                                          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
                                                        ? 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,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
814
downloads

Documentation

API reference

Publisher

verified publishercornpip.dev

Weekly Downloads

Flutter UVC camera plugin using FFI (libuvc) — live preview, frame access, and device control

Repository (GitHub)
View/report issues

Topics

#camera #usb #uvc #ffi

License

BSD-3-Clause (license)

Dependencies

ffi, flutter

More

Packages that depend on flutter_ffi_uvc

Packages that implement flutter_ffi_uvc