dual_cameras_recorder 0.1.0 copy "dual_cameras_recorder: ^0.1.0" to clipboard
dual_cameras_recorder: ^0.1.0 copied to clipboard

Record the front and back cameras at the same time, composited live on the GPU into a single portrait video and photo, with a real-time preview. Android + iOS.

example/lib/main.dart

import 'package:dual_cameras_recorder/dual_cameras_recorder.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gal/gal.dart';
import 'package:permission_handler/permission_handler.dart';

void main() => runApp(const DemoApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'dual_cameras',
      theme: ThemeData.dark(useMaterial3: true),
      home: const DemoScreen(),
    );
  }
}

class DemoScreen extends StatefulWidget {
  const DemoScreen({super.key});

  @override
  State<DemoScreen> createState() => _DemoScreenState();
}

class _DemoScreenState extends State<DemoScreen> {
  final DualCameraController _controller = DualCameraController();
  CameraCapabilities? _caps;
  String _status = 'Probing…';
  String? _lastFile;
  bool _circle = false;

  // Live geometry tuning (debug channel) — to dial in the front/back
  // orientation and fix any stretch directly on the device.
  static const MethodChannel _debug = MethodChannel('dual_cameras/debug');
  int _frontOffset = 90; // matches FRONT_ROTATION_OFFSET default
  int _backOffset = -90; // matches BACK_ROTATION_OFFSET default
  bool _mirrorFront = true;
  double _frontAspect = 0; // 0 = auto (use camera-reported)
  double _backAspect = 0;

  LayoutConfig _layout() => DualLayout.pictureInPicture(
        insetScale: 0.32,
        margin: 16,
        circleInset: _circle,
      );

  @override
  void initState() {
    super.initState();
    _bootstrap();
  }

  Future<void> _bootstrap() async {
    final caps = await DualCameraController.probeSupport();
    if (!mounted) return;
    setState(() {
      _caps = caps;
      _status = caps.isSupported
          ? 'Supported (up to ${caps.maxWidth}×${caps.maxHeight}) — grant permissions to start'
          : 'Not supported on this device (${caps.reason?.name ?? 'unknown'})';
    });
  }

  Future<void> _start() async {
    final granted = await [Permission.camera, Permission.microphone].request();
    if (granted.values.any((s) => !s.isGranted)) {
      setState(() => _status = 'Camera/microphone permission denied');
      return;
    }
    try {
      await _controller.initialize(layout: _layout());
      setState(() => _status = 'Initialized');
    } catch (e) {
      setState(() => _status = 'initialize failed: $e');
    }
  }

  Future<void> _guard(Future<void> Function() action, String label) async {
    try {
      await action();
      setState(() => _status = '$label ok');
    } catch (e) {
      setState(() => _status = '$label failed: $e');
    }
  }

  int _norm(int deg) => ((deg % 360) + 360) % 360;

  Future<void> _setRotation(bool front, int offset) async {
    final norm = _norm(offset);
    setState(() => front ? _frontOffset = norm : _backOffset = norm);
    await _debug.invokeMethod('setRotationOffset', {'front': front, 'offset': norm});
  }

  Future<void> _setAspect(bool front, double aspect) async {
    setState(() => front ? _frontAspect = aspect : _backAspect = aspect);
    await _debug.invokeMethod('setAspectOverride', {'front': front, 'aspect': aspect});
  }

  Future<void> _setMirror(bool on) async {
    setState(() => _mirrorFront = on);
    await _debug.invokeMethod('setMirrorFront', {'on': on});
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Widget _rotationRow(String label, bool front, int offset) {
    return Row(
      children: [
        SizedBox(width: 110, child: Text(label)),
        IconButton(
          icon: const Icon(Icons.rotate_left),
          onPressed: () => _setRotation(front, offset - 90),
        ),
        SizedBox(
          width: 44,
          child: Text('$offset°', textAlign: TextAlign.center),
        ),
        IconButton(
          icon: const Icon(Icons.rotate_right),
          onPressed: () => _setRotation(front, offset + 90),
        ),
        const SizedBox(width: 4),
        for (final d in const [0, 90, 180, 270])
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 2),
            child: ChoiceChip(
              label: Text('$d'),
              selected: offset == d,
              onSelected: (_) => _setRotation(front, d),
            ),
          ),
      ],
    );
  }

  Widget _aspectRow(String label, bool front, double aspect) {
    return Row(
      children: [
        SizedBox(width: 110, child: Text(label)),
        Expanded(
          child: Slider(
            min: 0,
            max: 2,
            divisions: 40,
            label: aspect == 0 ? 'auto' : aspect.toStringAsFixed(2),
            value: aspect,
            onChanged: (v) => _setAspect(front, v),
          ),
        ),
        SizedBox(
          width: 52,
          child: Text(
            aspect == 0 ? 'auto' : aspect.toStringAsFixed(2),
            textAlign: TextAlign.right,
          ),
        ),
      ],
    );
  }

  Widget _debugPanel() {
    return Theme(
      data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
      child: ExpansionTile(
        title: const Text('Debug tuning'),
        tilePadding: EdgeInsets.zero,
        childrenPadding: const EdgeInsets.only(bottom: 8),
        children: [
          _rotationRow('Front rot', true, _frontOffset),
          _rotationRow('Back rot', false, _backOffset),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            dense: true,
            title: const Text('Mirror front'),
            value: _mirrorFront,
            onChanged: _setMirror,
          ),
          _aspectRow('Front aspect', true, _frontAspect),
          _aspectRow('Back aspect', false, _backAspect),
          Align(
            alignment: Alignment.centerLeft,
            child: TextButton(
              onPressed: () {
                _setAspect(true, 0);
                _setAspect(false, 0);
              },
              child: const Text('Reset aspect → auto'),
            ),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('dual_cameras')),
      body: ValueListenableBuilder<DualCameraValue>(
        valueListenable: _controller,
        builder: (context, value, _) {
          return Column(
            children: [
              Expanded(
                child: Stack(
                  fit: StackFit.expand,
                  children: [
                    ColoredBox(
                      color: Colors.black,
                      child: DualCameraPreview(
                        _controller,
                        placeholder: const Center(
                          child: Text('Live preview appears on a real device'),
                        ),
                      ),
                    ),
                    Positioned(
                      top: 8,
                      left: 8,
                      child: DualCameraStatsOverlay(_controller),
                    ),
                  ],
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(12),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    Text(_status, textAlign: TextAlign.center),
                    if (value.thermal != ThermalLevel.nominal)
                      Text('Thermal: ${value.thermal.name}',
                          style: const TextStyle(color: Colors.orange)),
                    if (_lastFile != null) Text('Saved: $_lastFile'),
                    const SizedBox(height: 8),
                    Wrap(
                      alignment: WrapAlignment.center,
                      spacing: 8,
                      runSpacing: 8,
                      children: [
                        FilledButton(
                          onPressed: (_caps?.isSupported ?? false) &&
                                  !value.isInitialized
                              ? _start
                              : null,
                          child: const Text('Initialize'),
                        ),
                        FilledButton(
                          onPressed: value.isInitialized && !value.isRecording
                              ? () =>
                                  _guard(_controller.startRecording, 'record')
                              : null,
                          child: const Text('Record'),
                        ),
                        FilledButton(
                          onPressed: value.isRecording
                              ? () => _guard(() async {
                                    _lastFile =
                                        await _controller.stopRecording();
                                    await Gal.putVideo(_lastFile!);
                                  }, 'stop → gallery')
                              : null,
                          child: const Text('Stop'),
                        ),
                        FilledButton(
                          onPressed: value.isInitialized
                              ? () => _guard(() async {
                                    _lastFile = await _controller.takePhoto();
                                    await Gal.putImage(_lastFile!);
                                  }, 'photo → gallery')
                              : null,
                          child: const Text('Photo'),
                        ),
                        FilledButton(
                          onPressed: value.isInitialized
                              ? () => _guard(_controller.swapPrimary, 'swap')
                              : null,
                          child: const Text('Swap'),
                        ),
                        FilledButton(
                          onPressed: value.isInitialized
                              ? () => _guard(() async {
                                    setState(() => _circle = !_circle);
                                    await _controller.setLayout(_layout());
                                  }, _circle ? 'circle off' : 'circle on')
                              : null,
                          child: Text(_circle ? 'Square' : 'Circle'),
                        ),
                      ],
                    ),
                    if (value.isInitialized) _debugPanel(),
                  ],
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}
0
likes
140
points
0
downloads
screenshot

Documentation

API reference

Publisher

verified publisherdualcameras.com

Weekly Downloads

Record the front and back cameras at the same time, composited live on the GPU into a single portrait video and photo, with a real-time preview. Android + iOS.

Topics

#camera #video #recording #dual-camera #multicam

License

MIT (license)

Dependencies

dual_cameras_android, dual_cameras_ios, dual_cameras_platform_interface, flutter

More

Packages that depend on dual_cameras_recorder

Packages that implement dual_cameras_recorder