You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter相机录制时实时切换摄像头功能实现求助

录制中实时切换摄像头并合并视频的解决方案

核心思路是录制多段视频片段,切换摄像头时无缝衔接录制,最后合并所有片段为完整视频——原生camera库不支持录制中直接切换摄像头,必须通过分段录制+后期合并实现。

步骤1:添加依赖

在pubspec.yaml中添加视频合并所需的FFmpeg库:

dependencies:
  camera: ^0.10.5+5
  ffmpeg_kit_flutter: ^5.1.0
  lottie: ^2.6.0
  video_player: ^2.8.6
  flutter_svg: ^2.0.9

步骤2:修改完整代码

import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:ffmpeg_kit_flutter/ffmpeg_kit.dart';
import 'package:path_provider/path_provider.dart';

class CameraaPreview extends StatefulWidget {
  final List<CameraDescription> cameras;
  final bool permissionStatus;
  const CameraaPreview({
    super.key,
    required this.cameras,
    required this.permissionStatus,
  });

  @override
  State<CameraaPreview> createState() => _CameraaPreviewState();
}

class _CameraaPreviewState extends State<CameraaPreview>
    with SingleTickerProviderStateMixin {
  late CameraController _cameraController;
  late AnimationController _lottieController;
  Future<void>? _initializeFutureController;
  VideoPlayerController? _controller;
  XFile? image;
  File? _finalVideo;
  bool _havePic = false;
  bool _haveVideo = false;
  bool _isRecording = false;
  bool _flashOpen = false;
  late Timer _timer;
  bool isBackCameraOpen = true;
  int _recordedSeconds = 0;
  List<File> _recordedSegments = []; // 保存所有录制片段

  @override
  void initState() {
    _initializeCameraController();
    _lottieController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
    super.initState();
  }

  @override
  void dispose() {
    _cameraController.dispose();
    _controller?.dispose();
    _lottieController.dispose();
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final Size size = MediaQuery.of(context).size;
    const double navBarHeight = kBottomNavigationBarHeight;
    final avatarSize = size.width * 0.0999;
    final avataPosition = (navBarHeight - avatarSize) / 0.35;

    return _havePic
        ? _showImage(image!)
        : _haveVideo
            ? _showVideo(_finalVideo!)
            : FutureBuilder(
                future: _initializeFutureController,
                builder: (context, snapshot) {
                  return Stack(
                    children: <Widget>[
                      if (_cameraController.value.isInitialized)
                        AspectRatio(
                          aspectRatio: 1.09 / _cameraController.value.aspectRatio,
                          child: CameraPreview(_cameraController),
                        ),
                      // 录制按钮
                      Positioned(
                        bottom: avataPosition / 9,
                        left: size.width / 2.9 - avatarSize / 2,
                        child: GestureDetector(
                          onLongPressStart: (_) {
                            _lottieController.forward();
                            _lottieController.repeat(
                              reverse: true,
                              period: const Duration(seconds: 1),
                            );
                            _startRecording();
                          },
                          onLongPressEnd: (_) async {
                            _lottieController.stop();
                            _lottieController.reset();
                            await _stopAndMergeRecording();
                          },
                          onTap: () => _clickPicture(),
                          child: Lottie.asset(
                            "assets/recordingButton.json",
                            controller: _lottieController,
                            height: 155,
                            width: 155,
                            fit: BoxFit.cover,
                          ),
                        ),
                      ),
                      // 摄像头切换按钮
                      Positioned(
                        top: 65,
                        right: size.width * 0.05,
                        child: GestureDetector(
                          onTap: () => _toggleCamera(),
                          child: SvgPicture.asset(
                            "assets/camerarotate.svg",
                            color: Colors.white,
                            height: 25,
                          ),
                        ),
                      ),
                      if (_isRecording)
                        Positioned(
                          top: 20,
                          left: size.width / 2.25,
                          child: Text(
                            '$_recordedSeconds s',
                            style: const TextStyle(
                              color: Colors.red,
                              fontSize: 24,
                            ),
                          ),
                        ),
                    ],
                  );
                },
              );
  }

  void _clickPicture() async {
    if (_cameraController.value.isInitialized) {
      await _cameraController.setFlashMode(
        _flashOpen ? FlashMode.always : FlashMode.off,
      );
      image = await _cameraController.takePicture();
      setState(() => _havePic = true);
    }
  }

  void _startRecording() async {
    if (_cameraController.value.isInitialized && !_isRecording) {
      await _cameraController.setFlashMode(
        _flashOpen ? FlashMode.always : FlashMode.off,
      );
      await _cameraController.startVideoRecording();
      _timer = Timer.periodic(const Duration(seconds: 1), (_) {
        setState(() => _recordedSeconds += 1);
      });
      setState(() => _isRecording = true);
    }
  }

  Future<void> _stopCurrentSegment() async {
    if (_cameraController.value.isRecordingVideo) {
      _timer.cancel();
      final segment = await _cameraController.stopVideoRecording();
      _recordedSegments.add(File(segment.path));
      setState(() => _isRecording = false);
    }
  }

  Future<void> _toggleCamera() async {
    if (_isRecording) {
      // 录制中切换:先停止当前片段,切换摄像头后立即开始录制
      await _stopCurrentSegment();
      await _switchCamera();
      _startRecording();
    } else {
      // 非录制状态直接切换
      await _switchCamera();
    }
  }

  Future<void> _switchCamera() async {
    await _cameraController.dispose();
    final newCamera = isBackCameraOpen ? widget.cameras[1] : widget.cameras[0];
    _cameraController = CameraController(
      newCamera,
      ResolutionPreset.ultraHigh,
      enableAudio: true,
      imageFormatGroup: ImageFormatGroup.jpeg,
    );
    setState(() {
      _initializeFutureController = _cameraController.initialize();
      isBackCameraOpen = !isBackCameraOpen;
    });
    await _initializeFutureController;
  }

  Future<void> _stopAndMergeRecording() async {
    if (_isRecording) {
      await _stopCurrentSegment();
    }

    if (_recordedSegments.isEmpty) return;

    // 生成合并后的视频文件路径
    final tempDir = await getTemporaryDirectory();
    _finalVideo = File('${tempDir.path}/final_video.mp4');

    // 生成FFmpeg命令:合并多个视频片段
    final segmentListPath = '${tempDir.path}/segments.txt';
    final segmentFile = File(segmentListPath);
    final content = _recordedSegments
        .map((file) => "file '${file.path}'")
        .join('\n');
    await segmentFile.writeAsString(content);

    // 执行FFmpeg合并命令
    await FFmpegKit.execute(
      "-f concat -safe 0 -i $segmentListPath -c copy ${_finalVideo!.path}",
    );

    // 初始化播放控制器
    _controller = VideoPlayerController.file(_finalVideo!)
      ..initialize().then((_) {
        setState(() => _haveVideo = true);
        _controller?.setLooping(true);
        _controller?.play();
      });

    // 清理临时片段文件
    for (var file in _recordedSegments) {
      await file.delete();
    }
    await segmentFile.delete();
    _recordedSegments.clear();
    _recordedSeconds = 0;
  }

  Widget _showImage(XFile image) {
    return Stack(
      children: [
        SizedBox.expand(
          child: Image.file(
            File(image.path),
            fit: BoxFit.cover,
          ),
        ),
        Positioned(
          top: 55,
          left: size.width * 0.05,
          child: GestureDetector(
            onTap: () => setState(() => _havePic = false),
            child: SvgPicture.asset(
              "assets/cancel.svg",
              color: Colors.white,
              height: 25,
            ),
          ),
        ),
      ],
    );
  }

  Future<void> _initializeCameraController() async {
    _cameraController = CameraController(
      widget.cameras[0],
      ResolutionPreset.ultraHigh,
      enableAudio: true,
      imageFormatGroup: ImageFormatGroup.jpeg,
    );
    setState(() {
      _initializeFutureController = _cameraController.initialize();
    });
  }

  Widget _showVideo(File video) {
    return Stack(
      children: [
        SizedBox.expand(
          child: VideoPlayer(_controller!),
        ),
        Positioned(
          top: 55,
          left: size.width * 0.05,
          child: GestureDetector(
            onTap: () {
              setState(() => _haveVideo = false);
              _controller?.pause();
            },
            child: SvgPicture.asset(
              "assets/cancel.svg",
              color: Colors.white,
              height: 25,
            ),
          ),
        ),
      ],
    );
  }
}

关键改动说明

  1. 分段录制管理:新增_recordedSegments列表保存每次切换摄像头前后的视频片段,切换时先停止当前片段录制,再切换摄像头并启动新录制。
  2. 摄像头切换逻辑:重写_toggleCamera方法,区分录制中/非录制状态的切换逻辑,保证录制无缝衔接。
  3. 视频合并:使用FFmpeg将多个片段合并为单个视频,通过生成片段列表文件的方式执行合并命令,最后清理临时文件。
  4. 状态修正:调整了变量命名、播放控制器初始化逻辑,避免空指针异常。

内容的提问来源于stack exchange,提问作者RIZWAN ALI

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 14:37:35