Flutter 视频播放器UI封装

2,828 阅读8分钟

Flutter 视频播放器封装

01、扩展的功能介绍

flutter 官方推荐的视频插件 video_player 是非常简陋的,除了能播放视频,其它啥功能都没有。为了满足需求,在 video_player 的基础上进行封装,封装的功能包括:

  1. 顶部视频标题和其它功能按钮
  2. 双击暂停、播放视频
  3. 长按快进
  4. 水平滑动快进或快退
  5. 左右垂直滑动调整视频亮度、音量
  6. 视频全屏

包含了播放器的一些常见功能,其它功能可以根据需求自行添加。

02、封装后的UI效果

image.png image.png image.png image.png

可以看到,封装后的效果还是很ok的,UI可以根据自己的喜欢进行调整。

03、用到的插件

  1. video_player 视频播放器
  2. auto_orientation 屏幕旋转插件
  3. screen_brightness 软件亮度调整
  4. provider 状态管理

播放器就选择官方推荐的 video_player 就ok了、屏幕旋转可以使用插件,也可以使用flutter提供的api、亮度调整推荐使用 screen_brightness 插件,可以调整软件整体的亮度或者调整系统的亮度(需要配置相关权限)、状态管理我使用的是 provider ,你也可以根据自己习惯,选择其它状态管理插件。这些插件使用都很简单,可以直接看官方文档或者查一下资料,代码中稍微提一下,不做详细介绍。

04、项目启动的一些问题及处理办法

1、如果你是直接克隆的项目,是无法直接运行的,因为项目的 gradle 是用的本地。

image.png

2、auto_orientation 插件运行时可能会出现下方错误:

namespace.png

解决办法:在 build.gradle 中添加如下代码

image.png

添加的代码,Android 打包问题我也看不懂,都是网上查的解决办法

//  set-namespace for information about setting the namespace.
subprojects {
    afterEvaluate { project ->
        if (project.hasProperty('android')) {
            project.android {
                if (namespace == null) {
                    namespace project.group
                }
            }
        }
    }
}

3、可能会出现以下问题,AGP版本低于 8.2.1 会出错

Gradle.png

解决办法:我是手动调整到 8.2.1 版本,在运行就没问题了。

image.png

到这里,准备工作就完成了,接下来就是代码部分了。

05、组件结构

image.png

z_video
    assembly        -- 小组件
        zsn_bar.dart      -- 音量和亮度控制条
        zsn_header.dart   -- 顶部标题栏
        zsn_pause.dart    -- 暂停播放图标
        zsn_progess.dart  -- 进度条
    zsn_class.dart        -- 一些基础属性和方法
    zsn_fullScreen.dart   -- 全屏组件
    zsn_providers.dart    -- 状态管理

06、视频初始化和切换

  • 初始化视频,获得必要参数和添加监听器
  • 判断是否初始化过,初始化过释放和恢复相应设置
  • 初始化完成获取必要参数(视频总时长)、添加监听器实时获取进度
  void initializeVideo(String videoUrl) async {
    try {
      // 判断是否已经初始化过
      if (zProvider.videoInitialized) {
        _videoController.dispose();
        _videoController.removeListener(() {}); // 移除监听器
        zProvider.videoInitialized = false;
      }

      _videoController = VideoPlayerController.networkUrl(Uri.parse(videoUrl));
      // 获取重定向后的视频链接
      // final redirectedUrl = await getRedirectedVideoUrl(videoUrl);
      // _videoController =
      //     VideoPlayerController.networkUrl(Uri.parse(redirectedUrl));
      _videoController.setVolume(zProvider.currentVolume);
      await _videoController.initialize();
      await _videoController.play();
      await _videoController.setLooping(true);

      _videoController.setVolume(zProvider.currentVolume);
      zProvider.setPause(false);
      zProvider.resetState();
      zProvider.setVideoDuration(_videoController.value.duration);
      zProvider.videoInitialized = true;

      // 实时监听视频播放进度
      _videoController.addListener(() {
        // 调整视频进度时,没有预加载的视频会重新加载,如何监听视频重新加载和加载完成
        if (_videoController.value.isBuffering) {
          zProvider.setBuffering(true);
        } else if (_videoController.value.isInitialized) {
          zProvider.setBuffering(false);
        }
        zProvider.setVideoCurrentDuration(_videoController.value.position);
      });
    } catch (e) {
      print('视频初始化失败: $e');
      zProvider.setErrorMessage('Failed to load video!'); // 设置错误信息
    }
  }

  • 外部切换视频时重新加载视频
  @override
  void didUpdateWidget(ZsnVideo oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 比较旧的 videoUrl 和新的 videoUrl
    if (oldWidget.videoUrl != widget.videoUrl) {
      zProvider.errorMessage = null;
      zProvider.videoDuration = const Duration();
      zProvider.videoCurrentDuration = const Duration();
      // 重新初始化视频
      initializeVideo(widget.videoUrl);
    }
  }

  @override
  void dispose() {
    _videoController.dispose();
    _videoController.removeListener(() {}); // 移除监听器
    ZsnClass().resetApplicationBrightness(); // 重置应用亮度
    super.dispose();
    if (timer != null) timer!.cancel();
  }

07、provider参数 及 provider 使用

7.1、参数

  • 用到的所有参数如下,方法列举了一部分,基本都是修改数据,没有啥难懂的
  • 继承自 ChangeNotifier
  • 数据变化时使用 notifyListeners() 通知
import 'package:flutter/material.dart';

class ZsnProviders extends ChangeNotifier {
  bool videoInitialized = false; // 视频是否初始化
  bool pause = false; // 是否暂停
  bool isBuffering = false; // 是否缓冲
  bool showUI = false; // 是否显示UI
  Duration videoDuration = const Duration(); // 视频总时长
  Duration videoCurrentDuration = const Duration(); // 视频当前播放时长
  double second = 0; // 快退 快进的秒数
  Duration beforeProgress = const Duration(); // 快退 快进前的进度
  Duration afterProgress = const Duration(); // 快退 快进后的进度
  double currentVolume = 1.0; // 当前音量
  bool volumeUI = false; // 音量控制UI
  double currentBrightness = 1.0; // 当前亮度
  bool brightnessUI = false; // 亮度控制UI
  bool isLock = false; // 是否锁定
  String? errorMessage; // 添加错误信息属性

  // 数据重置
  ZsnProviders() {
    resetState();
  }
  void resetState() {
    isLock = false;
    showUI = false;
    errorMessage = null;
    notifyListeners();
  }
  
  void changePause() {
    pause = !pause;
    notifyListeners();
  }

  void setPause(bool isPause) {
    pause = isPause;
    notifyListeners();
  }
  ···
  其它方法
  ···
}

7.2、注入

  • 首先在 main.dart 中注入 provider
  • 可以同时注入多个 provider
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:videodemo/home.dart';
import 'package:videodemo/http/dio_instance.dart';
import 'package:videodemo/z_video/zsn_providers.dart';

void main() {
  // dio初始化
  DioInstance.instance().initDio(baseUrl: '');
  // 注入 provider
  runApp(MultiProvider(
      providers: [ChangeNotifierProvider(create: (contex) => ZsnProviders())],
      child: const MyApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: Home(),
    );
  }
}

7.3、使用

1、结合 Consumer 就能直接使用了

  @override
  Widget build(BuildContext context) {
    return Consumer<ZsnProviders>(
      builder: (context, provider, child) {
        return Text(provider.参数名);
  ···

2、定义一个全局的参数,方便在函数中调用。在 didChangeDependencies中赋予初始值,这样才能安全的拿到 context,在需要使用的地方 zProvider.参数名 或者 zProvider.方法名()

class _VideoBaseState extends State<ZsnVideo> {
  late VideoPlayerController _videoController;

  Timer? timer; // 创建个延时器
  ZsnProviders zProvider = ZsnProviders(); // 获取provider
  ···
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    zProvider = Provider.of<ZsnProviders>(context, listen: false);
  }
  ···

08、zsn_class 公用方法

  • formatDuration 把 Duration 时间转换成 02:25:2302:25 格式
  • volumeIconbrightnessIcon根据亮度和音量的大小返回特定图标
  • applicationBrightnesssetApplicationBrightnessresetApplicationBrightness 设置应用亮度
  • calculateTopPosition 计算图标的位置
import 'package:flutter/material.dart';
import 'package:screen_brightness/screen_brightness.dart';

class ZsnClass {
  Color baseColor = const Color.fromARGB(255, 0, 110, 255);
  ZsnClass();
  get baseColor1 => baseColor;
  // 计算时分秒
  String formatDuration(Duration duration) {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
    String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
    return duration.inHours > 0
        ? "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"
        : "$twoDigitMinutes:$twoDigitSeconds";
  }

  Icon volumeIcon(double volume) {
    if (volume == 0) {
      return Icon(Icons.volume_off, color: Colors.grey);
    } else if (volume < 0.5) {
      return Icon(Icons.volume_down, color: Colors.blue);
    } else {
      return Icon(Icons.volume_up, color: Colors.blue);
    }
  }

  Icon brightnessIcon(double brightness) {
    if (brightness == 0) {
      return Icon(Icons.brightness_low, color: Colors.grey);
    } else if (brightness < 0.5) {
      return Icon(Icons.brightness_medium, color: Colors.blue);
    } else {
      return Icon(Icons.brightness_high, color: Colors.blue);
    }
  }

  // 获取应用亮度
  Future<double> get applicationBrightness async {
    try {
      return await ScreenBrightness.instance.application;
    } catch (e) {
      throw 'Failed to get application brightness';
    }
  }

  // 设置应用亮度
  Future<void> setApplicationBrightness(double brightness) async {
    try {
      await ScreenBrightness.instance
          .setApplicationScreenBrightness(brightness);
    } catch (e) {
      debugPrint(e.toString());
      throw 'Failed to set application brightness';
    }
  }

  // 重置应用亮度
  Future<void> resetApplicationBrightness() async {
    try {
      await ScreenBrightness.instance.resetApplicationScreenBrightness();
    } catch (e) {
      debugPrint(e.toString());
      throw 'Failed to reset application brightness';
    }
  }

  // 计算锁屏UI、亮度UI、音量UI的位置
  // top = 元素高度的一半  - 自身高度的一半(默认为按钮高度的一半)
  double calculateTopPosition(BuildContext context, {double height = 48.0}) {
    final screenHeight = MediaQuery.of(context).size.height;
    return (screenHeight / 2) - (height / 2);
  }
}

基本函数、基本参数、基本用法介绍完毕。

09、zsn_header 顶部标题栏

  • 使用时传入参数判断是否处于全屏状态下,全屏状态下 padding 设置稍微大一点,布局更美观
  • 给一个从上到下渐变的背景色 UI更美观
  • 左侧返回按钮
  • 右侧预览更多按钮,需要其它功能可自行扩展

过于简单,代码就不贴了,可自行查看,不然文章太长。

10、zsn_pause 暂停图标

  • 根据视频是否暂停切换图标

同上

11、zsn_bar 音量和亮度控制条

  • 整体长度 100
  • 整体逆时针旋转 90 度,图标在顺时针旋转 90 度,使得UI布局达到效果图
  • 默认显示 音量图标,isVolume 为false时显示亮度图标
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:videodemo/z_video/zsn_class.dart';

class ZsnBar extends StatelessWidget {
  final double value;
  final double height;
  final bool isVolume; // 默认音量图标
  const ZsnBar({
    super.key,
    required this.value,
    required this.height,
    this.isVolume = true,
  });
  @override
  Widget build(BuildContext context) {
    return Positioned(
      right: 0,
      top: (height - 30) / 2,
      height: 30,
      width: 100,
      child: Transform.rotate(
        angle: -pi / 2,
        child: Row(
          children: [
            Transform.rotate(
              angle: pi / 2,
              child: isVolume
                  ? ZsnClass().volumeIcon(value)
                  : ZsnClass().brightnessIcon(value),
            ),
            SizedBox(
              width: 3,
              height: 3,
            ),
            Expanded(
              child: LinearProgressIndicator(
                backgroundColor: Colors.grey[200],
                valueColor: AlwaysStoppedAnimation(Colors.blue),
                value: value,
              ),
            )
          ],
        ),
      ),
    );
  }
}

12、zsn_progess 视频进度条

  • 我用的是video自带的视频进度条,有需要可自行封装
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:videodemo/z_video/zsn_class.dart';


class ZsnProgess extends StatelessWidget {
  final VideoPlayerController controller;
  const ZsnProgess({super.key, required this.controller});

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: VideoProgressIndicator(
        controller,
        allowScrubbing: true,
        padding: EdgeInsets.all(5),
        colors: VideoProgressColors(
          backgroundColor: Colors.white,
          playedColor: ZsnClass().baseColor,
          bufferedColor: Colors.grey[500]!,
        ),
      ),
    );
  }
}

13、底部控制栏

  • 直接看代码部分
  • 底部控制栏布局
  • 左右两个时间调用函数,分别把 视频当前播放时长 和 视频总时长传入即可得到
  • 全屏功能见下方
image.png
 // 底部控制条 UI
 if (provider.showUI && !provider.isLock)
   Positioned(
       bottom: 0,
       left: 0,
       child: Container(
         padding: EdgeInsets.all(5),
         width: MediaQuery.of(context).size.width,
         // ignore: deprecated_member_use
         color: Colors.black.withOpacity(0.1),
         child: Row(
             mainAxisAlignment: MainAxisAlignment.spaceBetween,
             // 上下居中
             crossAxisAlignment: CrossAxisAlignment.center,
             children: [
               //  播放暂停按钮
               ZsnPause(
                   pause: provider.pause, playVideo: playVideo),
               // 播放进度
               Text(
                 ZsnClass().formatDuration(
                     provider.videoCurrentDuration),
                 style: TextStyle(color: Colors.white),
               ),
               SizedBox(
                 width: 10,
               ),
               // 进度条
               ZsnProgess(controller: _videoController),
               SizedBox(
                 width: 10,
               ),
               // 播放进度
               Text(
                 ZsnClass()
                     .formatDuration(provider.videoDuration),
                 style: TextStyle(color: Colors.white),
               ),
               // 全屏按钮
               IconButton(
                 icon: Icon(
                   Icons.fullscreen,
                   color: Colors.white,
                 ),
                 onPressed: () {
                   // 跳转全屏
                   Navigator.push(
                       context,
                       MaterialPageRoute(
                           builder: (context) => ZsnFullscreen(
                                 title: widget.title,
                                 providers: provider,
                                 controller: _videoController,
                                 pauseEvent: pauseEvent,
                                 onTap: onTap,
                                 onDoubleTap: onDoubleTap,
                                 onLongPress: onLongPress,
                                 playVideo: playVideo,
                                 onVerticalDragUpdate:
                                     onVerticalDragUpdate,
                                 onVerticalDragEnd:
                                     onVerticalDragEnd,
                                 onHorizontalDragUpdate:
                                     onHorizontalDragUpdate,
                                 onHorizontalDragEnd:
                                     onHorizontalDragEnd,
                                 onLongPressEnd: onLongPressEnd,
                               )));
                 },
               ),
             ]),
       )),

14、锁屏按钮、暂停居中的UI、缓存中UI

  • 这些UI显示都差不多,根据参数决定是否需要渲染
  • 可以去代码中查看,都有对应备注
  • 聪明如你,肯定能看懂,我就懒得一个一个的介绍了
   // 锁屏按钮
   if (provider.showUI)
     Positioned(
         top: widget.height / 2 - 25,
         left: 10,
         child: IconButton(
             style: ButtonStyle(
               backgroundColor: WidgetStateProperty.all(
                   const Color.fromARGB(68, 158, 158, 158)),
             ),
             onPressed: () {
               provider.changeLock();
             },
             icon: Icon(
               provider.isLock ? Icons.lock : Icons.lock_open,
               color: Colors.white,
               size: 25,
             ))),

15、手势处理(核心功能)

  • 核心功能都在手势处理里面,上面基本上所有的功能都依托手势处理
  • 先介绍一下基础函数,然后在依次介绍手势处理

基础事件

  • 初始化上方已经介绍过
  • zProvider.changePause() 切换状态的
  • 调用的函数都是上方封装好的,可前往原代码处查看
  // 播放暂停
  void playVideo() {
    zProvider.changePause();
    if (zProvider.pause) {
      _videoController.pause();
    } else {
      _videoController.play();
    }
  }

  // 控制亮度(模拟)
  void _controlBrightness(double delta) {
    double newBrightness = zProvider.currentBrightness + delta;
    newBrightness = newBrightness.clamp(0.0, 1.0); // 亮度限制在 0-1
    zProvider.setBrightness(newBrightness);
    ZsnClass().setApplicationBrightness(newBrightness);
  }

  // 控制音量
  void _controlVolume(double delta) {
    double newVolume = zProvider.currentVolume + delta;
    newVolume = newVolume.clamp(0.0, 1.0); // 音量限制在 0-1
    zProvider.setVolume(newVolume);
    _videoController.setVolume(newVolume);
  }

onTap 点击事件

  • 切换UI显示与隐藏
  • 通过延时器控制自动隐藏
  void onTap() {
    // 点击时显示或隐藏控制栏
    zProvider.changeShowUI();
    timer?.cancel();
    timer = Timer(Duration(milliseconds: 5000), () {
      zProvider.setShowUI(false);
    });
  }

onDoubleTap 双击事件

  • 控制视频播放与暂停
  void onDoubleTap() {
    // 双击时暂停或播放视频
    zProvider.changePause();
    if (zProvider.pause) {
      _videoController.pause();
    } else {
      _videoController.play();
    }
  }

onLongPress 长按开始事件

  • 长按改变视频倍速
  • if (zProvider.isLock) return; 如果处于锁定状态,长按倍速不生效,下方同理
  // 长按开始
  void onLongPress() {
    if (zProvider.isLock) return;
    // 长按改变视频倍速
    _videoController.setPlaybackSpeed(3.0);
  }

onLongPressEnd 长按结束事件

  • 长按结束恢复视频倍速,默认 1
  void onLongPressEnd(details) {
    if (zProvider.isLock) return;
    // 长按结束时取消视频倍速改变
    _videoController.setPlaybackSpeed(1.0);
  }

onVerticalDragUpdate 垂直滑动开始事件

  • 垂直滑动开始事件,判断是在左侧还是右侧垂直滑动
  • 左侧改变亮度、右侧改变音量
  • 音量改变的是视频的音量,并非系统的音量,系统音量需要对应权限,可自行添加
  • UI显示
void onVerticalDragUpdate(details) {
    if (zProvider.isLock) return;
    // 判断是否是上下滑动
    if (details.delta.dy.abs() > details.delta.dx.abs()) {
      final double screenWidth = MediaQuery.of(context).size.width;
      double positionX = details.localPosition.dx;
      if (positionX < screenWidth / 2) {
        // 手势在左侧 控制视频亮度 保留小数点后一位
        zProvider.setBrightnessUI(true);
        _controlBrightness(-details.delta.dy / 100);
      } else {
        // 手势在右侧 控制视频音量
        zProvider.setVolumeUI(true);
        _controlVolume(-details.delta.dy / 100);
      }
    }
  }

onVerticalDragEnd 垂直滑动结束

  • 垂直滑动结束,需要将UI隐藏
  void onVerticalDragEnd(details) {
    if (zProvider.isLock) return;
    zProvider.setBrightnessUI(false);
    zProvider.setVolumeUI(false);
  }

onHorizontalDragUpdate 水平滑动开始

  • 记录滑动开始时视频的播放进度
  • 根据滑动距离设置快进秒数
  • UI是根据快进的秒数来判断是否需要隐藏的,只需要 归零就能实现隐藏UI
  void onHorizontalDragUpdate(details) {
    if (zProvider.isLock) return;
    if (details.delta.dx.abs() > details.delta.dy.abs()) {
      // 记录滑动开始时视频的播放进度
      if (zProvider.beforeProgress == Duration.zero) {
        zProvider.setBeforeProgress(zProvider.videoCurrentDuration);
      }
      // 根据滑动距离设置快进秒数
      zProvider.setSecond(details.delta.dx);
      zProvider.calculateAfterProgress();
    }
  }

onHorizontalDragEnd 水平滑动结束

  • 滑动开始时视频的播放进度 归零
  • 快进秒数 归零
  void onHorizontalDragEnd(details) {
    if (zProvider.isLock) return;
    _videoController.seekTo(zProvider.afterProgress);
    zProvider.setBeforeProgress(Duration.zero);
    zProvider.setAfterProgress(Duration.zero);
    zProvider.resetSecond();
  }

16、全屏处理

  • 全屏参考 guoshuyu.cn/home/wx/Flu… 文章
  • 结合 hero 动画,把video 控制器传给新的页面,在新页面做全屏处理
  • 其它布局和父元素一模一样就可以,copy一份代码即可,从父元素传入相应的事件和控制器
  • 详情见代码

初始化

  • 通过视频原始比例判断是否需要横屏
  • 隐藏系统状态栏
 bool isLand = false; // 是否横屏
 
 @override
  void initState() {
    super.initState();

    // 通过视频比例确定是否需要全屏
    if (widget.controller.value.aspectRatio < 1) {
      // 竖屏模式
      SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
      AutoOrientation.portraitUpMode();
    } else {
      // 横屏模式
      SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
      AutoOrientation.landscapeRightMode();
      isLand = true;
    }
  }

退出全屏(销毁)

  • 退出横屏
  • 显示系统状态栏
  @override
  void dispose() {
    if (isLand == true) {
      AutoOrientation.portraitUpMode();
    }
    // 恢复系统状态栏
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
        overlays: SystemUiOverlay.values);
    super.dispose();
  }

(/ω\),淦,终于写完了,写的不是很全,详情见代码。
代码地址: https://gitee.com/zsnoin-can/z_video