Flutter视频播放插件nexxplay的使用
Flutter视频播放插件nexxplay的使用
特性
Flutter插件作为Android nexxPLAY的包装器,结合示例应用提供的自定义代码,支持以下功能:
- 可用的nexxPLAY原生视图
- 动态配置支持
- 公共API,包括“准备和配置播放器”、“播放控制”、“请求播放器状态和详细信息”等方法
- 播放器事件观察
- 全屏模式
- PiP模式(仅限Android)
请注意,此插件不支持iOS。
集成指南
-
添加依赖 首先,将nexxPLAY依赖项添加到
pubspec.yaml
文件中。 -
Gradle准备 接下来,需要完成一些Gradle准备工作:
2.1. 排除
META-INF
和LICENSE
文件:packagingOptions { exclude 'META-INF/ASL2.0' exclude 'META-INF/LICENSE' exclude 'META-INF/LICENSE.txt' exclude 'LICENSE.txt' exclude 'META-INF/license.txt' exclude 'META-INF/NOTICE' exclude 'META-INF/DEPENDENCIES' exclude 'META-INF/NOTICE.txt' exclude 'META-INF/notice.txt' }
参见
example/android/app/build.gradle
文件以获取更多细节。2.2. 禁用混淆。可以通过排除混淆工具(
tv.nexx.*
)或完全禁用混淆来实现:minifyEnabled false shrinkResources false
参见
example/android/app/build.gradle
文件以获取更多细节。 -
Android原生配置 接下来,需要进行一些Android原生配置:
3.1. 在Android应用的主清单文件中,向
<application>
标签添加tools:replace="android:label"
。 参见example/android/app/src/main/AndroidManifest.xml
文件以获取更多细节。3.2. 在Android应用的主清单文件中,为使用的Activity添加
android:resizeableActivity="true"
和android:supportsPictureInPicture="true"
。 参见example/android/app/src/main/AndroidManifest.xml
文件以获取更多细节。3.3. 在Activity的代码中添加或覆盖
onUserLeaveHint
和onPictureInPictureModeChanged
方法:public class MainActivity extends FlutterActivity { // ... [@Override](/user/Override) public void onUserLeaveHint() { super.onUserLeaveHint(); NexxPlugin.post(OnUserLeaveHintEvent.create()); } [@Override](/user/Override) public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); NexxPlugin.post(OnPIPModeChangedEvent.create(isInPictureInPictureMode,newConfig)); } }
参见
example/android/app/src/main/java/tv/nexx/flutter/android_example/MainActivity.java
文件以获取更多细节。Kotlin版本:
class MainActivity : FlutterActivity() { override fun onUserLeaveHint() { super.onUserLeaveHint() NexxPlugin.post(OnUserLeaveHintEvent.create()) } override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) NexxPlugin.post(OnPIPModeChangedEvent.create(isInPictureInPictureMode,newConfig)) } }
-
继承AppCompat主题 接下来,继承AppCompat主题以满足Android原生应用部分的需求(即使你不使用ChromeCast):
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <item name="android:windowBackground">?android:colorBackground</item> </style>
确保你有AndroidX AppCompat依赖(直接或间接),例如:
implementation "androidx.appcompat:appcompat:1.4.0"
参见
example/android/app/build.gradle
,example/android/app/src/main/res/values/styles.xml
和example/android/app/src/main/res/values-night/styles.xml
文件以获取更多细节。 -
Kotlin Gradle插件(KGP) 使用较新的Android Gradle插件版本时,KGP会包含必要的androidx.*依赖。示例项目使用的是Kotlin 1.9.20和AGP 8.5,Gradle Wrapper版本为8.7。如果遇到类似
Fatal Exception: java.lang.RuntimeException: Unable to get provider androidx.startup.InitializationProvider: java.lang.ClassNotFoundException: Didn't find class "androidx.startup.InitializationProvider"
的错误提示,则可能需要更新Gradle环境。 -
最后一步 最后,从Flutter的角度进行一些操作以确保全屏和PiP支持。示例应用的
main.dart
文件包含了所有必要的文档。INTEGRATION_GUIDE
标记已经放置在文档中,以便于导航。
可选配置
共享MediaSessionCompat实例
7.1. 在build.gradle
的dependencies
块中,添加以下行:
implementation "androidx.media:media:1.6.0"
参见example/android/app/build.gradle
文件以获取更多细节。
7.2. 在宿主Activity类代码中,重写protected void onCreate([@Nullable](/user/Nullable) Bundle savedInstanceState)
方法:
protected void onCreate([@Nullable](/user/Nullable) Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ...其他配置代码
}
参见example/android/app/src/main/java/tv/nexx/flutter/android_example/MainActivity.java
文件以获取更多细节。
7.3. 在onCreate(Bundle)
方法中,添加以下代码:
final MediaSessionCompat mediaSession = new MediaSessionCompat(getApplicationContext(), getPackageName());
NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_MEDIA_SESSION, mediaSession);
或者,对于Kotlin:
val mediaSession = MediaSessionCompat(applicationContext, packageName)
NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_MEDIA_SESSION, mediaSession)
需要添加以下导入:
import android.support.v4.media.session.MediaSessionCompat;
对于Kotlin版本,省略结尾的分号。
广告支持
8.1. 在build.gradle
的dependencies
块中,添加以下行:
implementation "tv.nexx.android:admanager:1.0.05"
参见example/android/app/build.gradle
文件以获取更多细节。
8.2. 在宿主Activity类代码中,重写protected void onCreate([@Nullable](/user/Nullable) Bundle savedInstanceState)
方法:
protected void onCreate([@Nullable](/user/Nullable) Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ...其他配置代码
}
参见example/android/app/src/main/java/tv/nexx/flutter/android_example/MainActivity.java
文件以获取更多细节。
8.3. 在onCreate(Bundle)
方法中,添加以下代码:
NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_AD_MANAGER, NexxPlayAdManager::new);
或者,对于Kotlin:
NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_AD_MANAGER, ::NexxPlayAdManager)
需要添加以下导入:
import tv.nexx.android.admanager.NexxPlayAdManager;
对于Kotlin版本,省略结尾的分号。
Chromecast支持
9.1. 确保遵循下一章的原生集成指南中的步骤。
9.2. 应用程序的activity应扩展FlutterFragmentActivity
或其子类。
9.3. 当解析CastContext
实例时,需要应用一个修改,使CastContext
实例包含在插件的配置中:
CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor())
.addOnSuccessListener(castContext -> NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_CAST_CONTEXT, castContext))
.addOnFailureListener(exception -> Log.e("nexxPLAY", "Could not resolve CastContext", exception));
或者,对于Kotlin:
CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor())
.addOnSuccessListener { NexxPlayPlugin.addEnvironmentConfigurationEntry(NexxPlayPlugin.KEY_CAST_CONTEXT, it) }
.addOnFailureListener { Log.e("nexxPLAY", "Could not resolve CastContext", it) }
需要添加以下导入:
import com.google.android.gms.cast.framework.CastContext;
import java.util.concurrent.Executors;
import android.util.Log;
对于Kotlin版本,省略结尾的分号。
自定义通知图标
10.1. 通过原生钩子设置自定义通知图标。首先,将图像资源添加到Android资源包中。如果是PNG或JPG文件,只需将其放入/android/app/src/main/res/drawable
目录即可。
官方文档提供了更多信息。
10.2. 然后在配置中使用图像资源引用。NexxPlayPlugin
类包括addNativeConfigurationEntry
方法和静态的“key”值KEY_NOTIFICATION_ICON
;需要传递的值是图像资源引用——Android框架会自动创建这些变量名(例如,如果你的图像名为“my_image.png”并放在/res/drawable
目录下,那么Android将生成R.drawable.my_image
变量)。
正常情况下,该配置应在原生Android应用程序的MainActivity类的onCreate
方法中:
[@Override](/user/Override)
protected void onCreate([@Nullable](/user/Nullable) Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// ...其他配置代码
NexxPlayPlugin.addNativeConfigurationEntry(NexxPlayPlugin.KEY_NOTIFICATION_ICON, R.drawable.widget_icon);
}
或者,对于Kotlin:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...其他配置代码
NexxPlayPlugin.addNativeConfigurationEntry(NexxPlayPlugin.KEY_NOTIFICATION_ICON, R.drawable.widget_icon)
}
参见example
项目以获取完整的集成示例。
示例代码
以下是example/lib/main.dart
文件的部分代码:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:nexxplay/nexxplay.dart';
void main() => runApp(const NexxExampleApp());
class NexxExampleApp extends StatelessWidget {
const NexxExampleApp({Key? key}) : super(key: key);
[@override](/user/override)
Widget build(BuildContext context) {
return const MaterialApp(
title: 'nexxPLAY Flutter Testing',
home: NavigationPage(),
);
}
}
class NavigationPage extends StatelessWidget {
const NavigationPage({Key? key}) : super(key: key);
[@override](/user/override)
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Navigation page')),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const _NexxPlayPage()),
);
},
child: const Text('Launch player'),
),
),
);
}
}
class _NexxPlayPage extends StatefulWidget {
const _NexxPlayPage({Key? key}) : super(key: key);
[@override](/user/override)
_NexxPlayPageState createState() => _NexxPlayPageState();
}
class _NexxPlayPageState extends State<_NexxPlayPage> with AdHocVisitor<void> {
[@override](/user/override)
Widget build(BuildContext context) => _buildPage();
Widget _buildPage() {
return _mode.shouldExpand
? _buildFullscreenPlayerPage()
: _buildNonFullScreenPlayerPage();
}
Widget _buildFullscreenPlayerPage() {
return ScaffoldMessenger(
key: _messengerKey,
child: Scaffold(
body: _buildPlayer(),
),
);
}
Widget _buildNonFullScreenPlayerPage() {
return ScaffoldMessenger(
key: _messengerKey,
child: Scaffold(
appBar: AppBar(
title: const Text('nexxPLAY example app'),
actions: _controller == null
? []
: [
PopupMenuButton<String>(
onSelected: _handleOptionSelection,
itemBuilder: (_) => _optionMap.keys
.map((o) => PopupMenuItem<String>(value: o, child: Text(o)))
.toList(),
),
],
),
body: Center(child: _buildContent()),
),
);
}
void _handleOptionSelection(String option) => _optionMap[option]?.call(this);
Widget _buildContent() {
return Column(
children: [
Expanded(child: _buildPlayer()),
Expanded(child: _buildEventsList()),
],
);
}
Widget _buildPlayer() {
return NexxPlay(
key: _playerKey,
environment: _environment,
configuration: _configuration,
onControllerCreated: _startPlayer,
);
}
Widget _buildEventsList() {
return ListView(
children: _events.reversed
.map((e) => e.visit(const _WidgetPlayerEventVisitor()))
.toList(),
);
}
Future<void> _startPlayer(NexxPlayController controller) async {
try {
await controller.startPlay(
playMode: 'video',
mediaID: '#TODO',
configuration: _configuration,
);
if (!mounted) return;
_subscribe(controller);
_controller = controller;
} on Object catch (e, st) {
_report('Nexx: exception occurred during player start: \n$e\n$st');
}
}
void _report(String message) {
debugPrint(message);
_messengerKey.currentState?.showSnackBar(SnackBar(content: Text(message)));
}
void _subscribe(NexxPlayController controller) {
_subscription = controller.events().listen(
_consumeEvent,
onError: (Object e, StackTrace st) {
_report('Error occurred during events listening: $e');
debugPrintStack(
stackTrace: st,
label: 'Player events error stacktrace',
);
},
);
}
void _consumeEvent(PlayerEvent event) {
event.visit(this);
_events.add(event);
if (!_mode.shouldExpand) setState(() {});
}
[@override](/user/override)
void onPlayerEvent(DirectPlayerEvent event) {
final newMode = _modeTransformation[event.type]?.call(_mode);
if (newMode != null) setState(() => _mode = newMode);
}
[@override](/user/override)
void dispose() {
_dispose();
super.dispose();
}
Future<void> _dispose() async {
await _subscription?.cancel();
_subscription = null;
_controller?.dispose();
_controller = null;
}
NexxPlayController? _controller;
StreamSubscription<PlayerEvent>? _subscription;
_PlayerMode _mode = const _PlayerMode.initial();
final List<PlayerEvent> _events = [];
final _playerKey = GlobalKey<NexxPlayState>();
final _messengerKey = GlobalKey<ScaffoldMessengerState>();
final _optionMap = <String, void Function(_NexxPlayPageState)>{
'Clear Cache': (s) => s._controller?.clearCache(),
'Play': (s) => s._controller?.play(),
'Pause': (s) => s._controller?.pause(),
'Toggle': (s) => s._controller?.toggle(),
'Mute': (s) => s._controller?.mute(),
'Unmute': (s) => s._controller?.unmute(),
'Next': (s) => s._controller?.next(),
'Previous': (s) => s._controller?.previous(),
'Seek To 7.5 sec': (s) => s._controller?.seekTo(7.5),
'Seek By 5 sec': (s) => s._controller?.seekBy(5),
'Swap to position 1': (s) => s._controller?.swapToPosition(1),
'Get Current Media': (s) async {
final data = await s._controller?.getCurrentMedia();
debugPrint("Current Media: $data");
},
'Get Current Media Parent': (s) async {
final data = await s._controller?.getCurrentMediaParent();
debugPrint("Get Current Media Parent: $data");
},
'Get Audio Tracks': (s) async {
final data = await s._controller?.getAudioTracks();
debugPrint("Get Audio Tracks: $data");
},
'Get Connected Files': (s) async {
final data = await s._controller?.getConnectedFiles();
debugPrint("Get Connected Files: $data");
},
'Get Current Playback State': (s) async {
final data = await s._controller?.getCurrentPlaybackState();
debugPrint("Current Playback State: $data");
},
'Get Current Time': (s) async {
final data = await s._controller?.getCurrentTime();
debugPrint("Current Time: $data");
},
'Is Playing?': (s) async {
final data = await s._controller?.isPlaying();
debugPrint("Is Playing?: $data");
},
'Is Playing Ad?': (s) async {
final data = await s._controller?.isPlayingAd();
debugPrint("Is Playing Ad?: $data");
},
'Is Muted?': (s) async {
final data = await s._controller?.isMuted();
debugPrint("Is Muted?: $data");
},
'Is In PiP?': (s) async {
final data = await s._controller?.isInPiP();
debugPrint("Is In PiP?: $data");
},
'Is Casting?': (s) async {
final data = await s._controller?.isCasting();
debugPrint("Is Casting?: $data");
},
'List Local Media': (s) async {
// final media = await s._controller?.listLocalMedia('#TODO');
final media = await s._controller?.listLocalMedia();
debugPrint(media?.isEmpty ?? true
? 'Local Media: No media'
: 'Local Media: ${media!.join(", ")}');
},
'Clear Local Media': (s) {
// s._controller?.clearLocalMedia('#TODO');
s._controller?.clearLocalMedia();
},
'Disk Space Used For Local Media': (s) async {
final space = await s._controller?.diskSpaceUsedForLocalMedia();
debugPrint(space == null ? 'Space Used: Unknown' : 'Space used: $space');
}
// 'Update Configuration': (s) {
// s._controller?.updateConfiguration(key: '#TODO', value: '#TODO');
// },
// 'Update Environment': (s) {
// s._controller?.updateEnvironment(key: '#TODO', value: '#TODO');
// },
// 'Swap To Media Item': (s) {
// s._controller?.swapToMediaItem(
// mediaID: '#TODO', streamType: '#TODO', startPosition: 1, delay: 1);
// },
// 'Swap To Global ID': (s) {
// s._controller
// ?.swapToGlobalID(globalID: '#TODO', startPosition: 1, delay: 1);
// },
// 'Swap To Remote Media': (s) {
// s._controller
// ?.swapToRemoteMedia(reference: '#TODO', provider: '#TODO',
// delay: 1);
// },
// 'Start Downloading Local Media': (s) {
// s._controller?.startDownloadingLocalMedia(
// mediaID: '#TODO', streamType: '#TODO', provider: '#TODO');
// },
// 'Has Download Of Local Media': (s) async {
// final bool result = await s._controller?.hasDownloadOfLocalMedia(
// mediaID: '#TODO', streamType: '#TODO', provider: '#TODO') ?? false;
// debugPrint('Has Download of Local Media? $result');
// },
};
static final _modeTransformation = <NexxEventType, _PlayerMode Function(_PlayerMode)>{
NexxEventType.enterFullScreen: (mode) => mode.fullscreen(isEnabled: true),
NexxEventType.exitFullScreen: (mode) => mode.fullscreen(isEnabled: false),
NexxEventType.enterPIP: (mode) => mode.pip(isEnabled: true),
NexxEventType.exitPIP: (mode) => mode.pip(isEnabled: false),
};
static const _environment = NexxPlayEnvironment({
'domain': '#TODO',
'startFullscreen': 0,
});
static const _configuration = NexxPlayConfiguration({
'dataMode': 'API',
'exitMode': 'load',
'streamingFilter': '',
'adType': 'IMA',
'autoPlay': 0,
'autoNext': 1,
'disableAds': 1,
'hidePrevNext': 0,
'forcePrevNext': 0,
'startPosition': 0,
'delay': 0.0,
});
}
[@immutable](/user/immutable)
class _PlayerMode {
final bool isFullscreen;
final bool isInPIP;
bool get shouldExpand => isFullscreen || isInPIP;
const _PlayerMode({required this.isFullscreen, required this.isInPIP});
const _PlayerMode.initial() : this(isFullscreen: false, isInPIP: false);
_PlayerMode fullscreen({required bool isEnabled}) =>
_PlayerMode(isFullscreen: isEnabled, isInPIP: isInPIP);
_PlayerMode pip({required bool isEnabled}) =>
_PlayerMode(isFullscreen: isFullscreen, isInPIP: isEnabled);
[@override](/user/override)
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _PlayerMode &&
other.isFullscreen == isFullscreen &&
other.isInPIP == isInPIP;
}
[@override](/user/override)
int get hashCode => isFullscreen.hashCode ^ isInPIP.hashCode;
[@override](/user/override)
String toString() =>
'_PlayerMode(isFullscreen: $isFullscreen, isInPIP: $isInPIP)';
}
[@immutable](/user/immutable)
class _WidgetPlayerEventVisitor implements PlayerEventVisitor<Widget> {
const _WidgetPlayerEventVisitor();
[@override](/user/override)
Widget onPlayerEvent(DirectPlayerEvent event) {
return _DirectPlayerEventWidget(event: event);
}
[@override](/user/override)
Widget onPlayerStateChanged(PlayerStateChangeEvent event) {
return _PlayerStateEventWidget(event: event);
}
}
class _PlayerStateEventWidget extends StatelessWidget {
final PlayerStateChangeEvent event;
const _PlayerStateEventWidget({
required this.event,
Key? key,
}) : super(key: key);
[@override](/user/override)
Widget build(BuildContext context) {
return ListTile(
title: const Text('Player State Change'),
subtitle: Text('State: ${event.state}'),
);
}
[@override](/user/override)
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty('event', event));
}
}
class _DirectPlayerEventWidget extends StatelessWidget {
final DirectPlayerEvent event;
const _DirectPlayerEventWidget({
required this.event,
Key? key,
}) : super(key: key);
[@override](/user/override)
Widget build(BuildContext context) {
return ListTile(
title: const Text('Player Event'),
subtitle: Text(event.properties.entries
.map((e) => '${e.key}: ${e.value}')
.join(', ')),
);
}
[@override](/user/override)
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty('event', event));
}
}
更多关于Flutter视频播放插件nexxplay的使用的实战教程也可以访问 https://www.itying.com/category-92-b0.html
更多关于Flutter视频播放插件nexxplay的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
nexplay
是一个用于 Flutter 应用中的视频播放插件。它提供了丰富的功能,可以帮助你在应用中轻松集成视频播放功能。以下是如何在 Flutter 项目中使用 nexplay
插件的基本步骤。
1. 添加依赖
首先,你需要在 pubspec.yaml
文件中添加 nexplay
插件的依赖。
dependencies:
flutter:
sdk: flutter
nexplay: ^1.0.0 # 请检查最新版本
然后运行 flutter pub get
来获取依赖。
2. 导入插件
在需要使用 nexplay
的 Dart 文件中导入插件:
import 'package:nexplay/nexplay.dart';
3. 创建视频播放器
你可以使用 NexplayPlayer
来创建一个视频播放器。以下是一个简单的示例:
class VideoPlayerScreen extends StatefulWidget {
@override
_VideoPlayerScreenState createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
NexplayPlayerController _controller;
@override
void initState() {
super.initState();
_controller = NexplayPlayerController(
videoUrl: 'https://www.example.com/video.mp4', // 视频URL
playerConfig: NexplayPlayerConfig(
autoPlay: true, // 是否自动播放
looping: false, // 是否循环播放
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Video Player'),
),
body: Center(
child: NexplayPlayer(
controller: _controller,
),
),
);
}
}
4. 控制播放器
你可以使用 NexplayPlayerController
来控制视频的播放、暂停、跳转等操作。例如:
_controller.play(); // 播放视频
_controller.pause(); // 暂停视频
_controller.seekTo(Duration(seconds: 10)); // 跳转到指定时间
5. 处理事件
nexplay
插件还提供了事件监听功能,你可以监听视频的播放状态、缓冲状态等:
_controller.onPlayerStateChanged.listen((NexplayPlayerState state) {
if (state == NexplayPlayerState.PLAYING) {
print('Video is playing');
} else if (state == NexplayPlayerState.PAUSED) {
print('Video is paused');
}
});
_controller.onBufferingStateChanged.listen((bool isBuffering) {
if (isBuffering) {
print('Video is buffering');
} else {
print('Video is not buffering');
}
});
6. 自定义 UI
nexplay
允许你自定义播放器的 UI。你可以通过 NexplayPlayer
的 builder
参数来自定义播放器的布局:
NexplayPlayer(
controller: _controller,
builder: (BuildContext context, NexplayPlayerController controller) {
return Container(
color: Colors.black,
child: Stack(
children: [
controller.videoPlayer,
Positioned(
bottom: 10,
left: 10,
child: IconButton(
icon: Icon(
controller.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: () {
if (controller.isPlaying) {
controller.pause();
} else {
controller.play();
}
},
),
),
],
),
);
},
);
7. 处理全屏
nexplay
支持全屏播放。你可以通过 NexplayPlayerController
的 enterFullScreen
和 exitFullScreen
方法来控制全屏模式:
_controller.enterFullScreen(); // 进入全屏
_controller.exitFullScreen(); // 退出全屏
8. 处理错误
你还可以监听视频播放过程中的错误:
_controller.onError.listen((String error) {
print('Error occurred: $error');
});