Flutter 视频播放器封装
01、扩展的功能介绍
flutter 官方推荐的视频插件 video_player 是非常简陋的,除了能播放视频,其它啥功能都没有。为了满足需求,在 video_player 的基础上进行封装,封装的功能包括:
- 顶部视频标题和其它功能按钮
- 双击暂停、播放视频
- 长按快进
- 水平滑动快进或快退
- 左右垂直滑动调整视频亮度、音量
- 视频全屏
包含了播放器的一些常见功能,其它功能可以根据需求自行添加。
02、封装后的UI效果
可以看到,封装后的效果还是很ok的,UI可以根据自己的喜欢进行调整。
03、用到的插件
- video_player 视频播放器
- auto_orientation 屏幕旋转插件
- screen_brightness 软件亮度调整
- provider 状态管理
播放器就选择官方推荐的 video_player 就ok了、屏幕旋转可以使用插件,也可以使用flutter提供的api、亮度调整推荐使用 screen_brightness 插件,可以调整软件整体的亮度或者调整系统的亮度(需要配置相关权限)、状态管理我使用的是 provider ,你也可以根据自己习惯,选择其它状态管理插件。这些插件使用都很简单,可以直接看官方文档或者查一下资料,代码中稍微提一下,不做详细介绍。
04、项目启动的一些问题及处理办法
1、如果你是直接克隆的项目,是无法直接运行的,因为项目的 gradle 是用的本地。
2、auto_orientation 插件运行时可能会出现下方错误:
解决办法:在 build.gradle 中添加如下代码
添加的代码,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 会出错
解决办法:我是手动调整到 8.2.1 版本,在运行就没问题了。
到这里,准备工作就完成了,接下来就是代码部分了。
05、组件结构
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:23或02:25格式volumeIcon、brightnessIcon根据亮度和音量的大小返回特定图标applicationBrightness、setApplicationBrightness、resetApplicationBrightness设置应用亮度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、底部控制栏
- 直接看代码部分
- 底部控制栏布局
- 左右两个时间调用函数,分别把 视频当前播放时长 和 视频总时长传入即可得到
- 全屏功能见下方
// 底部控制条 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