Commit b56c6a299326a77855a887ed0fe1c63fa3b789c7

Authored by 吴启风
2 parents 413a50da dbca8811

Merge branch 'feature/android' into fix-build-wqf

Showing 54 changed files with 5797 additions and 754 deletions

Too many changes.

To preserve performance only 44 of 54 files are displayed.

android/app/build.gradle
@@ -29,7 +29,7 @@ apply from: "../archive_apk.gradle" @@ -29,7 +29,7 @@ apply from: "../archive_apk.gradle"
29 android { 29 android {
30 namespace "com.kouyuxingqiu.wow_english" 30 namespace "com.kouyuxingqiu.wow_english"
31 // compileSdkVersion flutter.compileSdkVersion 31 // compileSdkVersion flutter.compileSdkVersion
32 - compileSdkVersion 34 32 + compileSdkVersion 35
33 // 暂时没有ndk需求 33 // 暂时没有ndk需求
34 // ndkVersion flutter.ndkVersion 34 // ndkVersion flutter.ndkVersion
35 35
android/build.gradle
1 apply from: "/Users/stay/StudioProjects/ishowMavenRepositories.gradle" 1 apply from: "/Users/stay/StudioProjects/ishowMavenRepositories.gradle"
2 2
3 buildscript { 3 buildscript {
4 - ext.kotlin_version = '1.8.21' 4 + ext.kotlin_version = '1.8.22'
5 repositories { 5 repositories {
6 maven { url 'https://maven.aliyun.com/repository/google' } 6 maven { url 'https://maven.aliyun.com/repository/google' }
7 maven { url "https://maven.aliyun.com/repository/public" } 7 maven { url "https://maven.aliyun.com/repository/public" }
@@ -12,8 +12,7 @@ buildscript { @@ -12,8 +12,7 @@ buildscript {
12 } 12 }
13 13
14 dependencies { 14 dependencies {
15 - // AGP版本,目前自动创建的Gradle版本是用的7.5.0  
16 - classpath 'com.android.tools.build:gradle:7.3.0' 15 + classpath 'com.android.tools.build:gradle:8.1.0'
17 classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 16 classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
18 } 17 }
19 } 18 }
android/gradle/wrapper/gradle-wrapper.properties
@@ -2,5 +2,4 @@ distributionBase=GRADLE_USER_HOME @@ -2,5 +2,4 @@ distributionBase=GRADLE_USER_HOME
2 distributionPath=wrapper/dists 2 distributionPath=wrapper/dists
3 zipStoreBase=GRADLE_USER_HOME 3 zipStoreBase=GRADLE_USER_HOME
4 zipStorePath=wrapper/dists 4 zipStorePath=wrapper/dists
5 -#distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip  
6 -distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-7.5-all.zip 5 +distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-8.3-all.zip
lib/common/utils/video_playback_diagnostics.dart 0 → 100644
  1 +import 'dart:async';
  2 +
  3 +import 'package:flutter/foundation.dart';
  4 +import 'package:video_player/video_player.dart';
  5 +import 'package:wow_english/utils/toast_util.dart';
  6 +
  7 +/// Shared timeout and user-visible diagnostics for video_player based screens.
  8 +class VideoPlaybackDiagnostics {
  9 + static const initializeTimeout = Duration(seconds: 10);
  10 + static const commandTimeout = Duration(seconds: 5);
  11 + static const bufferingWarningDelay = Duration(seconds: 10);
  12 +
  13 + /// Starts native player disposal while keeping a stuck teardown bounded.
  14 + static Future<void> disposeController(
  15 + VideoPlayerController controller, {
  16 + required String context,
  17 + }) async {
  18 + try {
  19 + await controller.dispose().timeout(commandTimeout);
  20 + } catch (error) {
  21 + debugPrint('$context播放器释放失败或超时: $error');
  22 + }
  23 + }
  24 +
  25 + static String messageFor(Object error, {required String stage}) {
  26 + final detail = error.toString().toLowerCase();
  27 + if (error is TimeoutException) {
  28 + if (stage == '视频初始化') return '视频初始化超时,请检查网络或设备解码能力';
  29 + if (stage == '播放器配置') return '播放器配置超时,设备可能不支持当前播放参数';
  30 + if (stage == '恢复播放进度') return '恢复播放进度超时';
  31 + return '$stage超时';
  32 + }
  33 + if (detail.contains('decoder') ||
  34 + detail.contains('codec') ||
  35 + detail.contains('mediacodec') ||
  36 + detail.contains('acodec')) {
  37 + return '视频解码器启动失败,可能是设备兼容性问题';
  38 + }
  39 + if (detail.contains('http') ||
  40 + detail.contains('socket') ||
  41 + detail.contains('network') ||
  42 + detail.contains('connection') ||
  43 + detail.contains('source error') ||
  44 + detail.contains('sourceerror')) {
  45 + return '视频网络请求失败,请检查网络或视频地址';
  46 + }
  47 + return '$stage失败,请重试';
  48 + }
  49 +
  50 + static void show(String message, {int? videoId}) {
  51 + final idSuffix = videoId == null ? '' : '(视频ID: $videoId)';
  52 + showToast('$message$idSuffix', duration: const Duration(seconds: 4));
  53 + }
  54 +}
lib/common/utils/video_rendering_preference.dart 0 → 100644
  1 +import 'package:flutter/foundation.dart';
  2 +import 'package:video_player/video_player.dart';
  3 +import 'package:wow_english/utils/sp_util.dart';
  4 +
  5 +/// User-selectable Android video output path for device compatibility testing.
  6 +class VideoRenderingPreference {
  7 + static const _usePlatformViewKey = 'video_player_use_platform_view';
  8 +
  9 + static bool get usesPlatformView =>
  10 + SpUtil.getInstance().get<bool>(_usePlatformViewKey) ?? false;
  11 +
  12 + static VideoViewType get viewType =>
  13 + defaultTargetPlatform == TargetPlatform.android && usesPlatformView
  14 + ? VideoViewType.platformView
  15 + : VideoViewType.textureView;
  16 +
  17 + static Future<void> setUsesPlatformView(bool value) =>
  18 + SpUtil.getInstance().setData(_usePlatformViewKey, value);
  19 +}
lib/pages/repeataftercontent/widgets/repeat_video_widget.dart
  1 +import 'dart:async';
  2 +
1 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
2 import 'package:flutter_bloc/flutter_bloc.dart'; 4 import 'package:flutter_bloc/flutter_bloc.dart';
3 import 'package:flutter_screenutil/flutter_screenutil.dart'; 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 import 'package:video_player/video_player.dart'; 6 import 'package:video_player/video_player.dart';
  7 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
  8 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
5 import 'package:wow_english/models/read_content_entity.dart'; 9 import 'package:wow_english/models/read_content_entity.dart';
6 10
7 import '../bloc/repeat_after_content_bloc.dart'; 11 import '../bloc/repeat_after_content_bloc.dart';
@@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; { @@ -23,32 +27,75 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; {
23 String _totalTime = '00:00'; 27 String _totalTime = '00:00';
24 double _playDegree = 0.5; 28 double _playDegree = 0.5;
25 int _currentPlayIndex = 0; 29 int _currentPlayIndex = 0;
  30 + Object? _loadError;
  31 + Timer? _bufferingTimer;
  32 + VoidCallback? _controllerListener;
  33 + bool _bufferingWarningShown = false;
  34 + int _loadGeneration = 0;
26 35
27 String formatDuration(Duration duration) { 36 String formatDuration(Duration duration) {
28 - String minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');  
29 - String seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); 37 + String minutes =
  38 + duration.inMinutes.remainder(60).toString().padLeft(2, '0');
  39 + String seconds =
  40 + duration.inSeconds.remainder(60).toString().padLeft(2, '0');
30 return "$minutes:$seconds"; 41 return "$minutes:$seconds";
31 } 42 }
32 43
33 void _addListener() { 44 void _addListener() {
34 - _controller!.addListener(() {  
35 - if(_controller!.value.isInitialized) {  
36 - if (_controller!.value.isPlaying) {  
37 - setState(() {  
38 - double currentSecond = (_controller!.value.position.inMinutes.remainder(60)*60+_controller!.value.position.inSeconds.remainder(60)).toDouble();  
39 - int totalSecond = _controller!.value.duration.inMinutes.remainder(60)*60+_controller!.value.duration.inSeconds.remainder(60);  
40 - _currentTime = formatDuration(_controller!.value.position);  
41 - _playDegree = currentSecond/totalSecond;  
42 - if(_playDegree < 0) {  
43 - _playDegree = 0.0;  
44 - } 45 + _controllerListener = _onControllerChanged;
  46 + _controller?.addListener(_controllerListener!);
  47 + }
  48 +
  49 + void _onControllerChanged() {
  50 + final controller = _controller;
  51 + if (!mounted || controller == null) return;
  52 + _updateBufferingWatch(controller);
  53 + if (controller.value.hasError && _loadError == null) {
  54 + final error = controller.value.errorDescription ?? '未知播放器错误';
  55 + debugPrint('课程儿歌/视频播放失败: $error');
  56 + VideoPlaybackDiagnostics.show(
  57 + VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'),
  58 + );
  59 + setState(() => _loadError = error);
  60 + return;
  61 + }
  62 + if (!controller.value.isInitialized || !controller.value.isPlaying) return;
  63 + setState(() {
  64 + final currentSecond =
  65 + (controller.value.position.inMinutes.remainder(60) * 60 +
  66 + controller.value.position.inSeconds.remainder(60))
  67 + .toDouble();
  68 + final totalSecond =
  69 + controller.value.duration.inMinutes.remainder(60) * 60 +
  70 + controller.value.duration.inSeconds.remainder(60);
  71 + _currentTime = formatDuration(controller.value.position);
  72 + _playDegree = totalSecond == 0 ? 0 : currentSecond / totalSecond;
  73 + _playDegree = _playDegree.clamp(0.0, 1.0);
  74 + });
  75 + }
45 76
46 - if(_playDegree > 1) {  
47 - _playDegree = 1.0;  
48 - }  
49 - });  
50 - } 77 + void _updateBufferingWatch(VideoPlayerController controller) {
  78 + if (!controller.value.isBuffering) {
  79 + _bufferingTimer?.cancel();
  80 + _bufferingTimer = null;
  81 + _bufferingWarningShown = false;
  82 + return;
  83 + }
  84 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  85 + _bufferingTimer = Timer(VideoPlaybackDiagnostics.bufferingWarningDelay, () {
  86 + _bufferingTimer = null;
  87 + if (!mounted ||
  88 + controller != _controller ||
  89 + !controller.value.isBuffering) {
  90 + return;
51 } 91 }
  92 + _bufferingWarningShown = true;
  93 + final error = controller.value.errorDescription;
  94 + final message = error == null
  95 + ? '视频持续缓冲,网络或设备解码可能异常'
  96 + : VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码');
  97 + debugPrint('课程儿歌/视频持续缓冲: $message ${error ?? ''}');
  98 + VideoPlaybackDiagnostics.show(message);
52 }); 99 });
53 } 100 }
54 101
@@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; { @@ -56,127 +103,207 @@ class _RepeatVideoWidgetState extends State&lt;RepeatVideoWidget&gt; {
56 void initState() { 103 void initState() {
57 super.initState(); 104 super.initState();
58 var videoUrl = ''; 105 var videoUrl = '';
59 - if(widget.videoUrls.isNotEmpty) {  
60 - videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl??''; 106 + if (widget.videoUrls.isNotEmpty) {
  107 + videoUrl = widget.videoUrls[_currentPlayIndex]?.videoUrl ?? '';
61 } 108 }
62 - _initVideo(videoUrl); 109 + unawaited(_initVideo(videoUrl));
63 } 110 }
64 111
65 @override 112 @override
66 Widget build(BuildContext context) { 113 Widget build(BuildContext context) {
67 return Center( 114 return Center(
68 - child: _controller!.value.isInitialized ? BlocListener<RepeatAfterContentBloc,RepeatAfterContentState>(  
69 - listener: (context, state){  
70 - if (state is VideoPlayChangeState) {  
71 - if (context.read<RepeatAfterContentBloc>().videoPlaying) {  
72 - _controller!.play();  
73 - } else {  
74 - _controller!.pause();  
75 - }  
76 - }  
77 -  
78 - if (state is ChangeVideoPlayIndexState) {  
79 - if(state.isNext) {  
80 - if (_currentPlayIndex != (widget.videoUrls.length-1)) {  
81 - _currentPlayIndex++;  
82 - _destroyVideo();  
83 - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!);  
84 - }  
85 - } else {  
86 - if (_currentPlayIndex > 0) {  
87 - _currentPlayIndex--;  
88 - _destroyVideo();  
89 - _initVideo(widget.videoUrls[_currentPlayIndex]!.videoUrl!);  
90 - }  
91 - }  
92 - }  
93 - },  
94 - child: SizedBox(  
95 - child: Column(  
96 - children: [  
97 - Expanded(  
98 - child: Stack(  
99 - children: [  
100 - Container(  
101 - width: double.infinity,  
102 - height: double.infinity,  
103 - alignment: Alignment.center,  
104 - decoration: BoxDecoration(  
105 - color: Colors.black,  
106 - border: Border.all(  
107 - width: 1.0,  
108 - color: const Color(0xFF140C10), 115 + child: _loadError != null
  116 + ? Column(
  117 + mainAxisSize: MainAxisSize.min,
  118 + children: [
  119 + const Text('视频加载失败,请重试'),
  120 + const SizedBox(height: 8),
  121 + ElevatedButton(
  122 + onPressed: () => unawaited(_initVideo(
  123 + widget.videoUrls.isEmpty
  124 + ? ''
  125 + : widget.videoUrls[_currentPlayIndex]?.videoUrl ?? '',
  126 + )),
  127 + child: const Text('重试'),
  128 + ),
  129 + ],
  130 + )
  131 + : _controller?.value.isInitialized == true
  132 + ? BlocListener<RepeatAfterContentBloc, RepeatAfterContentState>(
  133 + listener: (context, state) {
  134 + if (state is VideoPlayChangeState) {
  135 + unawaited(_setPlayback(
  136 + context.read<RepeatAfterContentBloc>().videoPlaying,
  137 + ));
  138 + }
  139 + if (state is ChangeVideoPlayIndexState) {
  140 + final nextIndex = state.isNext
  141 + ? _currentPlayIndex + 1
  142 + : _currentPlayIndex - 1;
  143 + if (nextIndex >= 0 &&
  144 + nextIndex < widget.videoUrls.length) {
  145 + _currentPlayIndex = nextIndex;
  146 + unawaited(_initVideo(
  147 + widget.videoUrls[nextIndex]?.videoUrl ?? '',
  148 + ));
  149 + }
  150 + }
  151 + },
  152 + child: SizedBox(
  153 + child: Column(
  154 + children: [
  155 + Expanded(
  156 + child: Stack(
  157 + children: [
  158 + Container(
  159 + width: double.infinity,
  160 + height: double.infinity,
  161 + alignment: Alignment.center,
  162 + decoration: BoxDecoration(
  163 + color: Colors.black,
  164 + border: Border.all(
  165 + width: 1.0,
  166 + color: const Color(0xFF140C10),
  167 + ),
  168 + borderRadius: BorderRadius.circular(5.r),
  169 + ),
  170 + child: AspectRatio(
  171 + aspectRatio: _controller!.value.aspectRatio,
  172 + child: VideoPlayer(_controller!),
  173 + ),
  174 + ),
  175 + Positioned(
  176 + left: 0,
  177 + right: 0,
  178 + bottom: 0,
  179 + child: LinearProgressIndicator(
  180 + color: const Color(0xFF78B72D),
  181 + backgroundColor: const Color(0xFF7E756C),
  182 + value: _playDegree,
  183 + ),
  184 + ),
  185 + ],
109 ), 186 ),
110 - borderRadius: BorderRadius.circular(5.r)  
111 - ),  
112 - child: AspectRatio(  
113 - aspectRatio: _controller!.value.aspectRatio,  
114 - child: VideoPlayer(_controller!),  
115 - ), 187 + ),
  188 + Row(
  189 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
  190 + children: [Text(_currentTime), Text(_totalTime)],
  191 + ),
  192 + ],
116 ), 193 ),
117 - Positioned(  
118 - left: 0,  
119 - right: 0,  
120 - bottom: 0,  
121 - child: LinearProgressIndicator(  
122 - color: const Color(0xFF78B72D),  
123 - backgroundColor: const Color(0xFF7E756C),  
124 - value: _playDegree,  
125 - ))  
126 - ], 194 + ),
  195 + )
  196 + : Container(
  197 + color: Colors.white,
  198 + child: const CircularProgressIndicator(),
127 ), 199 ),
128 - ),  
129 - Row(  
130 - mainAxisAlignment: MainAxisAlignment.spaceBetween,  
131 - children: [  
132 - Text(_currentTime),  
133 - Text(_totalTime),  
134 - ],  
135 - )  
136 - ],  
137 - ),  
138 - )  
139 - )  
140 - :  
141 - Container(  
142 - color: Colors.white,  
143 - child: const CircularProgressIndicator(),  
144 - // Text(  
145 - // '视频加载中....',  
146 - // style: TextStyle(  
147 - // fontSize: 20.sp,  
148 - // color: Colors.black  
149 - // ),  
150 - // ),  
151 - ),  
152 ); 200 );
153 } 201 }
154 202
155 - void _initVideo(String videoUrl) {  
156 - if(videoUrl.isNotEmpty) {  
157 - Uri uri = Uri.parse(videoUrl);  
158 - _controller = VideoPlayerController.networkUrl(uri)  
159 - ..initialize().then((_){  
160 - setState(() {  
161 - _currentTime = formatDuration(_controller!.value.position);  
162 - _totalTime = formatDuration(_controller!.value.duration);  
163 - _controller!.setLooping(false);  
164 - _controller!.setVolume(100);  
165 - _controller!.play();  
166 - });  
167 - _addListener();  
168 - }); 203 + Future<void> _initVideo(String videoUrl) async {
  204 + final generation = ++_loadGeneration;
  205 + _bufferingTimer?.cancel();
  206 + _bufferingWarningShown = false;
  207 + await _destroyController();
  208 + if (!mounted || generation != _loadGeneration) return;
  209 + _loadError = null;
  210 + final url = videoUrl.trim();
  211 + if (url.isEmpty) {
  212 + _loadError = StateError('课程视频地址为空');
  213 + VideoPlaybackDiagnostics.show('课程视频地址为空');
  214 + if (mounted) setState(() {});
  215 + return;
  216 + }
  217 + final controller = VideoPlayerController.networkUrl(
  218 + Uri.parse(url),
  219 + viewType: VideoRenderingPreference.viewType,
  220 + );
  221 + _controller = controller;
  222 + if (mounted) setState(() {});
  223 + var stage = '视频初始化';
  224 + try {
  225 + await controller
  226 + .initialize()
  227 + .timeout(VideoPlaybackDiagnostics.initializeTimeout);
  228 + if (!mounted ||
  229 + generation != _loadGeneration ||
  230 + controller != _controller) {
  231 + await controller.dispose();
  232 + return;
  233 + }
  234 + stage = '播放器配置';
  235 + await controller
  236 + .setLooping(false)
  237 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  238 + await controller
  239 + .setVolume(1)
  240 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  241 + _addListener();
  242 + _updateBufferingWatch(controller);
  243 + await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout);
  244 + if (!mounted || generation != _loadGeneration) return;
  245 + setState(() {
  246 + _currentTime = formatDuration(controller.value.position);
  247 + _totalTime = formatDuration(controller.value.duration);
  248 + });
  249 + } catch (error) {
  250 + final failedCurrentController = controller == _controller;
  251 + if (failedCurrentController) {
  252 + _bufferingTimer?.cancel();
  253 + _bufferingTimer = null;
  254 + }
  255 + await VideoPlaybackDiagnostics.disposeController(
  256 + controller,
  257 + context: '课程儿歌/视频初始化失败($stage)',
  258 + );
  259 + if (!mounted ||
  260 + generation != _loadGeneration ||
  261 + !failedCurrentController) {
  262 + return;
  263 + }
  264 + _controller = null;
  265 + final message = VideoPlaybackDiagnostics.messageFor(error, stage: stage);
  266 + debugPrint('课程儿歌/视频加载失败(stage=$stage): $error');
  267 + VideoPlaybackDiagnostics.show(message);
  268 + setState(() => _loadError = error);
169 } 269 }
170 } 270 }
171 271
172 - void _destroyVideo() {  
173 - _controller?.dispose();  
174 - _controller?.removeListener(() {}); 272 + Future<void> _setPlayback(bool shouldPlay) async {
  273 + final controller = _controller;
  274 + if (controller?.value.isInitialized != true) return;
  275 + try {
  276 + final operation = shouldPlay ? controller!.play() : controller!.pause();
  277 + await operation.timeout(VideoPlaybackDiagnostics.commandTimeout);
  278 + } catch (error) {
  279 + debugPrint('课程视频播放状态切换失败: $error');
  280 + VideoPlaybackDiagnostics.show(
  281 + VideoPlaybackDiagnostics.messageFor(error, stage: '播放器控制'),
  282 + );
  283 + }
  284 + }
  285 +
  286 + Future<void> _destroyController() async {
  287 + final controller = _controller;
  288 + final listener = _controllerListener;
  289 + _controllerListener = null;
  290 + _controller = null;
  291 + if (controller != null) {
  292 + if (listener != null) {
  293 + controller.removeListener(listener);
  294 + }
  295 + await VideoPlaybackDiagnostics.disposeController(
  296 + controller,
  297 + context: '课程儿歌/视频切换',
  298 + );
  299 + }
175 } 300 }
176 301
177 @override 302 @override
178 void dispose() { 303 void dispose() {
179 - _destroyVideo(); 304 + _loadGeneration++;
  305 + _bufferingTimer?.cancel();
  306 + unawaited(_destroyController());
180 super.dispose(); 307 super.dispose();
181 } 308 }
182 } 309 }
183 \ No newline at end of file 310 \ No newline at end of file
lib/pages/shopping/bloc.dart
@@ -15,13 +15,17 @@ import &#39;../../utils/toast_util.dart&#39;; @@ -15,13 +15,17 @@ import &#39;../../utils/toast_util.dart&#39;;
15 import 'event.dart'; 15 import 'event.dart';
16 import 'state.dart'; 16 import 'state.dart';
17 17
  18 +const bool isWechatPayOptionVisible = false;
  19 +
18 class ShoppingBloc extends Bloc<ShoppingEvent, ShoppingState> { 20 class ShoppingBloc extends Bloc<ShoppingEvent, ShoppingState> {
19 21
20 ProductEntity? _productData; 22 ProductEntity? _productData;
21 23
22 ProductEntity? get productData => _productData; 24 ProductEntity? get productData => _productData;
23 25
24 - PaymentChannel _curPaymentChannel = PaymentChannel.wechatPay; 26 + PaymentChannel _curPaymentChannel = isWechatPayOptionVisible
  27 + ? PaymentChannel.wechatPay
  28 + : PaymentChannel.aliPay;
25 29
26 PaymentChannel get curPaymentChannel => _curPaymentChannel; 30 PaymentChannel get curPaymentChannel => _curPaymentChannel;
27 31
lib/pages/shopping/view.dart
@@ -149,17 +149,26 @@ Widget _paymentWidget() =&gt; BlocBuilder&lt;ShoppingBloc, ShoppingState&gt;( @@ -149,17 +149,26 @@ Widget _paymentWidget() =&gt; BlocBuilder&lt;ShoppingBloc, ShoppingState&gt;(
149 fontFamily: 'PingFangSC-Regular')), 149 fontFamily: 'PingFangSC-Regular')),
150 ), 150 ),
151 const SizedBox(height: 18.0), 151 const SizedBox(height: 18.0),
152 - buildRadioOption(  
153 - value: PaymentChannel.wechatPay.payChannelType,  
154 - icon: AssetImage('weixin'.assetPng),  
155 - text: PaymentChannel.wechatPay.payChannelName,  
156 - isSelected: bloc.curPaymentChannel.payChannelType ==  
157 - PaymentChannel.wechatPay.payChannelType,  
158 - onSelect: (newValue) {  
159 - bloc.add(ChangePaymentChannelEvent(PaymentChannel.wechatPay));  
160 - }, 152 + Visibility(
  153 + visible: isWechatPayOptionVisible,
  154 + child: Column(
  155 + children: [
  156 + buildRadioOption(
  157 + value: PaymentChannel.wechatPay.payChannelType,
  158 + icon: AssetImage('weixin'.assetPng),
  159 + text: PaymentChannel.wechatPay.payChannelName,
  160 + isSelected: bloc.curPaymentChannel.payChannelType ==
  161 + PaymentChannel.wechatPay.payChannelType,
  162 + onSelect: (newValue) {
  163 + bloc.add(
  164 + ChangePaymentChannelEvent(PaymentChannel.wechatPay),
  165 + );
  166 + },
  167 + ),
  168 + const SizedBox(height: 15.0),
  169 + ],
  170 + ),
161 ), 171 ),
162 - const SizedBox(height: 15.0),  
163 buildRadioOption( 172 buildRadioOption(
164 value: PaymentChannel.aliPay.payChannelType, 173 value: PaymentChannel.aliPay.payChannelType,
165 icon: AssetImage('zhifubao'.assetPng), 174 icon: AssetImage('zhifubao'.assetPng),
lib/pages/user/setting/setting_page.dart
  1 +import 'package:flutter/foundation.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 import 'package:flutter_easyloading/flutter_easyloading.dart'; 3 import 'package:flutter_easyloading/flutter_easyloading.dart';
3 import 'package:flutter_screenutil/flutter_screenutil.dart'; 4 import 'package:flutter_screenutil/flutter_screenutil.dart';
4 import 'package:package_info_plus/package_info_plus.dart'; 5 import 'package:package_info_plus/package_info_plus.dart';
  6 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
5 import 'package:wow_english/common/widgets/we_app_bar.dart'; 7 import 'package:wow_english/common/widgets/we_app_bar.dart';
6 import 'package:wow_english/utils/toast_util.dart'; 8 import 'package:wow_english/utils/toast_util.dart';
7 9
@@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget { @@ -19,9 +21,11 @@ class SettingPage extends StatefulWidget {
19 class SettingPageState extends State<SettingPage> { 21 class SettingPageState extends State<SettingPage> {
20 String? _version; 22 String? _version;
21 String? _buildNum; 23 String? _buildNum;
  24 + bool _usePlatformView = false;
22 @override 25 @override
23 void initState() { 26 void initState() {
24 super.initState(); 27 super.initState();
  28 + _usePlatformView = VideoRenderingPreference.usesPlatformView;
25 _retrieveVersionInfo(); 29 _retrieveVersionInfo();
26 } 30 }
27 31
@@ -62,6 +66,10 @@ class SettingPageState extends State&lt;SettingPage&gt; { @@ -62,6 +66,10 @@ class SettingPageState extends State&lt;SettingPage&gt; {
62 _buildItemWidget('帮助与反馈', onPress: () { 66 _buildItemWidget('帮助与反馈', onPress: () {
63 pushNamed(AppRouteName.reBack); 67 pushNamed(AppRouteName.reBack);
64 }), 68 }),
  69 + if (defaultTargetPlatform == TargetPlatform.android) ...[
  70 + 12.verticalSpace,
  71 + _buildVideoRenderingSetting(),
  72 + ],
65 12.verticalSpace, 73 12.verticalSpace,
66 _buildItemWidget('Version: $_version Build:$_buildNum', 74 _buildItemWidget('Version: $_version Build:$_buildNum',
67 onPress: () {}), 75 onPress: () {}),
@@ -73,6 +81,49 @@ class SettingPageState extends State&lt;SettingPage&gt; { @@ -73,6 +81,49 @@ class SettingPageState extends State&lt;SettingPage&gt; {
73 ); 81 );
74 } 82 }
75 83
  84 + Widget _buildVideoRenderingSetting() {
  85 + return Container(
  86 + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
  87 + decoration: BoxDecoration(
  88 + border: Border.all(color: const Color(0xFF140C10), width: 1.5.w),
  89 + borderRadius: BorderRadius.circular(15.r),
  90 + color: Colors.white,
  91 + ),
  92 + child: Row(
  93 + children: [
  94 + Expanded(
  95 + child: Column(
  96 + crossAxisAlignment: CrossAxisAlignment.start,
  97 + children: [
  98 + Text(
  99 + '视频兼容模式',
  100 + style: TextStyle(
  101 + color: const Color(0xFF333333), fontSize: 19.sp),
  102 + ),
  103 + 4.verticalSpace,
  104 + Text(
  105 + _usePlatformView
  106 + ? 'PlatformView(兼容测试)· 新打开的视频生效'
  107 + : 'Texture(默认)· 新打开的视频生效',
  108 + style: TextStyle(
  109 + color: const Color(0xFF777777), fontSize: 13.sp),
  110 + ),
  111 + ],
  112 + ),
  113 + ),
  114 + Switch.adaptive(
  115 + value: _usePlatformView,
  116 + onChanged: (value) async {
  117 + setState(() => _usePlatformView = value);
  118 + await VideoRenderingPreference.setUsesPlatformView(value);
  119 + showToast(value ? '已切换为 PlatformView' : '已切换为 Texture');
  120 + },
  121 + ),
  122 + ],
  123 + ),
  124 + );
  125 + }
  126 +
76 Widget _buildItemWidget(String text, {VoidCallback? onPress}) { 127 Widget _buildItemWidget(String text, {VoidCallback? onPress}) {
77 return OutlinedButton( 128 return OutlinedButton(
78 onPressed: () => onPress?.call(), 129 onPressed: () => onPress?.call(),
lib/pages/video/lookvideo/widgets/video_widget.dart
  1 +import 'dart:async';
  2 +
1 import 'package:common_utils/common_utils.dart'; 3 import 'package:common_utils/common_utils.dart';
2 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
3 import 'package:flutter_bloc/flutter_bloc.dart'; 5 import 'package:flutter_bloc/flutter_bloc.dart';
4 import 'package:flutter_screenutil/flutter_screenutil.dart'; 6 import 'package:flutter_screenutil/flutter_screenutil.dart';
5 import 'package:video_player/video_player.dart'; 7 import 'package:video_player/video_player.dart';
6 import 'package:wow_english/common/extension/string_extension.dart'; 8 import 'package:wow_english/common/extension/string_extension.dart';
  9 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
  10 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
7 import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart'; 11 import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart';
8 import 'package:wow_english/route/route.dart'; 12 import 'package:wow_english/route/route.dart';
9 13
@@ -36,6 +40,11 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -36,6 +40,11 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
36 String _totalTime = '00:00'; 40 String _totalTime = '00:00';
37 double _playDegree = 0.0; 41 double _playDegree = 0.0;
38 bool _hiddenTipView = false; 42 bool _hiddenTipView = false;
  43 + Object? _loadError;
  44 + Timer? _bufferingTimer;
  45 + int _loadGeneration = 0;
  46 + VoidCallback? _diagnosticListener;
  47 + bool _bufferingWarningShown = false;
39 TimerUtil? timerUtil; 48 TimerUtil? timerUtil;
40 49
41 String formatDuration(Duration duration) { 50 String formatDuration(Duration duration) {
@@ -142,19 +151,132 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -142,19 +151,132 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
142 @override 151 @override
143 void initState() { 152 void initState() {
144 super.initState(); 153 super.initState();
145 - Uri uri = Uri.parse(widget.videoUrl);  
146 - _controller = VideoPlayerController.networkUrl(uri)  
147 - ..initialize().then((_) {  
148 - startTimer();  
149 - setState(() {  
150 - _currentTime = formatDuration(_controller!.value.position);  
151 - _totalTime = formatDuration(_controller!.value.duration);  
152 - _controller!.setLooping(!widget.isTopic);  
153 - _controller!.setVolume(100);  
154 - _controller!.play();  
155 - });  
156 - _addListener(); 154 + unawaited(_initializeVideo());
  155 + }
  156 +
  157 + Future<void> _initializeVideo() async {
  158 + final generation = ++_loadGeneration;
  159 + _bufferingTimer?.cancel();
  160 + _bufferingWarningShown = false;
  161 + final oldController = _controller;
  162 + if (oldController != null) {
  163 + if (_diagnosticListener != null) {
  164 + oldController.removeListener(_diagnosticListener!);
  165 + }
  166 + }
  167 + _diagnosticListener = null;
  168 + _controller = null;
  169 + _loadError = null;
  170 + if (oldController != null) {
  171 + await VideoPlaybackDiagnostics.disposeController(
  172 + oldController,
  173 + context: '课程视频切换',
  174 + );
  175 + }
  176 + if (!mounted || generation != _loadGeneration) return;
  177 + final url = widget.videoUrl.trim();
  178 + if (url.isEmpty) {
  179 + _loadError = StateError('课程视频地址为空');
  180 + VideoPlaybackDiagnostics.show('课程视频地址为空');
  181 + if (mounted) setState(() {});
  182 + return;
  183 + }
  184 + final controller = VideoPlayerController.networkUrl(
  185 + Uri.parse(url),
  186 + viewType: VideoRenderingPreference.viewType,
  187 + );
  188 + _controller = controller;
  189 + if (mounted) setState(() {});
  190 + var stage = '视频初始化';
  191 + try {
  192 + await controller
  193 + .initialize()
  194 + .timeout(VideoPlaybackDiagnostics.initializeTimeout);
  195 + if (!mounted ||
  196 + generation != _loadGeneration ||
  197 + controller != _controller) {
  198 + await controller.dispose();
  199 + return;
  200 + }
  201 + stage = '播放器配置';
  202 + await controller
  203 + .setLooping(!widget.isTopic)
  204 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  205 + await controller
  206 + .setVolume(1)
  207 + .timeout(VideoPlaybackDiagnostics.commandTimeout);
  208 + _addListener();
  209 + _diagnosticListener = _onDiagnosticChanged;
  210 + controller.addListener(_diagnosticListener!);
  211 + _updateBufferingWatch(controller);
  212 + await controller.play().timeout(VideoPlaybackDiagnostics.commandTimeout);
  213 + if (!mounted || generation != _loadGeneration) return;
  214 + startTimer();
  215 + setState(() {
  216 + _currentTime = formatDuration(controller.value.position);
  217 + _totalTime = formatDuration(controller.value.duration);
157 }); 218 });
  219 + } catch (error) {
  220 + final failedCurrentController = controller == _controller;
  221 + if (failedCurrentController) {
  222 + _bufferingTimer?.cancel();
  223 + _bufferingTimer = null;
  224 + controller.removeListener(_diagnosticListener ?? _onDiagnosticChanged);
  225 + }
  226 + await VideoPlaybackDiagnostics.disposeController(
  227 + controller,
  228 + context: '课程视频初始化失败($stage)',
  229 + );
  230 + if (!mounted ||
  231 + generation != _loadGeneration ||
  232 + !failedCurrentController) {
  233 + return;
  234 + }
  235 + _controller = null;
  236 + final message = VideoPlaybackDiagnostics.messageFor(error, stage: stage);
  237 + debugPrint('课程视频加载失败(stage=$stage): $error');
  238 + VideoPlaybackDiagnostics.show(message);
  239 + setState(() => _loadError = error);
  240 + }
  241 + }
  242 +
  243 + void _onDiagnosticChanged() {
  244 + final controller = _controller;
  245 + if (!mounted || controller == null) return;
  246 + _updateBufferingWatch(controller);
  247 + if (controller.value.hasError && _loadError == null) {
  248 + final error = controller.value.errorDescription ?? '未知播放器错误';
  249 + debugPrint('课程视频播放失败: $error');
  250 + VideoPlaybackDiagnostics.show(
  251 + VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码'),
  252 + );
  253 + setState(() => _loadError = error);
  254 + }
  255 + }
  256 +
  257 + void _updateBufferingWatch(VideoPlayerController controller) {
  258 + if (!controller.value.isBuffering) {
  259 + _bufferingTimer?.cancel();
  260 + _bufferingTimer = null;
  261 + _bufferingWarningShown = false;
  262 + return;
  263 + }
  264 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  265 + _bufferingTimer = Timer(VideoPlaybackDiagnostics.bufferingWarningDelay, () {
  266 + _bufferingTimer = null;
  267 + if (!mounted ||
  268 + controller != _controller ||
  269 + !controller.value.isBuffering) {
  270 + return;
  271 + }
  272 + _bufferingWarningShown = true;
  273 + final error = controller.value.errorDescription;
  274 + final message = error == null
  275 + ? '视频持续缓冲,网络或设备解码可能异常'
  276 + : VideoPlaybackDiagnostics.messageFor(error, stage: '视频播放/解码');
  277 + debugPrint('课程视频持续缓冲: $message ${error ?? ''}');
  278 + VideoPlaybackDiagnostics.show(message);
  279 + });
158 } 280 }
159 281
160 @override 282 @override
@@ -162,12 +284,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -162,12 +284,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
162 return BlocListener<LookVideoBloc, BaseSectionState>( 284 return BlocListener<LookVideoBloc, BaseSectionState>(
163 listener: (context, state) async { 285 listener: (context, state) async {
164 if (state is SectionAgainState) { 286 if (state is SectionAgainState) {
165 - await _controller?.seekTo(Duration.zero);  
166 - _controller?.play(); 287 + final controller = _controller;
  288 + if (controller?.value.isInitialized == true) {
  289 + await controller!.seekTo(Duration.zero);
  290 + await controller.play();
  291 + }
167 } 292 }
168 }, 293 },
169 child: GestureDetector( 294 child: GestureDetector(
170 onTap: () { 295 onTap: () {
  296 + if (_loadError != null || _controller?.value.isInitialized != true) {
  297 + return;
  298 + }
171 setState(() { 299 setState(() {
172 _hiddenTipView = !_hiddenTipView; 300 _hiddenTipView = !_hiddenTipView;
173 if (!_hiddenTipView) { 301 if (!_hiddenTipView) {
@@ -180,7 +308,7 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -180,7 +308,7 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
180 }); 308 });
181 }, 309 },
182 onDoubleTap: () { 310 onDoubleTap: () {
183 - if (_controller!.value.isInitialized) { 311 + if (_controller?.value.isInitialized == true) {
184 if (_controller!.value.isPlaying) { 312 if (_controller!.value.isPlaying) {
185 _controller!.pause(); 313 _controller!.pause();
186 } else { 314 } else {
@@ -190,65 +318,78 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -190,65 +318,78 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
190 } 318 }
191 }, 319 },
192 child: Center( 320 child: Center(
193 - child: _controller!.value.isInitialized  
194 - ? Stack(  
195 - alignment: Alignment.center, 321 + child: _loadError != null
  322 + ? Column(
  323 + mainAxisSize: MainAxisSize.min,
196 children: [ 324 children: [
197 - SizedBox(  
198 - height: double.infinity,  
199 - width: double.infinity,  
200 - child: AspectRatio(  
201 - aspectRatio: _controller!.value.aspectRatio,  
202 - child: VideoPlayer(_controller!),  
203 - ),  
204 - ),  
205 - Offstage(  
206 - offstage: _hiddenTipView,  
207 - child: VideoOperaWidget(  
208 - title: widget.typeTitle ?? 'song',  
209 - degree: _playDegree,  
210 - totalTime: _totalTime,  
211 - currentTime: _currentTime,  
212 - isPlay: _controller!.value.isPlaying,  
213 - actionEvent: (OperationType type) {  
214 - actionType(type);  
215 - },  
216 - sliderChangeEvent: (double degree) {  
217 - int totalSecond = _controller!  
218 - .value.duration.inMinutes  
219 - .remainder(60) *  
220 - 60 +  
221 - _controller!.value.duration.inSeconds  
222 - .remainder(60);  
223 - int positionSecond = (totalSecond * degree).toInt();  
224 - _controller!  
225 - .seekTo(Duration(seconds: positionSecond));  
226 - },  
227 - ), 325 + const Text('视频加载失败,请重试'),
  326 + const SizedBox(height: 8),
  327 + ElevatedButton(
  328 + onPressed: _initializeVideo,
  329 + child: const Text('重试'),
228 ), 330 ),
229 - Offstage(  
230 - offstage: _controller!.value.isPlaying,  
231 - child: IconButton(  
232 - onPressed: () {  
233 - _controller!.play();  
234 - },  
235 - icon: Image.asset(  
236 - 'video_stop'.assetPng,  
237 - width: 70.w,  
238 - height: 70.h,  
239 - ),  
240 - ),  
241 - )  
242 ], 331 ],
243 ) 332 )
244 - : Container(  
245 - color: Colors.white,  
246 - child: const CircularProgressIndicator(),  
247 - // Text(  
248 - // '视频加载中....',  
249 - // style: TextStyle(fontSize: 20.sp, color: Colors.black),  
250 - // ),  
251 - ), 333 + : _controller?.value.isInitialized == true
  334 + ? Stack(
  335 + alignment: Alignment.center,
  336 + children: [
  337 + SizedBox(
  338 + height: double.infinity,
  339 + width: double.infinity,
  340 + child: AspectRatio(
  341 + aspectRatio: _controller!.value.aspectRatio,
  342 + child: VideoPlayer(_controller!),
  343 + ),
  344 + ),
  345 + Offstage(
  346 + offstage: _hiddenTipView,
  347 + child: VideoOperaWidget(
  348 + title: widget.typeTitle ?? 'song',
  349 + degree: _playDegree,
  350 + totalTime: _totalTime,
  351 + currentTime: _currentTime,
  352 + isPlay: _controller!.value.isPlaying,
  353 + actionEvent: (OperationType type) {
  354 + actionType(type);
  355 + },
  356 + sliderChangeEvent: (double degree) {
  357 + int totalSecond = _controller!
  358 + .value.duration.inMinutes
  359 + .remainder(60) *
  360 + 60 +
  361 + _controller!.value.duration.inSeconds
  362 + .remainder(60);
  363 + int positionSecond =
  364 + (totalSecond * degree).toInt();
  365 + _controller!
  366 + .seekTo(Duration(seconds: positionSecond));
  367 + },
  368 + ),
  369 + ),
  370 + Offstage(
  371 + offstage: _controller!.value.isPlaying,
  372 + child: IconButton(
  373 + onPressed: () {
  374 + _controller!.play();
  375 + },
  376 + icon: Image.asset(
  377 + 'video_stop'.assetPng,
  378 + width: 70.w,
  379 + height: 70.h,
  380 + ),
  381 + ),
  382 + )
  383 + ],
  384 + )
  385 + : Container(
  386 + color: Colors.white,
  387 + child: const CircularProgressIndicator(),
  388 + // Text(
  389 + // '视频加载中....',
  390 + // style: TextStyle(fontSize: 20.sp, color: Colors.black),
  391 + // ),
  392 + ),
252 ), 393 ),
253 ), 394 ),
254 ); 395 );
@@ -256,8 +397,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; { @@ -256,8 +397,18 @@ class _VideoWidgetState extends State&lt;VideoWidget&gt; {
256 397
257 @override 398 @override
258 void dispose() { 399 void dispose() {
259 - _controller?.dispose();  
260 - _controller?.removeListener(() {}); 400 + _loadGeneration++;
  401 + _bufferingTimer?.cancel();
  402 + if (_diagnosticListener != null) {
  403 + _controller?.removeListener(_diagnosticListener!);
  404 + }
  405 + final controller = _controller;
  406 + if (controller != null) {
  407 + unawaited(VideoPlaybackDiagnostics.disposeController(
  408 + controller,
  409 + context: '课程视频页面退出',
  410 + ));
  411 + }
261 if (timerUtil != null) { 412 if (timerUtil != null) {
262 timerUtil!.cancel(); 413 timerUtil!.cancel();
263 timerUtil = null; 414 timerUtil = null;
lib/pages/video/zone/video_zone_page.dart
@@ -2,12 +2,12 @@ import &#39;dart:async&#39;; @@ -2,12 +2,12 @@ import &#39;dart:async&#39;;
2 2
3 import 'package:cached_network_image/cached_network_image.dart'; 3 import 'package:cached_network_image/cached_network_image.dart';
4 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
5 -import 'package:flutter/services.dart';  
6 import 'package:flutter_screenutil/flutter_screenutil.dart'; 5 import 'package:flutter_screenutil/flutter_screenutil.dart';
7 import 'package:wow_english/common/dialogs/show_dialog.dart'; 6 import 'package:wow_english/common/dialogs/show_dialog.dart';
8 import 'package:wow_english/common/request/dao/video_dao.dart'; 7 import 'package:wow_english/common/request/dao/video_dao.dart';
9 import 'package:wow_english/common/widgets/we_app_bar.dart'; 8 import 'package:wow_english/common/widgets/we_app_bar.dart';
10 import 'package:wow_english/route/route.dart'; 9 import 'package:wow_english/route/route.dart';
  10 +import 'package:wow_english/utils/toast_util.dart';
11 11
12 import 'video_zone_item.dart'; 12 import 'video_zone_item.dart';
13 13
@@ -24,104 +24,93 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; { @@ -24,104 +24,93 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; {
24 @override 24 @override
25 void initState() { 25 void initState() {
26 super.initState(); 26 super.initState();
27 - unawaited(  
28 - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),  
29 - );  
30 - _itemsFuture = VideoDao.list(); 27 + _itemsFuture = _loadItems();
31 } 28 }
32 29
33 - @override  
34 - void dispose() {  
35 - unawaited(  
36 - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),  
37 - );  
38 - super.dispose(); 30 + Future<List<VideoZoneItem>> _loadItems() async {
  31 + try {
  32 + return await VideoDao.list().timeout(const Duration(seconds: 10));
  33 + } on TimeoutException catch (error) {
  34 + debugPrint('获取视频列表超时: $error');
  35 + showToast('视频列表加载超时,请检查网络后重试');
  36 + rethrow;
  37 + } catch (error) {
  38 + debugPrint('获取视频列表失败: $error');
  39 + showToast('视频列表加载失败,请稍后重试');
  40 + rethrow;
  41 + }
39 } 42 }
40 43
41 void _reload() { 44 void _reload() {
42 setState(() { 45 setState(() {
43 - _itemsFuture = VideoDao.list(); 46 + _itemsFuture = _loadItems();
44 }); 47 });
45 } 48 }
46 49
47 @override 50 @override
48 Widget build(BuildContext context) { 51 Widget build(BuildContext context) {
49 - return MediaQuery.removePadding(  
50 - context: context,  
51 - removeTop: true,  
52 - child: Scaffold(  
53 - backgroundColor: Colors.white,  
54 - appBar: const WEAppBar(  
55 - titleText: '视频专区',  
56 - centerTitle: false,  
57 - ),  
58 - body: FutureBuilder<List<VideoZoneItem>>(  
59 - future: _itemsFuture,  
60 - builder: (context, snapshot) {  
61 - if (snapshot.connectionState == ConnectionState.waiting) {  
62 - return const Center(child: CircularProgressIndicator());  
63 - }  
64 - if (snapshot.hasError) {  
65 - return _VideoLoadError(onRetry: _reload);  
66 - }  
67 - final items = snapshot.data ?? const <VideoZoneItem>[];  
68 - if (items.isEmpty) {  
69 - return const Center(  
70 - child: Text(  
71 - '暂无视频',  
72 - style: TextStyle(color: Color(0xFF999999), fontSize: 18),  
73 - ),  
74 - );  
75 - }  
76 - return RefreshIndicator(  
77 - onRefresh: () async {  
78 - final future = VideoDao.list();  
79 - setState(() => _itemsFuture = future);  
80 - await future;  
81 - },  
82 - child: LayoutBuilder(  
83 - builder: (context, constraints) {  
84 - final horizontalPadding = 30.w;  
85 - final crossAxisSpacing = 12.w;  
86 - final titleStyle = _VideoCard.createTitleStyle(context);  
87 - final titlePainter = TextPainter(  
88 - text: TextSpan(text: '视频标题', style: titleStyle),  
89 - maxLines: 1,  
90 - textDirection: Directionality.of(context),  
91 - textScaler: MediaQuery.textScalerOf(context),  
92 - )..layout();  
93 - final cardWidth = (constraints.maxWidth -  
94 - horizontalPadding * 2 -  
95 - crossAxisSpacing * 2) /  
96 - 3;  
97 - final cardHeight =  
98 - cardWidth * 9 / 16 + 5.h + titlePainter.height;  
99 - return GridView.builder(  
100 - physics: const AlwaysScrollableScrollPhysics(),  
101 - padding: EdgeInsets.fromLTRB(  
102 - horizontalPadding,  
103 - 10.h,  
104 - horizontalPadding,  
105 - 16.h,  
106 - ),  
107 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(  
108 - crossAxisCount: 3,  
109 - mainAxisSpacing: 14.h,  
110 - crossAxisSpacing: crossAxisSpacing,  
111 - mainAxisExtent: cardHeight,  
112 - ),  
113 - itemCount: items.length,  
114 - itemBuilder: (context, index) => _VideoCard(  
115 - item: items[index],  
116 - onTap: () => _openVideo(items, index),  
117 - titleStyle: titleStyle,  
118 - ),  
119 - );  
120 - }, 52 + return Scaffold(
  53 + backgroundColor: Colors.white,
  54 + appBar: const WEAppBar(
  55 + titleText: '视频专区',
  56 + centerTitle: false,
  57 + ),
  58 + body: FutureBuilder<List<VideoZoneItem>>(
  59 + future: _itemsFuture,
  60 + builder: (context, snapshot) {
  61 + if (snapshot.connectionState == ConnectionState.waiting) {
  62 + return const Center(child: CircularProgressIndicator());
  63 + }
  64 + if (snapshot.hasError) {
  65 + return _VideoLoadError(onRetry: _reload);
  66 + }
  67 + final items = snapshot.data ?? const <VideoZoneItem>[];
  68 + if (items.isEmpty) {
  69 + return const Center(
  70 + child: Text(
  71 + '暂无视频',
  72 + style: TextStyle(color: Color(0xFF999999), fontSize: 18),
121 ), 73 ),
122 ); 74 );
123 - },  
124 - ), 75 + }
  76 + return RefreshIndicator(
  77 + onRefresh: () async {
  78 + final future = _loadItems();
  79 + setState(() => _itemsFuture = future);
  80 + try {
  81 + await future;
  82 + } catch (_) {
  83 + // The FutureBuilder shows the error state; _loadItems already toasts it.
  84 + }
  85 + },
  86 + child: LayoutBuilder(
  87 + builder: (context, constraints) {
  88 + final titleStyle = _VideoCard.createTitleStyle(context);
  89 + return GridView.builder(
  90 + physics: const AlwaysScrollableScrollPhysics(),
  91 + padding: EdgeInsets.fromLTRB(
  92 + 14.w,
  93 + 10.h,
  94 + 14.w,
  95 + 16.h,
  96 + ),
  97 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  98 + crossAxisCount: 4,
  99 + mainAxisSpacing: 14.h,
  100 + crossAxisSpacing: 12.w,
  101 + childAspectRatio: 1.55,
  102 + ),
  103 + itemCount: items.length,
  104 + itemBuilder: (context, index) => _VideoCard(
  105 + item: items[index],
  106 + onTap: () => _openVideo(items, index),
  107 + titleStyle: titleStyle,
  108 + ),
  109 + );
  110 + },
  111 + ),
  112 + );
  113 + },
125 ), 114 ),
126 ); 115 );
127 } 116 }
@@ -144,13 +133,7 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; { @@ -144,13 +133,7 @@ class _VideoZonePageState extends State&lt;VideoZonePage&gt; {
144 .toList(), 133 .toList(),
145 'initialVideoId': item.id, 134 'initialVideoId': item.id,
146 }, 135 },
147 - ).then((_) {  
148 - if (!mounted) return;  
149 - unawaited(  
150 - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),  
151 - );  
152 - _reload();  
153 - }); 136 + ).then((_) => _reload());
154 } 137 }
155 } 138 }
156 139
@@ -203,8 +186,7 @@ class _VideoCard extends StatelessWidget { @@ -203,8 +186,7 @@ class _VideoCard extends StatelessWidget {
203 child: Column( 186 child: Column(
204 crossAxisAlignment: CrossAxisAlignment.start, 187 crossAxisAlignment: CrossAxisAlignment.start,
205 children: [ 188 children: [
206 - AspectRatio(  
207 - aspectRatio: 16 / 9, 189 + Expanded(
208 child: ClipRRect( 190 child: ClipRRect(
209 borderRadius: BorderRadius.circular(12.r), 191 borderRadius: BorderRadius.circular(12.r),
210 child: CachedNetworkImage( 192 child: CachedNetworkImage(
lib/pages/video/zone/video_zone_player_page.dart
@@ -6,7 +6,10 @@ import &#39;package:flutter/material.dart&#39;; @@ -6,7 +6,10 @@ import &#39;package:flutter/material.dart&#39;;
6 import 'package:flutter/services.dart'; 6 import 'package:flutter/services.dart';
7 import 'package:video_player/video_player.dart'; 7 import 'package:video_player/video_player.dart';
8 import 'package:wow_english/common/request/dao/video_dao.dart'; 8 import 'package:wow_english/common/request/dao/video_dao.dart';
  9 +import 'package:wow_english/common/utils/video_playback_diagnostics.dart';
9 import 'package:wow_english/common/utils/video_progress_cache.dart'; 10 import 'package:wow_english/common/utils/video_progress_cache.dart';
  11 +import 'package:wow_english/common/utils/video_rendering_preference.dart';
  12 +import 'package:wow_english/utils/toast_util.dart';
10 13
11 import 'video_zone_item.dart'; 14 import 'video_zone_item.dart';
12 15
@@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget { @@ -26,13 +29,20 @@ class VideoZonePlayerPage extends StatefulWidget {
26 29
27 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage> 30 class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
28 with WidgetsBindingObserver { 31 with WidgetsBindingObserver {
  32 + static const _initializeTimeout = Duration(seconds: 10);
  33 + static const _playerCommandTimeout = Duration(seconds: 5);
  34 + static const _progressReadTimeout = Duration(seconds: 2);
  35 + static const _bufferingWarningDelay = Duration(seconds: 10);
  36 +
29 VideoPlayerController? _controller; 37 VideoPlayerController? _controller;
30 ChewieController? _chewieController; 38 ChewieController? _chewieController;
31 Timer? _cacheTimer; 39 Timer? _cacheTimer;
  40 + Timer? _bufferingTimer;
32 late int _currentIndex; 41 late int _currentIndex;
33 double _playbackSpeed = 1; 42 double _playbackSpeed = 1;
34 bool _switchingVideo = false; 43 bool _switchingVideo = false;
35 bool _handlingCompletion = false; 44 bool _handlingCompletion = false;
  45 + bool _bufferingWarningShown = false;
36 bool? _lastIsPlaying; 46 bool? _lastIsPlaying;
37 int? _completedReportedVideoId; 47 int? _completedReportedVideoId;
38 Object? _loadError; 48 Object? _loadError;
@@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -56,6 +66,8 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
56 Future<void> _loadCurrentVideo() async { 66 Future<void> _loadCurrentVideo() async {
57 final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS; 67 final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
58 _cacheTimer?.cancel(); 68 _cacheTimer?.cancel();
  69 + _bufferingTimer?.cancel();
  70 + _bufferingWarningShown = false;
59 final oldController = _controller; 71 final oldController = _controller;
60 final oldChewieController = _chewieController; 72 final oldChewieController = _chewieController;
61 if (oldController?.value.isInitialized == true) { 73 if (oldController?.value.isInitialized == true) {
@@ -63,25 +75,32 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -63,25 +75,32 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
63 } 75 }
64 oldController?.removeListener(_onVideoChanged); 76 oldController?.removeListener(_onVideoChanged);
65 _lastIsPlaying = null; 77 _lastIsPlaying = null;
66 - final controller =  
67 - VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl)); 78 + final controller = VideoPlayerController.networkUrl(
  79 + Uri.parse(_currentItem.videoUrl),
  80 + viewType: VideoRenderingPreference.viewType,
  81 + );
68 _controller = controller; 82 _controller = controller;
69 _chewieController = null; 83 _chewieController = null;
70 _loadError = null; 84 _loadError = null;
71 if (mounted) { 85 if (mounted) {
72 setState(() {}); 86 setState(() {});
73 } 87 }
74 - oldChewieController?.dispose();  
75 - await oldController?.dispose();  
76 - 88 + var stage = '释放旧播放器';
77 try { 89 try {
78 - await controller.initialize().timeout(const Duration(seconds: 20)); 90 + oldChewieController?.dispose();
  91 + await oldController?.dispose().timeout(_playerCommandTimeout);
  92 + stage = '初始化视频流';
  93 + await controller.initialize().timeout(_initializeTimeout);
79 if (!mounted || controller != _controller) { 94 if (!mounted || controller != _controller) {
80 await controller.dispose(); 95 await controller.dispose();
81 return; 96 return;
82 } 97 }
83 - await controller.setLooping(false);  
84 - await controller.setPlaybackSpeed(_playbackSpeed); 98 + stage = '配置播放器';
  99 + await controller.setLooping(false).timeout(_playerCommandTimeout);
  100 + await controller
  101 + .setPlaybackSpeed(_playbackSpeed)
  102 + .timeout(_playerCommandTimeout);
  103 + stage = '读取本地播放进度';
85 final savedProgress = await _resumeProgressFor(_currentItem.id); 104 final savedProgress = await _resumeProgressFor(_currentItem.id);
86 if (!mounted || controller != _controller) { 105 if (!mounted || controller != _controller) {
87 await controller.dispose(); 106 await controller.dispose();
@@ -89,10 +108,25 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -89,10 +108,25 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
89 } 108 }
90 if (savedProgress > 0 && 109 if (savedProgress > 0 &&
91 savedProgress < controller.value.duration.inSeconds) { 110 savedProgress < controller.value.duration.inSeconds) {
92 - await controller.seekTo(Duration(seconds: savedProgress)); 111 + stage = '恢复播放进度';
  112 + try {
  113 + await controller
  114 + .seekTo(Duration(seconds: savedProgress))
  115 + .timeout(_playerCommandTimeout);
  116 + } on TimeoutException catch (error) {
  117 + debugPrint(
  118 + '恢复本地视频进度超时(videoId=${_currentItem.id}): $error',
  119 + );
  120 + _showVideoToast('恢复进度超时,已尝试从头播放');
  121 + } catch (error) {
  122 + debugPrint('恢复本地视频进度失败(videoId=${_currentItem.id}): $error');
  123 + _showVideoToast('恢复进度失败,已尝试从头播放');
  124 + }
93 } 125 }
  126 + stage = '创建播放界面';
94 _lastIsPlaying = controller.value.isPlaying; 127 _lastIsPlaying = controller.value.isPlaying;
95 controller.addListener(_onVideoChanged); 128 controller.addListener(_onVideoChanged);
  129 + _updateBufferingWatch(controller);
96 _chewieController = ChewieController( 130 _chewieController = ChewieController(
97 videoPlayerController: controller, 131 videoPlayerController: controller,
98 autoPlay: true, 132 autoPlay: true,
@@ -139,15 +173,42 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -139,15 +173,42 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
139 ); 173 );
140 setState(() {}); 174 setState(() {});
141 } catch (error) { 175 } catch (error) {
142 - if (mounted && controller == _controller) {  
143 - setState(() => _loadError = error); 176 + final failedCurrentController = controller == _controller;
  177 + if (failedCurrentController) {
  178 + controller.removeListener(_onVideoChanged);
  179 + _bufferingTimer?.cancel();
  180 + _bufferingTimer = null;
  181 + _cacheTimer?.cancel();
  182 + _cacheTimer = null;
  183 + _chewieController?.dispose();
  184 + _chewieController = null;
144 } 185 }
  186 + await VideoPlaybackDiagnostics.disposeController(
  187 + controller,
  188 + context: '视频专区初始化失败($stage)',
  189 + );
  190 + if (!mounted || !failedCurrentController) return;
  191 + _controller = null;
  192 + final message = _diagnosticMessage(stage, error);
  193 + debugPrint('$message (videoId=${_currentItem.id}): $error');
  194 + _showVideoToast('$message(视频ID: ${_currentItem.id})');
  195 + setState(() => _loadError = error);
145 } 196 }
146 } 197 }
147 198
148 void _onVideoChanged() { 199 void _onVideoChanged() {
149 final controller = _controller; 200 final controller = _controller;
150 if (!mounted || controller == null) return; 201 if (!mounted || controller == null) return;
  202 + _updateBufferingWatch(controller);
  203 + if (controller.value.hasError && _loadError == null) {
  204 + final error = controller.value.errorDescription ?? '未知播放器错误';
  205 + debugPrint('视频播放失败(videoId=${_currentItem.id}): $error');
  206 + _showVideoToast(
  207 + '${_diagnosticMessage('播放/解码', error)}(视频ID: ${_currentItem.id})',
  208 + );
  209 + setState(() => _loadError = error);
  210 + return;
  211 + }
151 final isPlaying = controller.value.isPlaying; 212 final isPlaying = controller.value.isPlaying;
152 if (_lastIsPlaying != null && _lastIsPlaying != isPlaying) { 213 if (_lastIsPlaying != null && _lastIsPlaying != isPlaying) {
153 unawaited(_cacheCurrentProgress()); 214 unawaited(_cacheCurrentProgress());
@@ -162,6 +223,30 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -162,6 +223,30 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
162 } 223 }
163 } 224 }
164 225
  226 + void _updateBufferingWatch(VideoPlayerController controller) {
  227 + if (!controller.value.isBuffering) {
  228 + _bufferingTimer?.cancel();
  229 + _bufferingTimer = null;
  230 + _bufferingWarningShown = false;
  231 + return;
  232 + }
  233 + if (_bufferingTimer != null || _bufferingWarningShown) return;
  234 + _bufferingTimer = Timer(_bufferingWarningDelay, () {
  235 + _bufferingTimer = null;
  236 + if (!mounted ||
  237 + controller != _controller ||
  238 + !controller.value.isBuffering) {
  239 + return;
  240 + }
  241 + _bufferingWarningShown = true;
  242 + final error = controller.value.errorDescription;
  243 + final reason =
  244 + error == null ? '网络缓冲或设备解码可能异常' : _diagnosticMessage('播放/解码', error);
  245 + debugPrint('视频持续缓冲(videoId=${_currentItem.id}): $reason ${error ?? ''}');
  246 + _showVideoToast('视频持续缓冲超过10秒:$reason(视频ID: ${_currentItem.id})');
  247 + });
  248 + }
  249 +
165 Future<void> _playNext({bool currentCompleted = false}) async { 250 Future<void> _playNext({bool currentCompleted = false}) async {
166 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return; 251 if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
167 _switchingVideo = true; 252 _switchingVideo = true;
@@ -187,6 +272,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -187,6 +272,7 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
187 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky), 272 SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
188 ); 273 );
189 _cacheTimer?.cancel(); 274 _cacheTimer?.cancel();
  275 + _bufferingTimer?.cancel();
190 _controller?.removeListener(_onVideoChanged); 276 _controller?.removeListener(_onVideoChanged);
191 unawaited(_reportCurrentProgress()); 277 unawaited(_reportCurrentProgress());
192 _chewieController?.dispose(); 278 _chewieController?.dispose();
@@ -237,14 +323,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt; @@ -237,14 +323,52 @@ class _VideoZonePlayerPageState extends State&lt;VideoZonePlayerPage&gt;
237 323
238 Future<int> _resumeProgressFor(int videoId) async { 324 Future<int> _resumeProgressFor(int videoId) async {
239 try { 325 try {
240 - final cached = await VideoProgressCache.get(videoId); 326 + final cached =
  327 + await VideoProgressCache.get(videoId).timeout(_progressReadTimeout);
241 if (cached != null) return cached.progressSeconds; 328 if (cached != null) return cached.progressSeconds;
  329 + } on TimeoutException catch (error) {
  330 + debugPrint('读取本地视频进度超时(videoId=$videoId): $error');
  331 + _showVideoToast('本地进度读取超时,已从头播放');
242 } catch (error) { 332 } catch (error) {
243 debugPrint('读取本地视频播放进度失败: $error'); 333 debugPrint('读取本地视频播放进度失败: $error');
  334 + _showVideoToast('本地进度读取失败,已从头播放');
244 } 335 }
245 return 0; 336 return 0;
246 } 337 }
247 338
  339 + String _diagnosticMessage(String stage, Object error) {
  340 + final detail = error.toString().toLowerCase();
  341 + if (error is TimeoutException) {
  342 + if (stage == '初始化视频流') return '视频初始化超时,请检查网络或设备解码能力';
  343 + if (stage == '释放旧播放器') return '播放器释放超时,请退出后重试';
  344 + if (stage == '配置播放器') return '播放器配置超时,设备可能不支持当前播放参数';
  345 + if (stage == '恢复播放进度') return '恢复播放进度超时';
  346 + return '$stage超时';
  347 + }
  348 + if (detail.contains('decoder') ||
  349 + detail.contains('codec') ||
  350 + detail.contains('mediacodec') ||
  351 + detail.contains('acodec')) {
  352 + return '视频解码器启动失败,可能是设备兼容性问题';
  353 + }
  354 + if (detail.contains('http') ||
  355 + detail.contains('socket') ||
  356 + detail.contains('network') ||
  357 + detail.contains('connection') ||
  358 + detail.contains('source error') ||
  359 + detail.contains('sourceerror')) {
  360 + return '视频网络请求失败,请检查网络或视频地址';
  361 + }
  362 + if (stage == '初始化视频流') return '视频流初始化失败';
  363 + if (stage == '配置播放器') return '播放器配置失败';
  364 + if (stage == '播放/解码') return '视频播放/解码失败,请检查网络或设备兼容性';
  365 + return '$stage失败';
  366 + }
  367 +
  368 + void _showVideoToast(String message) {
  369 + showToast(message, duration: const Duration(seconds: 4));
  370 + }
  371 +
248 Future<void> _cacheCurrentProgress() async { 372 Future<void> _cacheCurrentProgress() async {
249 final controller = _controller; 373 final controller = _controller;
250 if (controller == null || !controller.value.isInitialized) return; 374 if (controller == null || !controller.value.isInitialized) return;
packages/chivox_aiengine/android/build.gradle
@@ -22,6 +22,7 @@ rootProject.allprojects { @@ -22,6 +22,7 @@ rootProject.allprojects {
22 apply plugin: 'com.android.library' 22 apply plugin: 'com.android.library'
23 23
24 android { 24 android {
  25 + namespace 'com.example.chivox_aiengine'
25 compileSdkVersion 31 26 compileSdkVersion 31
26 27
27 compileOptions { 28 compileOptions {
packages/video_player_android/LICENSE 0 → 100644
  1 +Copyright 2013 The Flutter Authors. All rights reserved.
  2 +
  3 +Redistribution and use in source and binary forms, with or without modification,
  4 +are permitted provided that the following conditions are met:
  5 +
  6 + * Redistributions of source code must retain the above copyright
  7 + notice, this list of conditions and the following disclaimer.
  8 + * Redistributions in binary form must reproduce the above
  9 + copyright notice, this list of conditions and the following
  10 + disclaimer in the documentation and/or other materials provided
  11 + with the distribution.
  12 + * Neither the name of Google Inc. nor the names of its
  13 + contributors may be used to endorse or promote products derived
  14 + from this software without specific prior written permission.
  15 +
  16 +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  17 +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  18 +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  19 +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
  20 +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  21 +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  22 +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
  23 +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  24 +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  25 +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
packages/video_player_android/android/build.gradle 0 → 100644
  1 +group 'io.flutter.plugins.videoplayer'
  2 +version '1.0-SNAPSHOT'
  3 +
  4 +buildscript {
  5 + repositories {
  6 + google()
  7 + mavenCentral()
  8 + }
  9 +
  10 + dependencies {
  11 + classpath 'com.android.tools.build:gradle:8.5.0'
  12 + }
  13 +}
  14 +
  15 +rootProject.allprojects {
  16 + repositories {
  17 + google()
  18 + mavenCentral()
  19 + }
  20 +}
  21 +
  22 +apply plugin: 'com.android.library'
  23 +
  24 +android {
  25 + namespace 'io.flutter.plugins.videoplayer'
  26 + compileSdk = flutter.compileSdkVersion
  27 +
  28 + defaultConfig {
  29 + minSdkVersion 21
  30 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  31 + }
  32 + lintOptions {
  33 + checkAllWarnings true
  34 + warningsAsErrors true
  35 + disable 'AndroidGradlePluginVersion', 'InvalidPackage', 'GradleDependency', 'NewerVersionAvailable'
  36 + }
  37 + compileOptions {
  38 + sourceCompatibility JavaVersion.VERSION_11
  39 + targetCompatibility JavaVersion.VERSION_11
  40 + }
  41 +
  42 + dependencies {
  43 + def exoplayer_version = "1.4.1"
  44 + implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
  45 + implementation "androidx.media3:media3-exoplayer-hls:${exoplayer_version}"
  46 + implementation "androidx.media3:media3-exoplayer-dash:${exoplayer_version}"
  47 + implementation "androidx.media3:media3-exoplayer-rtsp:${exoplayer_version}"
  48 + implementation "androidx.media3:media3-exoplayer-smoothstreaming:${exoplayer_version}"
  49 + testImplementation 'junit:junit:4.13.2'
  50 + testImplementation 'androidx.test:core:1.6.1'
  51 + testImplementation 'org.mockito:mockito-inline:5.2.0'
  52 + testImplementation 'org.robolectric:robolectric:4.14.1'
  53 + testImplementation "androidx.media3:media3-test-utils:${exoplayer_version}"
  54 + }
  55 +
  56 + testOptions {
  57 + unitTests.includeAndroidResources = true
  58 + unitTests.returnDefaultValues = true
  59 + unitTests.all {
  60 + // The org.gradle.jvmargs property that may be set in gradle.properties does not impact
  61 + // the Java heap size when running the Android unit tests. The following property here
  62 + // sets the heap size to a size large enough to run the robolectric tests across
  63 + // multiple SDK levels.
  64 + jvmArgs "-Xmx4G"
  65 + testLogging {
  66 + events "passed", "skipped", "failed", "standardOut", "standardError"
  67 + outputs.upToDateWhen {false}
  68 + showStandardStreams = true
  69 + }
  70 + }
  71 + }
  72 +}
packages/video_player_android/android/settings.gradle 0 → 100644
  1 +rootProject.name = 'video_player_android'
packages/video_player_android/android/src/main/AndroidManifest.xml 0 → 100644
  1 +<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  2 + package="io.flutter.plugins.videoplayer">
  3 +</manifest>
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import androidx.annotation.NonNull;
  8 +import androidx.media3.common.PlaybackException;
  9 +import androidx.media3.common.Player;
  10 +import androidx.media3.exoplayer.ExoPlayer;
  11 +
  12 +public abstract class ExoPlayerEventListener implements Player.Listener {
  13 + private boolean isBuffering = false;
  14 + private boolean isInitialized;
  15 + protected final ExoPlayer exoPlayer;
  16 + protected final VideoPlayerCallbacks events;
  17 +
  18 + protected enum RotationDegrees {
  19 + ROTATE_0(0),
  20 + ROTATE_90(90),
  21 + ROTATE_180(180),
  22 + ROTATE_270(270);
  23 +
  24 + private final int degrees;
  25 +
  26 + RotationDegrees(int degrees) {
  27 + this.degrees = degrees;
  28 + }
  29 +
  30 + public static RotationDegrees fromDegrees(int degrees) {
  31 + for (RotationDegrees rotationDegrees : RotationDegrees.values()) {
  32 + if (rotationDegrees.degrees == degrees) {
  33 + return rotationDegrees;
  34 + }
  35 + }
  36 + throw new IllegalArgumentException("Invalid rotation degrees specified: " + degrees);
  37 + }
  38 +
  39 + public int getDegrees() {
  40 + return this.degrees;
  41 + }
  42 + }
  43 +
  44 + public ExoPlayerEventListener(
  45 + @NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events, boolean initialized) {
  46 + this.exoPlayer = exoPlayer;
  47 + this.events = events;
  48 + this.isInitialized = initialized;
  49 + }
  50 +
  51 + private void setBuffering(boolean buffering) {
  52 + if (isBuffering == buffering) {
  53 + return;
  54 + }
  55 + isBuffering = buffering;
  56 + if (buffering) {
  57 + events.onBufferingStart();
  58 + } else {
  59 + events.onBufferingEnd();
  60 + }
  61 + }
  62 +
  63 + protected abstract void sendInitialized();
  64 +
  65 + @Override
  66 + public void onPlaybackStateChanged(final int playbackState) {
  67 + switch (playbackState) {
  68 + case Player.STATE_BUFFERING:
  69 + setBuffering(true);
  70 + events.onBufferingUpdate(exoPlayer.getBufferedPosition());
  71 + break;
  72 + case Player.STATE_READY:
  73 + if (isInitialized) {
  74 + return;
  75 + }
  76 + isInitialized = true;
  77 + sendInitialized();
  78 + break;
  79 + case Player.STATE_ENDED:
  80 + events.onCompleted();
  81 + break;
  82 + case Player.STATE_IDLE:
  83 + break;
  84 + }
  85 + if (playbackState != Player.STATE_BUFFERING) {
  86 + setBuffering(false);
  87 + }
  88 + }
  89 +
  90 + @Override
  91 + public void onPlayerError(@NonNull final PlaybackException error) {
  92 + setBuffering(false);
  93 + if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
  94 + // See
  95 + // https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window
  96 + exoPlayer.seekToDefaultPosition();
  97 + exoPlayer.prepare();
  98 + } else {
  99 + events.onError("VideoError", "Video player had error " + error, null);
  100 + }
  101 + }
  102 +
  103 + @Override
  104 + public void onIsPlayingChanged(boolean isPlaying) {
  105 + events.onIsPlayingStateUpdate(isPlaying);
  106 + }
  107 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerState.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import androidx.annotation.NonNull;
  8 +import androidx.media3.common.PlaybackParameters;
  9 +import androidx.media3.exoplayer.ExoPlayer;
  10 +
  11 +/**
  12 + * Internal state representing an {@link ExoPlayer} instance at a snapshot in time.
  13 + *
  14 + * <p>During the Android application lifecycle, the underlying {@link android.view.Surface} being
  15 + * rendered to by the player can be destroyed when the application is in the background and memory
  16 + * is reclaimed. Upon <em>resume</em>, the player will need to be recreated, but start again at the
  17 + * previous point (and settings).
  18 + */
  19 +public final class ExoPlayerState {
  20 + /**
  21 + * Saves a representation of the current state of the player at the current point in time.
  22 + *
  23 + * <p>The inverse of this operation is {@link #restore(ExoPlayer)}.
  24 + *
  25 + * @param exoPlayer the active player instance.
  26 + * @return an opaque object representing the state.
  27 + */
  28 + @NonNull
  29 + public static ExoPlayerState save(@NonNull ExoPlayer exoPlayer) {
  30 + return new ExoPlayerState(
  31 + /* position= */ exoPlayer.getCurrentPosition(),
  32 + /* repeatMode= */ exoPlayer.getRepeatMode(),
  33 + /* volume= */ exoPlayer.getVolume(),
  34 + /* playbackParameters= */ exoPlayer.getPlaybackParameters());
  35 + }
  36 +
  37 + private ExoPlayerState(
  38 + long position, int repeatMode, float volume, PlaybackParameters playbackParameters) {
  39 + this.position = position;
  40 + this.repeatMode = repeatMode;
  41 + this.volume = volume;
  42 + this.playbackParameters = playbackParameters;
  43 + }
  44 +
  45 + /** Previous value of {@link ExoPlayer#getCurrentPosition()}. */
  46 + private final long position;
  47 +
  48 + /** Previous value of {@link ExoPlayer#getRepeatMode()}. */
  49 + private final int repeatMode;
  50 +
  51 + /** Previous value of {@link ExoPlayer#getVolume()}. */
  52 + private final float volume;
  53 +
  54 + /** Previous value of {@link ExoPlayer#getPlaybackParameters()}. */
  55 + private final PlaybackParameters playbackParameters;
  56 +
  57 + /**
  58 + * Restores the captured state onto the provided player.
  59 + *
  60 + * <p>This will typically be done after creating a new player, setting up a media source, and
  61 + * listening to events.
  62 + *
  63 + * @param exoPlayer the new player instance to reflect the state back to.
  64 + */
  65 + public void restore(@NonNull ExoPlayer exoPlayer) {
  66 + exoPlayer.seekTo(position);
  67 + exoPlayer.setRepeatMode(repeatMode);
  68 + exoPlayer.setVolume(volume);
  69 + exoPlayer.setPlaybackParameters(playbackParameters);
  70 + }
  71 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/HttpVideoAsset.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.Nullable;
  10 +import androidx.annotation.OptIn;
  11 +import androidx.annotation.VisibleForTesting;
  12 +import androidx.media3.common.MediaItem;
  13 +import androidx.media3.common.MimeTypes;
  14 +import androidx.media3.common.util.UnstableApi;
  15 +import androidx.media3.datasource.DataSource;
  16 +import androidx.media3.datasource.DefaultDataSource;
  17 +import androidx.media3.datasource.DefaultHttpDataSource;
  18 +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
  19 +import androidx.media3.exoplayer.source.MediaSource;
  20 +import java.util.Map;
  21 +
  22 +final class HttpVideoAsset extends VideoAsset {
  23 + private static final String DEFAULT_USER_AGENT = "ExoPlayer";
  24 + private static final String HEADER_USER_AGENT = "User-Agent";
  25 +
  26 + @NonNull private final StreamingFormat streamingFormat;
  27 + @NonNull private final Map<String, String> httpHeaders;
  28 +
  29 + HttpVideoAsset(
  30 + @Nullable String assetUrl,
  31 + @NonNull StreamingFormat streamingFormat,
  32 + @NonNull Map<String, String> httpHeaders) {
  33 + super(assetUrl);
  34 + this.streamingFormat = streamingFormat;
  35 + this.httpHeaders = httpHeaders;
  36 + }
  37 +
  38 + @NonNull
  39 + @Override
  40 + public MediaItem getMediaItem() {
  41 + MediaItem.Builder builder = new MediaItem.Builder().setUri(assetUrl);
  42 + String mimeType = null;
  43 + switch (streamingFormat) {
  44 + case SMOOTH:
  45 + mimeType = MimeTypes.APPLICATION_SS;
  46 + break;
  47 + case DYNAMIC_ADAPTIVE:
  48 + mimeType = MimeTypes.APPLICATION_MPD;
  49 + break;
  50 + case HTTP_LIVE:
  51 + mimeType = MimeTypes.APPLICATION_M3U8;
  52 + break;
  53 + }
  54 + if (mimeType != null) {
  55 + builder.setMimeType(mimeType);
  56 + }
  57 + return builder.build();
  58 + }
  59 +
  60 + @NonNull
  61 + @Override
  62 + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) {
  63 + return getMediaSourceFactory(context, new DefaultHttpDataSource.Factory());
  64 + }
  65 +
  66 + /**
  67 + * Returns a configured media source factory, starting at the provided factory.
  68 + *
  69 + * <p>This method is provided for ease of testing without making real HTTP calls.
  70 + *
  71 + * @param context application context.
  72 + * @param initialFactory initial factory, to be configured.
  73 + * @return configured factory, or {@code null} if not needed for this asset type.
  74 + */
  75 + @VisibleForTesting
  76 + MediaSource.Factory getMediaSourceFactory(
  77 + Context context, DefaultHttpDataSource.Factory initialFactory) {
  78 + String userAgent = DEFAULT_USER_AGENT;
  79 + if (!httpHeaders.isEmpty() && httpHeaders.containsKey(HEADER_USER_AGENT)) {
  80 + userAgent = httpHeaders.get(HEADER_USER_AGENT);
  81 + }
  82 + unstableUpdateDataSourceFactory(initialFactory, httpHeaders, userAgent);
  83 + DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, initialFactory);
  84 + return new DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory);
  85 + }
  86 +
  87 + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
  88 + @OptIn(markerClass = UnstableApi.class)
  89 + private static void unstableUpdateDataSourceFactory(
  90 + @NonNull DefaultHttpDataSource.Factory factory,
  91 + @NonNull Map<String, String> httpHeaders,
  92 + @Nullable String userAgent) {
  93 + factory.setUserAgent(userAgent).setAllowCrossProtocolRedirects(true);
  94 + if (!httpHeaders.isEmpty()) {
  95 + factory.setDefaultRequestProperties(httpHeaders);
  96 + }
  97 + }
  98 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/LocalVideoAsset.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.media3.common.MediaItem;
  10 +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
  11 +import androidx.media3.exoplayer.source.MediaSource;
  12 +
  13 +final class LocalVideoAsset extends VideoAsset {
  14 + LocalVideoAsset(@NonNull String assetUrl) {
  15 + super(assetUrl);
  16 + }
  17 +
  18 + @NonNull
  19 + @Override
  20 + public MediaItem getMediaItem() {
  21 + return new MediaItem.Builder().setUri(assetUrl).build();
  22 + }
  23 +
  24 + @NonNull
  25 + @Override
  26 + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) {
  27 + return new DefaultMediaSourceFactory(context);
  28 + }
  29 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/Messages.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +// Autogenerated from Pigeon (v22.6.1), do not edit directly.
  5 +// See also: https://pub.dev/packages/pigeon
  6 +
  7 +package io.flutter.plugins.videoplayer;
  8 +
  9 +import static java.lang.annotation.ElementType.METHOD;
  10 +import static java.lang.annotation.RetentionPolicy.CLASS;
  11 +
  12 +import android.util.Log;
  13 +import androidx.annotation.NonNull;
  14 +import androidx.annotation.Nullable;
  15 +import io.flutter.plugin.common.BasicMessageChannel;
  16 +import io.flutter.plugin.common.BinaryMessenger;
  17 +import io.flutter.plugin.common.MessageCodec;
  18 +import io.flutter.plugin.common.StandardMessageCodec;
  19 +import java.io.ByteArrayOutputStream;
  20 +import java.lang.annotation.Retention;
  21 +import java.lang.annotation.Target;
  22 +import java.nio.ByteBuffer;
  23 +import java.util.ArrayList;
  24 +import java.util.Map;
  25 +import java.util.Objects;
  26 +
  27 +/** Generated class from Pigeon. */
  28 +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression", "serial"})
  29 +public class Messages {
  30 +
  31 + /** Error class for passing custom error details to Flutter via a thrown PlatformException. */
  32 + public static class FlutterError extends RuntimeException {
  33 +
  34 + /** The error code. */
  35 + public final String code;
  36 +
  37 + /** The error details. Must be a datatype supported by the api codec. */
  38 + public final Object details;
  39 +
  40 + public FlutterError(@NonNull String code, @Nullable String message, @Nullable Object details) {
  41 + super(message);
  42 + this.code = code;
  43 + this.details = details;
  44 + }
  45 + }
  46 +
  47 + @NonNull
  48 + protected static ArrayList<Object> wrapError(@NonNull Throwable exception) {
  49 + ArrayList<Object> errorList = new ArrayList<>(3);
  50 + if (exception instanceof FlutterError) {
  51 + FlutterError error = (FlutterError) exception;
  52 + errorList.add(error.code);
  53 + errorList.add(error.getMessage());
  54 + errorList.add(error.details);
  55 + } else {
  56 + errorList.add(exception.toString());
  57 + errorList.add(exception.getClass().getSimpleName());
  58 + errorList.add(
  59 + "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception));
  60 + }
  61 + return errorList;
  62 + }
  63 +
  64 + @Target(METHOD)
  65 + @Retention(CLASS)
  66 + @interface CanIgnoreReturnValue {}
  67 +
  68 + /** Pigeon equivalent of VideoViewType. */
  69 + public enum PlatformVideoViewType {
  70 + TEXTURE_VIEW(0),
  71 + PLATFORM_VIEW(1);
  72 +
  73 + final int index;
  74 +
  75 + PlatformVideoViewType(final int index) {
  76 + this.index = index;
  77 + }
  78 + }
  79 +
  80 + /**
  81 + * Information passed to the platform view creation.
  82 + *
  83 + * <p>Generated class from Pigeon that represents data sent in messages.
  84 + */
  85 + public static final class PlatformVideoViewCreationParams {
  86 + private @NonNull Long playerId;
  87 +
  88 + public @NonNull Long getPlayerId() {
  89 + return playerId;
  90 + }
  91 +
  92 + public void setPlayerId(@NonNull Long setterArg) {
  93 + if (setterArg == null) {
  94 + throw new IllegalStateException("Nonnull field \"playerId\" is null.");
  95 + }
  96 + this.playerId = setterArg;
  97 + }
  98 +
  99 + /** Constructor is non-public to enforce null safety; use Builder. */
  100 + PlatformVideoViewCreationParams() {}
  101 +
  102 + @Override
  103 + public boolean equals(Object o) {
  104 + if (this == o) {
  105 + return true;
  106 + }
  107 + if (o == null || getClass() != o.getClass()) {
  108 + return false;
  109 + }
  110 + PlatformVideoViewCreationParams that = (PlatformVideoViewCreationParams) o;
  111 + return playerId.equals(that.playerId);
  112 + }
  113 +
  114 + @Override
  115 + public int hashCode() {
  116 + return Objects.hash(playerId);
  117 + }
  118 +
  119 + public static final class Builder {
  120 +
  121 + private @Nullable Long playerId;
  122 +
  123 + @CanIgnoreReturnValue
  124 + public @NonNull Builder setPlayerId(@NonNull Long setterArg) {
  125 + this.playerId = setterArg;
  126 + return this;
  127 + }
  128 +
  129 + public @NonNull PlatformVideoViewCreationParams build() {
  130 + PlatformVideoViewCreationParams pigeonReturn = new PlatformVideoViewCreationParams();
  131 + pigeonReturn.setPlayerId(playerId);
  132 + return pigeonReturn;
  133 + }
  134 + }
  135 +
  136 + @NonNull
  137 + ArrayList<Object> toList() {
  138 + ArrayList<Object> toListResult = new ArrayList<>(1);
  139 + toListResult.add(playerId);
  140 + return toListResult;
  141 + }
  142 +
  143 + static @NonNull PlatformVideoViewCreationParams fromList(
  144 + @NonNull ArrayList<Object> pigeonVar_list) {
  145 + PlatformVideoViewCreationParams pigeonResult = new PlatformVideoViewCreationParams();
  146 + Object playerId = pigeonVar_list.get(0);
  147 + pigeonResult.setPlayerId((Long) playerId);
  148 + return pigeonResult;
  149 + }
  150 + }
  151 +
  152 + /** Generated class from Pigeon that represents data sent in messages. */
  153 + public static final class CreateMessage {
  154 + private @Nullable String asset;
  155 +
  156 + public @Nullable String getAsset() {
  157 + return asset;
  158 + }
  159 +
  160 + public void setAsset(@Nullable String setterArg) {
  161 + this.asset = setterArg;
  162 + }
  163 +
  164 + private @Nullable String uri;
  165 +
  166 + public @Nullable String getUri() {
  167 + return uri;
  168 + }
  169 +
  170 + public void setUri(@Nullable String setterArg) {
  171 + this.uri = setterArg;
  172 + }
  173 +
  174 + private @Nullable String packageName;
  175 +
  176 + public @Nullable String getPackageName() {
  177 + return packageName;
  178 + }
  179 +
  180 + public void setPackageName(@Nullable String setterArg) {
  181 + this.packageName = setterArg;
  182 + }
  183 +
  184 + private @Nullable String formatHint;
  185 +
  186 + public @Nullable String getFormatHint() {
  187 + return formatHint;
  188 + }
  189 +
  190 + public void setFormatHint(@Nullable String setterArg) {
  191 + this.formatHint = setterArg;
  192 + }
  193 +
  194 + private @NonNull Map<String, String> httpHeaders;
  195 +
  196 + public @NonNull Map<String, String> getHttpHeaders() {
  197 + return httpHeaders;
  198 + }
  199 +
  200 + public void setHttpHeaders(@NonNull Map<String, String> setterArg) {
  201 + if (setterArg == null) {
  202 + throw new IllegalStateException("Nonnull field \"httpHeaders\" is null.");
  203 + }
  204 + this.httpHeaders = setterArg;
  205 + }
  206 +
  207 + private @Nullable PlatformVideoViewType viewType;
  208 +
  209 + public @Nullable PlatformVideoViewType getViewType() {
  210 + return viewType;
  211 + }
  212 +
  213 + public void setViewType(@Nullable PlatformVideoViewType setterArg) {
  214 + this.viewType = setterArg;
  215 + }
  216 +
  217 + /** Constructor is non-public to enforce null safety; use Builder. */
  218 + CreateMessage() {}
  219 +
  220 + @Override
  221 + public boolean equals(Object o) {
  222 + if (this == o) {
  223 + return true;
  224 + }
  225 + if (o == null || getClass() != o.getClass()) {
  226 + return false;
  227 + }
  228 + CreateMessage that = (CreateMessage) o;
  229 + return Objects.equals(asset, that.asset)
  230 + && Objects.equals(uri, that.uri)
  231 + && Objects.equals(packageName, that.packageName)
  232 + && Objects.equals(formatHint, that.formatHint)
  233 + && httpHeaders.equals(that.httpHeaders)
  234 + && Objects.equals(viewType, that.viewType);
  235 + }
  236 +
  237 + @Override
  238 + public int hashCode() {
  239 + return Objects.hash(asset, uri, packageName, formatHint, httpHeaders, viewType);
  240 + }
  241 +
  242 + public static final class Builder {
  243 +
  244 + private @Nullable String asset;
  245 +
  246 + @CanIgnoreReturnValue
  247 + public @NonNull Builder setAsset(@Nullable String setterArg) {
  248 + this.asset = setterArg;
  249 + return this;
  250 + }
  251 +
  252 + private @Nullable String uri;
  253 +
  254 + @CanIgnoreReturnValue
  255 + public @NonNull Builder setUri(@Nullable String setterArg) {
  256 + this.uri = setterArg;
  257 + return this;
  258 + }
  259 +
  260 + private @Nullable String packageName;
  261 +
  262 + @CanIgnoreReturnValue
  263 + public @NonNull Builder setPackageName(@Nullable String setterArg) {
  264 + this.packageName = setterArg;
  265 + return this;
  266 + }
  267 +
  268 + private @Nullable String formatHint;
  269 +
  270 + @CanIgnoreReturnValue
  271 + public @NonNull Builder setFormatHint(@Nullable String setterArg) {
  272 + this.formatHint = setterArg;
  273 + return this;
  274 + }
  275 +
  276 + private @Nullable Map<String, String> httpHeaders;
  277 +
  278 + @CanIgnoreReturnValue
  279 + public @NonNull Builder setHttpHeaders(@NonNull Map<String, String> setterArg) {
  280 + this.httpHeaders = setterArg;
  281 + return this;
  282 + }
  283 +
  284 + private @Nullable PlatformVideoViewType viewType;
  285 +
  286 + @CanIgnoreReturnValue
  287 + public @NonNull Builder setViewType(@Nullable PlatformVideoViewType setterArg) {
  288 + this.viewType = setterArg;
  289 + return this;
  290 + }
  291 +
  292 + public @NonNull CreateMessage build() {
  293 + CreateMessage pigeonReturn = new CreateMessage();
  294 + pigeonReturn.setAsset(asset);
  295 + pigeonReturn.setUri(uri);
  296 + pigeonReturn.setPackageName(packageName);
  297 + pigeonReturn.setFormatHint(formatHint);
  298 + pigeonReturn.setHttpHeaders(httpHeaders);
  299 + pigeonReturn.setViewType(viewType);
  300 + return pigeonReturn;
  301 + }
  302 + }
  303 +
  304 + @NonNull
  305 + ArrayList<Object> toList() {
  306 + ArrayList<Object> toListResult = new ArrayList<>(6);
  307 + toListResult.add(asset);
  308 + toListResult.add(uri);
  309 + toListResult.add(packageName);
  310 + toListResult.add(formatHint);
  311 + toListResult.add(httpHeaders);
  312 + toListResult.add(viewType);
  313 + return toListResult;
  314 + }
  315 +
  316 + static @NonNull CreateMessage fromList(@NonNull ArrayList<Object> pigeonVar_list) {
  317 + CreateMessage pigeonResult = new CreateMessage();
  318 + Object asset = pigeonVar_list.get(0);
  319 + pigeonResult.setAsset((String) asset);
  320 + Object uri = pigeonVar_list.get(1);
  321 + pigeonResult.setUri((String) uri);
  322 + Object packageName = pigeonVar_list.get(2);
  323 + pigeonResult.setPackageName((String) packageName);
  324 + Object formatHint = pigeonVar_list.get(3);
  325 + pigeonResult.setFormatHint((String) formatHint);
  326 + Object httpHeaders = pigeonVar_list.get(4);
  327 + pigeonResult.setHttpHeaders((Map<String, String>) httpHeaders);
  328 + Object viewType = pigeonVar_list.get(5);
  329 + pigeonResult.setViewType((PlatformVideoViewType) viewType);
  330 + return pigeonResult;
  331 + }
  332 + }
  333 +
  334 + private static class PigeonCodec extends StandardMessageCodec {
  335 + public static final PigeonCodec INSTANCE = new PigeonCodec();
  336 +
  337 + private PigeonCodec() {}
  338 +
  339 + @Override
  340 + protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) {
  341 + switch (type) {
  342 + case (byte) 129:
  343 + {
  344 + Object value = readValue(buffer);
  345 + return value == null ? null : PlatformVideoViewType.values()[((Long) value).intValue()];
  346 + }
  347 + case (byte) 130:
  348 + return PlatformVideoViewCreationParams.fromList((ArrayList<Object>) readValue(buffer));
  349 + case (byte) 131:
  350 + return CreateMessage.fromList((ArrayList<Object>) readValue(buffer));
  351 + default:
  352 + return super.readValueOfType(type, buffer);
  353 + }
  354 + }
  355 +
  356 + @Override
  357 + protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) {
  358 + if (value instanceof PlatformVideoViewType) {
  359 + stream.write(129);
  360 + writeValue(stream, value == null ? null : ((PlatformVideoViewType) value).index);
  361 + } else if (value instanceof PlatformVideoViewCreationParams) {
  362 + stream.write(130);
  363 + writeValue(stream, ((PlatformVideoViewCreationParams) value).toList());
  364 + } else if (value instanceof CreateMessage) {
  365 + stream.write(131);
  366 + writeValue(stream, ((CreateMessage) value).toList());
  367 + } else {
  368 + super.writeValue(stream, value);
  369 + }
  370 + }
  371 + }
  372 +
  373 + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
  374 + public interface AndroidVideoPlayerApi {
  375 +
  376 + void initialize();
  377 +
  378 + @NonNull
  379 + Long create(@NonNull CreateMessage msg);
  380 +
  381 + void dispose(@NonNull Long playerId);
  382 +
  383 + void setLooping(@NonNull Long playerId, @NonNull Boolean looping);
  384 +
  385 + void setVolume(@NonNull Long playerId, @NonNull Double volume);
  386 +
  387 + void setPlaybackSpeed(@NonNull Long playerId, @NonNull Double speed);
  388 +
  389 + void play(@NonNull Long playerId);
  390 +
  391 + @NonNull
  392 + Long position(@NonNull Long playerId);
  393 +
  394 + void seekTo(@NonNull Long playerId, @NonNull Long position);
  395 +
  396 + void pause(@NonNull Long playerId);
  397 +
  398 + void setMixWithOthers(@NonNull Boolean mixWithOthers);
  399 +
  400 + /** The codec used by AndroidVideoPlayerApi. */
  401 + static @NonNull MessageCodec<Object> getCodec() {
  402 + return PigeonCodec.INSTANCE;
  403 + }
  404 +
  405 + /**
  406 + * Sets up an instance of `AndroidVideoPlayerApi` to handle messages through the
  407 + * `binaryMessenger`.
  408 + */
  409 + static void setUp(
  410 + @NonNull BinaryMessenger binaryMessenger, @Nullable AndroidVideoPlayerApi api) {
  411 + setUp(binaryMessenger, "", api);
  412 + }
  413 +
  414 + static void setUp(
  415 + @NonNull BinaryMessenger binaryMessenger,
  416 + @NonNull String messageChannelSuffix,
  417 + @Nullable AndroidVideoPlayerApi api) {
  418 + messageChannelSuffix = messageChannelSuffix.isEmpty() ? "" : "." + messageChannelSuffix;
  419 + {
  420 + BasicMessageChannel<Object> channel =
  421 + new BasicMessageChannel<>(
  422 + binaryMessenger,
  423 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.initialize"
  424 + + messageChannelSuffix,
  425 + getCodec());
  426 + if (api != null) {
  427 + channel.setMessageHandler(
  428 + (message, reply) -> {
  429 + ArrayList<Object> wrapped = new ArrayList<>();
  430 + try {
  431 + api.initialize();
  432 + wrapped.add(0, null);
  433 + } catch (Throwable exception) {
  434 + wrapped = wrapError(exception);
  435 + }
  436 + reply.reply(wrapped);
  437 + });
  438 + } else {
  439 + channel.setMessageHandler(null);
  440 + }
  441 + }
  442 + {
  443 + BasicMessageChannel<Object> channel =
  444 + new BasicMessageChannel<>(
  445 + binaryMessenger,
  446 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.create"
  447 + + messageChannelSuffix,
  448 + getCodec());
  449 + if (api != null) {
  450 + channel.setMessageHandler(
  451 + (message, reply) -> {
  452 + ArrayList<Object> wrapped = new ArrayList<>();
  453 + ArrayList<Object> args = (ArrayList<Object>) message;
  454 + CreateMessage msgArg = (CreateMessage) args.get(0);
  455 + try {
  456 + Long output = api.create(msgArg);
  457 + wrapped.add(0, output);
  458 + } catch (Throwable exception) {
  459 + wrapped = wrapError(exception);
  460 + }
  461 + reply.reply(wrapped);
  462 + });
  463 + } else {
  464 + channel.setMessageHandler(null);
  465 + }
  466 + }
  467 + {
  468 + BasicMessageChannel<Object> channel =
  469 + new BasicMessageChannel<>(
  470 + binaryMessenger,
  471 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.dispose"
  472 + + messageChannelSuffix,
  473 + getCodec());
  474 + if (api != null) {
  475 + channel.setMessageHandler(
  476 + (message, reply) -> {
  477 + ArrayList<Object> wrapped = new ArrayList<>();
  478 + ArrayList<Object> args = (ArrayList<Object>) message;
  479 + Long playerIdArg = (Long) args.get(0);
  480 + try {
  481 + api.dispose(playerIdArg);
  482 + wrapped.add(0, null);
  483 + } catch (Throwable exception) {
  484 + wrapped = wrapError(exception);
  485 + }
  486 + reply.reply(wrapped);
  487 + });
  488 + } else {
  489 + channel.setMessageHandler(null);
  490 + }
  491 + }
  492 + {
  493 + BasicMessageChannel<Object> channel =
  494 + new BasicMessageChannel<>(
  495 + binaryMessenger,
  496 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setLooping"
  497 + + messageChannelSuffix,
  498 + getCodec());
  499 + if (api != null) {
  500 + channel.setMessageHandler(
  501 + (message, reply) -> {
  502 + ArrayList<Object> wrapped = new ArrayList<>();
  503 + ArrayList<Object> args = (ArrayList<Object>) message;
  504 + Long playerIdArg = (Long) args.get(0);
  505 + Boolean loopingArg = (Boolean) args.get(1);
  506 + try {
  507 + api.setLooping(playerIdArg, loopingArg);
  508 + wrapped.add(0, null);
  509 + } catch (Throwable exception) {
  510 + wrapped = wrapError(exception);
  511 + }
  512 + reply.reply(wrapped);
  513 + });
  514 + } else {
  515 + channel.setMessageHandler(null);
  516 + }
  517 + }
  518 + {
  519 + BasicMessageChannel<Object> channel =
  520 + new BasicMessageChannel<>(
  521 + binaryMessenger,
  522 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setVolume"
  523 + + messageChannelSuffix,
  524 + getCodec());
  525 + if (api != null) {
  526 + channel.setMessageHandler(
  527 + (message, reply) -> {
  528 + ArrayList<Object> wrapped = new ArrayList<>();
  529 + ArrayList<Object> args = (ArrayList<Object>) message;
  530 + Long playerIdArg = (Long) args.get(0);
  531 + Double volumeArg = (Double) args.get(1);
  532 + try {
  533 + api.setVolume(playerIdArg, volumeArg);
  534 + wrapped.add(0, null);
  535 + } catch (Throwable exception) {
  536 + wrapped = wrapError(exception);
  537 + }
  538 + reply.reply(wrapped);
  539 + });
  540 + } else {
  541 + channel.setMessageHandler(null);
  542 + }
  543 + }
  544 + {
  545 + BasicMessageChannel<Object> channel =
  546 + new BasicMessageChannel<>(
  547 + binaryMessenger,
  548 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setPlaybackSpeed"
  549 + + messageChannelSuffix,
  550 + getCodec());
  551 + if (api != null) {
  552 + channel.setMessageHandler(
  553 + (message, reply) -> {
  554 + ArrayList<Object> wrapped = new ArrayList<>();
  555 + ArrayList<Object> args = (ArrayList<Object>) message;
  556 + Long playerIdArg = (Long) args.get(0);
  557 + Double speedArg = (Double) args.get(1);
  558 + try {
  559 + api.setPlaybackSpeed(playerIdArg, speedArg);
  560 + wrapped.add(0, null);
  561 + } catch (Throwable exception) {
  562 + wrapped = wrapError(exception);
  563 + }
  564 + reply.reply(wrapped);
  565 + });
  566 + } else {
  567 + channel.setMessageHandler(null);
  568 + }
  569 + }
  570 + {
  571 + BasicMessageChannel<Object> channel =
  572 + new BasicMessageChannel<>(
  573 + binaryMessenger,
  574 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.play"
  575 + + messageChannelSuffix,
  576 + getCodec());
  577 + if (api != null) {
  578 + channel.setMessageHandler(
  579 + (message, reply) -> {
  580 + ArrayList<Object> wrapped = new ArrayList<>();
  581 + ArrayList<Object> args = (ArrayList<Object>) message;
  582 + Long playerIdArg = (Long) args.get(0);
  583 + try {
  584 + api.play(playerIdArg);
  585 + wrapped.add(0, null);
  586 + } catch (Throwable exception) {
  587 + wrapped = wrapError(exception);
  588 + }
  589 + reply.reply(wrapped);
  590 + });
  591 + } else {
  592 + channel.setMessageHandler(null);
  593 + }
  594 + }
  595 + {
  596 + BasicMessageChannel<Object> channel =
  597 + new BasicMessageChannel<>(
  598 + binaryMessenger,
  599 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.position"
  600 + + messageChannelSuffix,
  601 + getCodec());
  602 + if (api != null) {
  603 + channel.setMessageHandler(
  604 + (message, reply) -> {
  605 + ArrayList<Object> wrapped = new ArrayList<>();
  606 + ArrayList<Object> args = (ArrayList<Object>) message;
  607 + Long playerIdArg = (Long) args.get(0);
  608 + try {
  609 + Long output = api.position(playerIdArg);
  610 + wrapped.add(0, output);
  611 + } catch (Throwable exception) {
  612 + wrapped = wrapError(exception);
  613 + }
  614 + reply.reply(wrapped);
  615 + });
  616 + } else {
  617 + channel.setMessageHandler(null);
  618 + }
  619 + }
  620 + {
  621 + BasicMessageChannel<Object> channel =
  622 + new BasicMessageChannel<>(
  623 + binaryMessenger,
  624 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.seekTo"
  625 + + messageChannelSuffix,
  626 + getCodec());
  627 + if (api != null) {
  628 + channel.setMessageHandler(
  629 + (message, reply) -> {
  630 + ArrayList<Object> wrapped = new ArrayList<>();
  631 + ArrayList<Object> args = (ArrayList<Object>) message;
  632 + Long playerIdArg = (Long) args.get(0);
  633 + Long positionArg = (Long) args.get(1);
  634 + try {
  635 + api.seekTo(playerIdArg, positionArg);
  636 + wrapped.add(0, null);
  637 + } catch (Throwable exception) {
  638 + wrapped = wrapError(exception);
  639 + }
  640 + reply.reply(wrapped);
  641 + });
  642 + } else {
  643 + channel.setMessageHandler(null);
  644 + }
  645 + }
  646 + {
  647 + BasicMessageChannel<Object> channel =
  648 + new BasicMessageChannel<>(
  649 + binaryMessenger,
  650 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.pause"
  651 + + messageChannelSuffix,
  652 + getCodec());
  653 + if (api != null) {
  654 + channel.setMessageHandler(
  655 + (message, reply) -> {
  656 + ArrayList<Object> wrapped = new ArrayList<>();
  657 + ArrayList<Object> args = (ArrayList<Object>) message;
  658 + Long playerIdArg = (Long) args.get(0);
  659 + try {
  660 + api.pause(playerIdArg);
  661 + wrapped.add(0, null);
  662 + } catch (Throwable exception) {
  663 + wrapped = wrapError(exception);
  664 + }
  665 + reply.reply(wrapped);
  666 + });
  667 + } else {
  668 + channel.setMessageHandler(null);
  669 + }
  670 + }
  671 + {
  672 + BasicMessageChannel<Object> channel =
  673 + new BasicMessageChannel<>(
  674 + binaryMessenger,
  675 + "dev.flutter.pigeon.video_player_android.AndroidVideoPlayerApi.setMixWithOthers"
  676 + + messageChannelSuffix,
  677 + getCodec());
  678 + if (api != null) {
  679 + channel.setMessageHandler(
  680 + (message, reply) -> {
  681 + ArrayList<Object> wrapped = new ArrayList<>();
  682 + ArrayList<Object> args = (ArrayList<Object>) message;
  683 + Boolean mixWithOthersArg = (Boolean) args.get(0);
  684 + try {
  685 + api.setMixWithOthers(mixWithOthersArg);
  686 + wrapped.add(0, null);
  687 + } catch (Throwable exception) {
  688 + wrapped = wrapError(exception);
  689 + }
  690 + reply.reply(wrapped);
  691 + });
  692 + } else {
  693 + channel.setMessageHandler(null);
  694 + }
  695 + }
  696 + }
  697 + }
  698 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/QueuingEventSink.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import io.flutter.plugin.common.EventChannel;
  8 +import java.util.ArrayList;
  9 +
  10 +/**
  11 + * And implementation of {@link EventChannel.EventSink} which can wrap an underlying sink.
  12 + *
  13 + * <p>It delivers messages immediately when downstream is available, but it queues messages before
  14 + * the delegate event sink is set with setDelegate.
  15 + *
  16 + * <p>This class is not thread-safe. All calls must be done on the same thread or synchronized
  17 + * externally.
  18 + */
  19 +final class QueuingEventSink implements EventChannel.EventSink {
  20 + private EventChannel.EventSink delegate;
  21 + private final ArrayList<Object> eventQueue = new ArrayList<>();
  22 + private boolean done = false;
  23 +
  24 + public void setDelegate(EventChannel.EventSink delegate) {
  25 + this.delegate = delegate;
  26 + maybeFlush();
  27 + }
  28 +
  29 + @Override
  30 + public void endOfStream() {
  31 + enqueue(new EndOfStreamEvent());
  32 + maybeFlush();
  33 + done = true;
  34 + }
  35 +
  36 + @Override
  37 + public void error(String code, String message, Object details) {
  38 + enqueue(new ErrorEvent(code, message, details));
  39 + maybeFlush();
  40 + }
  41 +
  42 + @Override
  43 + public void success(Object event) {
  44 + enqueue(event);
  45 + maybeFlush();
  46 + }
  47 +
  48 + private void enqueue(Object event) {
  49 + if (done) {
  50 + return;
  51 + }
  52 + eventQueue.add(event);
  53 + }
  54 +
  55 + private void maybeFlush() {
  56 + if (delegate == null) {
  57 + return;
  58 + }
  59 + for (Object event : eventQueue) {
  60 + if (event instanceof EndOfStreamEvent) {
  61 + delegate.endOfStream();
  62 + } else if (event instanceof ErrorEvent) {
  63 + ErrorEvent errorEvent = (ErrorEvent) event;
  64 + delegate.error(errorEvent.code, errorEvent.message, errorEvent.details);
  65 + } else {
  66 + delegate.success(event);
  67 + }
  68 + }
  69 + eventQueue.clear();
  70 + }
  71 +
  72 + static class EndOfStreamEvent {}
  73 +
  74 + private static class ErrorEvent {
  75 + String code;
  76 + String message;
  77 + Object details;
  78 +
  79 + ErrorEvent(String code, String message, Object details) {
  80 + this.code = code;
  81 + this.message = message;
  82 + this.details = details;
  83 + }
  84 + }
  85 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/RtspVideoAsset.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.OptIn;
  10 +import androidx.media3.common.MediaItem;
  11 +import androidx.media3.common.util.UnstableApi;
  12 +import androidx.media3.exoplayer.rtsp.RtspMediaSource;
  13 +import androidx.media3.exoplayer.source.MediaSource;
  14 +
  15 +final class RtspVideoAsset extends VideoAsset {
  16 + RtspVideoAsset(@NonNull String assetUrl) {
  17 + super(assetUrl);
  18 + }
  19 +
  20 + @NonNull
  21 + @Override
  22 + public MediaItem getMediaItem() {
  23 + return new MediaItem.Builder().setUri(assetUrl).build();
  24 + }
  25 +
  26 + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
  27 + @OptIn(markerClass = UnstableApi.class)
  28 + @Override
  29 + public MediaSource.Factory getMediaSourceFactory(Context context) {
  30 + return new RtspMediaSource.Factory();
  31 + }
  32 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoAsset.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.Nullable;
  10 +import androidx.media3.common.MediaItem;
  11 +import androidx.media3.exoplayer.source.MediaSource;
  12 +import java.util.HashMap;
  13 +import java.util.Map;
  14 +
  15 +/** A video to be played by {@link VideoPlayer}. */
  16 +public abstract class VideoAsset {
  17 + /**
  18 + * Returns an asset from a local {@code asset:///} URL, i.e. an on-device asset.
  19 + *
  20 + * @param assetUrl local asset, beginning in {@code asset:///}.
  21 + * @return the asset.
  22 + */
  23 + @NonNull
  24 + static VideoAsset fromAssetUrl(@NonNull String assetUrl) {
  25 + if (!assetUrl.startsWith("asset:///")) {
  26 + throw new IllegalArgumentException("assetUrl must start with 'asset:///'");
  27 + }
  28 + return new LocalVideoAsset(assetUrl);
  29 + }
  30 +
  31 + /**
  32 + * Returns an asset from a remote URL.
  33 + *
  34 + * @param remoteUrl remote asset, i.e. typically beginning with {@code https://} or similar.
  35 + * @param streamingFormat which streaming format, provided as a hint if able.
  36 + * @param httpHeaders HTTP headers to set for a request.
  37 + * @return the asset.
  38 + */
  39 + @NonNull
  40 + static VideoAsset fromRemoteUrl(
  41 + @Nullable String remoteUrl,
  42 + @NonNull StreamingFormat streamingFormat,
  43 + @NonNull Map<String, String> httpHeaders) {
  44 + return new HttpVideoAsset(remoteUrl, streamingFormat, new HashMap<>(httpHeaders));
  45 + }
  46 +
  47 + /**
  48 + * Returns an asset from a RTSP URL.
  49 + *
  50 + * @param rtspUrl remote asset, beginning with {@code rtsp://}.
  51 + * @return the asset.
  52 + */
  53 + @NonNull
  54 + static VideoAsset fromRtspUrl(@NonNull String rtspUrl) {
  55 + if (!rtspUrl.startsWith("rtsp://")) {
  56 + throw new IllegalArgumentException("rtspUrl must start with 'rtsp://'");
  57 + }
  58 + return new RtspVideoAsset(rtspUrl);
  59 + }
  60 +
  61 + @Nullable protected final String assetUrl;
  62 +
  63 + protected VideoAsset(@Nullable String assetUrl) {
  64 + this.assetUrl = assetUrl;
  65 + }
  66 +
  67 + /**
  68 + * Returns the configured media item to be played.
  69 + *
  70 + * @return media item.
  71 + */
  72 + @NonNull
  73 + public abstract MediaItem getMediaItem();
  74 +
  75 + /**
  76 + * Returns the configured media source factory, if needed for this asset type.
  77 + *
  78 + * @param context application context.
  79 + * @return configured factory, or {@code null} if not needed for this asset type.
  80 + */
  81 + @NonNull
  82 + public abstract MediaSource.Factory getMediaSourceFactory(@NonNull Context context);
  83 +
  84 + /** Streaming formats that can be provided to the video player as a hint. */
  85 + enum StreamingFormat {
  86 + /** Default, if the format is either not known or not another valid format. */
  87 + UNKNOWN,
  88 +
  89 + /** Smooth Streaming. */
  90 + SMOOTH,
  91 +
  92 + /** MPEG-DASH (Dynamic Adaptive over HTTP). */
  93 + DYNAMIC_ADAPTIVE,
  94 +
  95 + /** HTTP Live Streaming (HLS). */
  96 + HTTP_LIVE
  97 + }
  98 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static androidx.media3.common.Player.REPEAT_MODE_ALL;
  8 +import static androidx.media3.common.Player.REPEAT_MODE_OFF;
  9 +
  10 +import androidx.annotation.NonNull;
  11 +import androidx.annotation.Nullable;
  12 +import androidx.media3.common.AudioAttributes;
  13 +import androidx.media3.common.C;
  14 +import androidx.media3.common.MediaItem;
  15 +import androidx.media3.common.PlaybackParameters;
  16 +import androidx.media3.exoplayer.ExoPlayer;
  17 +import io.flutter.view.TextureRegistry.SurfaceProducer;
  18 +
  19 +/**
  20 + * A class responsible for managing video playback using {@link ExoPlayer}.
  21 + *
  22 + * <p>It provides methods to control playback, adjust volume, and handle seeking.
  23 + */
  24 +public abstract class VideoPlayer {
  25 + @NonNull private final ExoPlayerProvider exoPlayerProvider;
  26 + @NonNull private final MediaItem mediaItem;
  27 + @NonNull private final VideoPlayerOptions options;
  28 + @NonNull protected final VideoPlayerCallbacks videoPlayerEvents;
  29 + @Nullable protected final SurfaceProducer surfaceProducer;
  30 + @NonNull protected ExoPlayer exoPlayer;
  31 +
  32 + /** A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. */
  33 + public interface ExoPlayerProvider {
  34 + /**
  35 + * Returns a new {@link ExoPlayer}.
  36 + *
  37 + * @return new instance.
  38 + */
  39 + @NonNull
  40 + ExoPlayer get();
  41 + }
  42 +
  43 + public VideoPlayer(
  44 + @NonNull VideoPlayerCallbacks events,
  45 + @NonNull MediaItem mediaItem,
  46 + @NonNull VideoPlayerOptions options,
  47 + @Nullable SurfaceProducer surfaceProducer,
  48 + @NonNull ExoPlayerProvider exoPlayerProvider) {
  49 + this.videoPlayerEvents = events;
  50 + this.mediaItem = mediaItem;
  51 + this.options = options;
  52 + this.exoPlayerProvider = exoPlayerProvider;
  53 + this.surfaceProducer = surfaceProducer;
  54 + this.exoPlayer = createVideoPlayer();
  55 + }
  56 +
  57 + @NonNull
  58 + protected ExoPlayer createVideoPlayer() {
  59 + ExoPlayer exoPlayer = exoPlayerProvider.get();
  60 + exoPlayer.setMediaItem(mediaItem);
  61 + exoPlayer.prepare();
  62 + exoPlayer.addListener(createExoPlayerEventListener(exoPlayer, surfaceProducer));
  63 + setAudioAttributes(exoPlayer, options.mixWithOthers);
  64 +
  65 + return exoPlayer;
  66 + }
  67 +
  68 + @NonNull
  69 + protected abstract ExoPlayerEventListener createExoPlayerEventListener(
  70 + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer);
  71 +
  72 + void sendBufferingUpdate() {
  73 + videoPlayerEvents.onBufferingUpdate(exoPlayer.getBufferedPosition());
  74 + }
  75 +
  76 + private static void setAudioAttributes(ExoPlayer exoPlayer, boolean isMixMode) {
  77 + exoPlayer.setAudioAttributes(
  78 + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
  79 + !isMixMode);
  80 + }
  81 +
  82 + void play() {
  83 + exoPlayer.play();
  84 + }
  85 +
  86 + void pause() {
  87 + exoPlayer.pause();
  88 + }
  89 +
  90 + void setLooping(boolean value) {
  91 + exoPlayer.setRepeatMode(value ? REPEAT_MODE_ALL : REPEAT_MODE_OFF);
  92 + }
  93 +
  94 + void setVolume(double value) {
  95 + float bracketedValue = (float) Math.max(0.0, Math.min(1.0, value));
  96 + exoPlayer.setVolume(bracketedValue);
  97 + }
  98 +
  99 + void setPlaybackSpeed(double value) {
  100 + // We do not need to consider pitch and skipSilence for now as we do not handle them and
  101 + // therefore never diverge from the default values.
  102 + final PlaybackParameters playbackParameters = new PlaybackParameters(((float) value));
  103 +
  104 + exoPlayer.setPlaybackParameters(playbackParameters);
  105 + }
  106 +
  107 + void seekTo(int location) {
  108 + exoPlayer.seekTo(location);
  109 + }
  110 +
  111 + long getPosition() {
  112 + return exoPlayer.getCurrentPosition();
  113 + }
  114 +
  115 + @NonNull
  116 + public ExoPlayer getExoPlayer() {
  117 + return exoPlayer;
  118 + }
  119 +
  120 + public void dispose() {
  121 + exoPlayer.release();
  122 + }
  123 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import androidx.annotation.NonNull;
  8 +import androidx.annotation.Nullable;
  9 +
  10 +/**
  11 + * Callbacks representing events invoked by {@link VideoPlayer}.
  12 + *
  13 + * <p>In the actual plugin, this will always be {@link VideoPlayerEventCallbacks}, which creates the
  14 + * expected events to send back through the plugin channel. In tests methods can be overridden in
  15 + * order to assert results.
  16 + *
  17 + * <p>See {@link androidx.media3.common.Player.Listener} for details.
  18 + */
  19 +public interface VideoPlayerCallbacks {
  20 + void onInitialized(int width, int height, long durationInMs, int rotationCorrectionInDegrees);
  21 +
  22 + void onBufferingStart();
  23 +
  24 + void onBufferingUpdate(long bufferedPosition);
  25 +
  26 + void onBufferingEnd();
  27 +
  28 + void onCompleted();
  29 +
  30 + void onError(@NonNull String code, @Nullable String message, @Nullable Object details);
  31 +
  32 + void onIsPlayingStateUpdate(boolean isPlaying);
  33 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import androidx.annotation.NonNull;
  8 +import androidx.annotation.Nullable;
  9 +import androidx.annotation.VisibleForTesting;
  10 +import io.flutter.plugin.common.EventChannel;
  11 +import java.util.Arrays;
  12 +import java.util.Collections;
  13 +import java.util.HashMap;
  14 +import java.util.List;
  15 +import java.util.Map;
  16 +
  17 +final class VideoPlayerEventCallbacks implements VideoPlayerCallbacks {
  18 + private final EventChannel.EventSink eventSink;
  19 +
  20 + static VideoPlayerEventCallbacks bindTo(EventChannel eventChannel) {
  21 + QueuingEventSink eventSink = new QueuingEventSink();
  22 + eventChannel.setStreamHandler(
  23 + new EventChannel.StreamHandler() {
  24 + @Override
  25 + public void onListen(Object arguments, EventChannel.EventSink events) {
  26 + eventSink.setDelegate(events);
  27 + }
  28 +
  29 + @Override
  30 + public void onCancel(Object arguments) {
  31 + eventSink.setDelegate(null);
  32 + }
  33 + });
  34 + return VideoPlayerEventCallbacks.withSink(eventSink);
  35 + }
  36 +
  37 + @VisibleForTesting
  38 + static VideoPlayerEventCallbacks withSink(EventChannel.EventSink eventSink) {
  39 + return new VideoPlayerEventCallbacks(eventSink);
  40 + }
  41 +
  42 + private VideoPlayerEventCallbacks(EventChannel.EventSink eventSink) {
  43 + this.eventSink = eventSink;
  44 + }
  45 +
  46 + @Override
  47 + public void onInitialized(
  48 + int width, int height, long durationInMs, int rotationCorrectionInDegrees) {
  49 + Map<String, Object> event = new HashMap<>();
  50 + event.put("event", "initialized");
  51 + event.put("width", width);
  52 + event.put("height", height);
  53 + event.put("duration", durationInMs);
  54 + if (rotationCorrectionInDegrees != 0) {
  55 + event.put("rotationCorrection", rotationCorrectionInDegrees);
  56 + }
  57 + eventSink.success(event);
  58 + }
  59 +
  60 + @Override
  61 + public void onBufferingStart() {
  62 + Map<String, Object> event = new HashMap<>();
  63 + event.put("event", "bufferingStart");
  64 + eventSink.success(event);
  65 + }
  66 +
  67 + @Override
  68 + public void onBufferingUpdate(long bufferedPosition) {
  69 + // iOS supports a list of buffered ranges, so we send as a list with a single range.
  70 + Map<String, Object> event = new HashMap<>();
  71 + event.put("event", "bufferingUpdate");
  72 +
  73 + List<? extends Number> range = Arrays.asList(0, bufferedPosition);
  74 + event.put("values", Collections.singletonList(range));
  75 + eventSink.success(event);
  76 + }
  77 +
  78 + @Override
  79 + public void onBufferingEnd() {
  80 + Map<String, Object> event = new HashMap<>();
  81 + event.put("event", "bufferingEnd");
  82 + eventSink.success(event);
  83 + }
  84 +
  85 + @Override
  86 + public void onCompleted() {
  87 + Map<String, Object> event = new HashMap<>();
  88 + event.put("event", "completed");
  89 + eventSink.success(event);
  90 + }
  91 +
  92 + @Override
  93 + public void onError(@NonNull String code, @Nullable String message, @Nullable Object details) {
  94 + eventSink.error(code, message, details);
  95 + }
  96 +
  97 + @Override
  98 + public void onIsPlayingStateUpdate(boolean isPlaying) {
  99 + Map<String, Object> event = new HashMap<>();
  100 + event.put("event", "isPlayingStateUpdate");
  101 + event.put("isPlaying", isPlaying);
  102 + eventSink.success(event);
  103 + }
  104 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerOptions.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +public class VideoPlayerOptions {
  8 + public boolean mixWithOthers;
  9 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import android.util.LongSparseArray;
  9 +import androidx.annotation.NonNull;
  10 +import io.flutter.FlutterInjector;
  11 +import io.flutter.Log;
  12 +import io.flutter.embedding.engine.plugins.FlutterPlugin;
  13 +import io.flutter.plugin.common.BinaryMessenger;
  14 +import io.flutter.plugin.common.EventChannel;
  15 +import io.flutter.plugins.videoplayer.Messages.AndroidVideoPlayerApi;
  16 +import io.flutter.plugins.videoplayer.Messages.CreateMessage;
  17 +import io.flutter.plugins.videoplayer.platformview.PlatformVideoViewFactory;
  18 +import io.flutter.plugins.videoplayer.platformview.PlatformViewVideoPlayer;
  19 +import io.flutter.plugins.videoplayer.texture.TextureVideoPlayer;
  20 +import io.flutter.view.TextureRegistry;
  21 +
  22 +/** Android platform implementation of the VideoPlayerPlugin. */
  23 +public class VideoPlayerPlugin implements FlutterPlugin, AndroidVideoPlayerApi {
  24 + private static final String TAG = "VideoPlayerPlugin";
  25 + private final LongSparseArray<VideoPlayer> videoPlayers = new LongSparseArray<>();
  26 + private FlutterState flutterState;
  27 + private final VideoPlayerOptions options = new VideoPlayerOptions();
  28 +
  29 + // TODO(stuartmorgan): Decouple identifiers for platform views and texture views.
  30 + /**
  31 + * The next non-texture player ID, initialized to a high number to avoid collisions with texture
  32 + * IDs (which are generated separately).
  33 + */
  34 + private Long nextPlatformViewPlayerId = Long.MAX_VALUE;
  35 +
  36 + /** Register this with the v2 embedding for the plugin to respond to lifecycle callbacks. */
  37 + public VideoPlayerPlugin() {}
  38 +
  39 + @Override
  40 + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
  41 + final FlutterInjector injector = FlutterInjector.instance();
  42 + this.flutterState =
  43 + new FlutterState(
  44 + binding.getApplicationContext(),
  45 + binding.getBinaryMessenger(),
  46 + injector.flutterLoader()::getLookupKeyForAsset,
  47 + injector.flutterLoader()::getLookupKeyForAsset,
  48 + binding.getTextureRegistry());
  49 + flutterState.startListening(this, binding.getBinaryMessenger());
  50 +
  51 + binding
  52 + .getPlatformViewRegistry()
  53 + .registerViewFactory(
  54 + "plugins.flutter.dev/video_player_android",
  55 + new PlatformVideoViewFactory(videoPlayers::get));
  56 + }
  57 +
  58 + @Override
  59 + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
  60 + if (flutterState == null) {
  61 + Log.wtf(TAG, "Detached from the engine before registering to it.");
  62 + }
  63 + flutterState.stopListening(binding.getBinaryMessenger());
  64 + flutterState = null;
  65 + onDestroy();
  66 + }
  67 +
  68 + private void disposeAllPlayers() {
  69 + for (int i = 0; i < videoPlayers.size(); i++) {
  70 + videoPlayers.valueAt(i).dispose();
  71 + }
  72 + videoPlayers.clear();
  73 + }
  74 +
  75 + public void onDestroy() {
  76 + // The whole FlutterView is being destroyed. Here we release resources acquired for all
  77 + // instances
  78 + // of VideoPlayer. Once https://github.com/flutter/flutter/issues/19358 is resolved this may
  79 + // be replaced with just asserting that videoPlayers.isEmpty().
  80 + // https://github.com/flutter/flutter/issues/20989 tracks this.
  81 + disposeAllPlayers();
  82 + }
  83 +
  84 + @Override
  85 + public void initialize() {
  86 + disposeAllPlayers();
  87 + }
  88 +
  89 + @Override
  90 + public @NonNull Long create(@NonNull CreateMessage arg) {
  91 + final VideoAsset videoAsset;
  92 + if (arg.getAsset() != null) {
  93 + String assetLookupKey;
  94 + if (arg.getPackageName() != null) {
  95 + assetLookupKey =
  96 + flutterState.keyForAssetAndPackageName.get(arg.getAsset(), arg.getPackageName());
  97 + } else {
  98 + assetLookupKey = flutterState.keyForAsset.get(arg.getAsset());
  99 + }
  100 + videoAsset = VideoAsset.fromAssetUrl("asset:///" + assetLookupKey);
  101 + } else if (arg.getUri().startsWith("rtsp://")) {
  102 + videoAsset = VideoAsset.fromRtspUrl(arg.getUri());
  103 + } else {
  104 + VideoAsset.StreamingFormat streamingFormat = VideoAsset.StreamingFormat.UNKNOWN;
  105 + String formatHint = arg.getFormatHint();
  106 + if (formatHint != null) {
  107 + switch (formatHint) {
  108 + case "ss":
  109 + streamingFormat = VideoAsset.StreamingFormat.SMOOTH;
  110 + break;
  111 + case "dash":
  112 + streamingFormat = VideoAsset.StreamingFormat.DYNAMIC_ADAPTIVE;
  113 + break;
  114 + case "hls":
  115 + streamingFormat = VideoAsset.StreamingFormat.HTTP_LIVE;
  116 + break;
  117 + }
  118 + }
  119 + videoAsset = VideoAsset.fromRemoteUrl(arg.getUri(), streamingFormat, arg.getHttpHeaders());
  120 + }
  121 +
  122 + long id;
  123 + VideoPlayer videoPlayer;
  124 + if (arg.getViewType() == Messages.PlatformVideoViewType.PLATFORM_VIEW) {
  125 + id = nextPlatformViewPlayerId--;
  126 + videoPlayer =
  127 + PlatformViewVideoPlayer.create(
  128 + flutterState.applicationContext,
  129 + VideoPlayerEventCallbacks.bindTo(createEventChannel(id)),
  130 + videoAsset,
  131 + options);
  132 + } else {
  133 + TextureRegistry.SurfaceProducer handle = flutterState.textureRegistry.createSurfaceProducer();
  134 + id = handle.id();
  135 + videoPlayer =
  136 + TextureVideoPlayer.create(
  137 + flutterState.applicationContext,
  138 + VideoPlayerEventCallbacks.bindTo(createEventChannel(id)),
  139 + handle,
  140 + videoAsset,
  141 + options);
  142 + }
  143 +
  144 + videoPlayers.put(id, videoPlayer);
  145 + return id;
  146 + }
  147 +
  148 + @NonNull
  149 + private EventChannel createEventChannel(long id) {
  150 + return new EventChannel(
  151 + flutterState.binaryMessenger, "flutter.io/videoPlayer/videoEvents" + id);
  152 + }
  153 +
  154 + @NonNull
  155 + private VideoPlayer getPlayer(long playerId) {
  156 + VideoPlayer player = videoPlayers.get(playerId);
  157 +
  158 + // Avoid a very ugly un-debuggable NPE that results in returning a null player.
  159 + if (player == null) {
  160 + String message = "No player found with playerId <" + playerId + ">";
  161 + if (videoPlayers.size() == 0) {
  162 + message += " and no active players created by the plugin.";
  163 + }
  164 + throw new IllegalStateException(message);
  165 + }
  166 +
  167 + return player;
  168 + }
  169 +
  170 + @Override
  171 + public void dispose(@NonNull Long playerId) {
  172 + VideoPlayer player = getPlayer(playerId);
  173 + player.dispose();
  174 + videoPlayers.remove(playerId);
  175 + }
  176 +
  177 + @Override
  178 + public void setLooping(@NonNull Long playerId, @NonNull Boolean looping) {
  179 + VideoPlayer player = getPlayer(playerId);
  180 + player.setLooping(looping);
  181 + }
  182 +
  183 + @Override
  184 + public void setVolume(@NonNull Long playerId, @NonNull Double volume) {
  185 + VideoPlayer player = getPlayer(playerId);
  186 + player.setVolume(volume);
  187 + }
  188 +
  189 + @Override
  190 + public void setPlaybackSpeed(@NonNull Long playerId, @NonNull Double speed) {
  191 + VideoPlayer player = getPlayer(playerId);
  192 + player.setPlaybackSpeed(speed);
  193 + }
  194 +
  195 + @Override
  196 + public void play(@NonNull Long playerId) {
  197 + VideoPlayer player = getPlayer(playerId);
  198 + player.play();
  199 + }
  200 +
  201 + @Override
  202 + public @NonNull Long position(@NonNull Long playerId) {
  203 + VideoPlayer player = getPlayer(playerId);
  204 + long position = player.getPosition();
  205 + player.sendBufferingUpdate();
  206 + return position;
  207 + }
  208 +
  209 + @Override
  210 + public void seekTo(@NonNull Long playerId, @NonNull Long position) {
  211 + VideoPlayer player = getPlayer(playerId);
  212 + player.seekTo(position.intValue());
  213 + }
  214 +
  215 + @Override
  216 + public void pause(@NonNull Long playerId) {
  217 + VideoPlayer player = getPlayer(playerId);
  218 + player.pause();
  219 + }
  220 +
  221 + @Override
  222 + public void setMixWithOthers(@NonNull Boolean mixWithOthers) {
  223 + options.mixWithOthers = mixWithOthers;
  224 + }
  225 +
  226 + private interface KeyForAssetFn {
  227 + String get(String asset);
  228 + }
  229 +
  230 + private interface KeyForAssetAndPackageName {
  231 + String get(String asset, String packageName);
  232 + }
  233 +
  234 + private static final class FlutterState {
  235 + final Context applicationContext;
  236 + final BinaryMessenger binaryMessenger;
  237 + final KeyForAssetFn keyForAsset;
  238 + final KeyForAssetAndPackageName keyForAssetAndPackageName;
  239 + final TextureRegistry textureRegistry;
  240 +
  241 + FlutterState(
  242 + Context applicationContext,
  243 + BinaryMessenger messenger,
  244 + KeyForAssetFn keyForAsset,
  245 + KeyForAssetAndPackageName keyForAssetAndPackageName,
  246 + TextureRegistry textureRegistry) {
  247 + this.applicationContext = applicationContext;
  248 + this.binaryMessenger = messenger;
  249 + this.keyForAsset = keyForAsset;
  250 + this.keyForAssetAndPackageName = keyForAssetAndPackageName;
  251 + this.textureRegistry = textureRegistry;
  252 + }
  253 +
  254 + void startListening(VideoPlayerPlugin methodCallHandler, BinaryMessenger messenger) {
  255 + AndroidVideoPlayerApi.setUp(messenger, methodCallHandler);
  256 + }
  257 +
  258 + void stopListening(BinaryMessenger messenger) {
  259 + AndroidVideoPlayerApi.setUp(messenger, null);
  260 + }
  261 + }
  262 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoView.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.platformview;
  6 +
  7 +import android.content.Context;
  8 +import android.os.Build;
  9 +import android.view.SurfaceHolder;
  10 +import android.view.SurfaceView;
  11 +import android.view.View;
  12 +import androidx.annotation.NonNull;
  13 +import androidx.annotation.OptIn;
  14 +import androidx.media3.common.util.UnstableApi;
  15 +import androidx.media3.exoplayer.ExoPlayer;
  16 +import io.flutter.plugin.platform.PlatformView;
  17 +
  18 +/**
  19 + * A class used to create a native video view that can be embedded in a Flutter app. It wraps an
  20 + * {@link ExoPlayer} instance and displays its video content.
  21 + */
  22 +public final class PlatformVideoView implements PlatformView {
  23 + @NonNull private final SurfaceView surfaceView;
  24 +
  25 + /**
  26 + * Constructs a new PlatformVideoView.
  27 + *
  28 + * @param context The context in which the view is running.
  29 + * @param exoPlayer The ExoPlayer instance used to play the video.
  30 + */
  31 + @OptIn(markerClass = UnstableApi.class)
  32 + public PlatformVideoView(@NonNull Context context, @NonNull ExoPlayer exoPlayer) {
  33 + surfaceView = new SurfaceView(context);
  34 +
  35 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
  36 + // Workaround for rendering issues on Android 9 (API 28).
  37 + // On Android 9, using setVideoSurfaceView seems to lead to issues where the first frame is
  38 + // not displayed if the video is paused initially.
  39 + // To ensure the first frame is visible, the surface is directly set using holder.getSurface()
  40 + // when the surface is created, and ExoPlayer seeks to a position to force rendering of the
  41 + // first frame.
  42 + setupSurfaceWithCallback(exoPlayer);
  43 + } else {
  44 + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) {
  45 + // Avoid blank space instead of a video on Android versions below 8 by adjusting video's
  46 + // z-layer within the Android view hierarchy:
  47 + surfaceView.setZOrderMediaOverlay(true);
  48 + }
  49 + exoPlayer.setVideoSurfaceView(surfaceView);
  50 + }
  51 + }
  52 +
  53 + private void setupSurfaceWithCallback(@NonNull ExoPlayer exoPlayer) {
  54 + surfaceView
  55 + .getHolder()
  56 + .addCallback(
  57 + new SurfaceHolder.Callback() {
  58 + @Override
  59 + public void surfaceCreated(@NonNull SurfaceHolder holder) {
  60 + exoPlayer.setVideoSurface(holder.getSurface());
  61 + // Force first frame rendering:
  62 + exoPlayer.seekTo(1);
  63 + }
  64 +
  65 + @Override
  66 + public void surfaceChanged(
  67 + @NonNull SurfaceHolder holder, int format, int width, int height) {
  68 + // No implementation needed.
  69 + }
  70 +
  71 + @Override
  72 + public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
  73 + exoPlayer.setVideoSurface(null);
  74 + }
  75 + });
  76 + }
  77 +
  78 + /**
  79 + * Returns the view associated with this PlatformView.
  80 + *
  81 + * @return The SurfaceView used to display the video.
  82 + */
  83 + @NonNull
  84 + @Override
  85 + public View getView() {
  86 + return surfaceView;
  87 + }
  88 +
  89 + /** Disposes of the resources used by this PlatformView. */
  90 + @Override
  91 + public void dispose() {
  92 + surfaceView.getHolder().getSurface().release();
  93 + }
  94 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformVideoViewFactory.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.platformview;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.Nullable;
  10 +import androidx.media3.exoplayer.ExoPlayer;
  11 +import io.flutter.plugin.platform.PlatformView;
  12 +import io.flutter.plugin.platform.PlatformViewFactory;
  13 +import io.flutter.plugins.videoplayer.Messages;
  14 +import io.flutter.plugins.videoplayer.VideoPlayer;
  15 +import java.util.Objects;
  16 +
  17 +/**
  18 + * A factory class responsible for creating platform video views that can be embedded in a Flutter
  19 + * app.
  20 + */
  21 +public class PlatformVideoViewFactory extends PlatformViewFactory {
  22 + private final VideoPlayerProvider videoPlayerProvider;
  23 +
  24 + /** Functional interface for providing a VideoPlayer instance based on the player ID. */
  25 + @FunctionalInterface
  26 + public interface VideoPlayerProvider {
  27 + /**
  28 + * Retrieves a VideoPlayer instance based on the provided player ID.
  29 + *
  30 + * @param playerId The unique identifier for the video player.
  31 + * @return A VideoPlayer instance associated with the given player ID.
  32 + */
  33 + @NonNull
  34 + VideoPlayer getVideoPlayer(@NonNull Long playerId);
  35 + }
  36 +
  37 + /**
  38 + * Constructs a new PlatformVideoViewFactory.
  39 + *
  40 + * @param videoPlayerProvider The provider used to retrieve the video player associated with the
  41 + * view.
  42 + */
  43 + public PlatformVideoViewFactory(@NonNull VideoPlayerProvider videoPlayerProvider) {
  44 + super(Messages.AndroidVideoPlayerApi.getCodec());
  45 + this.videoPlayerProvider = videoPlayerProvider;
  46 + }
  47 +
  48 + /**
  49 + * Creates a new instance of platform view.
  50 + *
  51 + * @param context The context in which the view is running.
  52 + * @param id The unique identifier for the view.
  53 + * @param args The arguments for creating the view.
  54 + * @return A new instance of PlatformVideoView.
  55 + */
  56 + @NonNull
  57 + @Override
  58 + public PlatformView create(@NonNull Context context, int id, @Nullable Object args) {
  59 + final Messages.PlatformVideoViewCreationParams params =
  60 + Objects.requireNonNull((Messages.PlatformVideoViewCreationParams) args);
  61 + final Long playerId = params.getPlayerId();
  62 +
  63 + final VideoPlayer player = videoPlayerProvider.getVideoPlayer(playerId);
  64 + final ExoPlayer exoPlayer = player.getExoPlayer();
  65 +
  66 + return new PlatformVideoView(context, exoPlayer);
  67 + }
  68 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewExoPlayerEventListener.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.platformview;
  6 +
  7 +import androidx.annotation.NonNull;
  8 +import androidx.annotation.OptIn;
  9 +import androidx.annotation.VisibleForTesting;
  10 +import androidx.media3.common.Format;
  11 +import androidx.media3.common.util.UnstableApi;
  12 +import androidx.media3.exoplayer.ExoPlayer;
  13 +import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
  14 +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
  15 +import java.util.Objects;
  16 +
  17 +public final class PlatformViewExoPlayerEventListener extends ExoPlayerEventListener {
  18 + @VisibleForTesting
  19 + public PlatformViewExoPlayerEventListener(
  20 + @NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events) {
  21 + this(exoPlayer, events, false);
  22 + }
  23 +
  24 + public PlatformViewExoPlayerEventListener(
  25 + @NonNull ExoPlayer exoPlayer, @NonNull VideoPlayerCallbacks events, boolean initialized) {
  26 + super(exoPlayer, events, initialized);
  27 + }
  28 +
  29 + @OptIn(markerClass = UnstableApi.class)
  30 + @Override
  31 + protected void sendInitialized() {
  32 + // We can't rely on VideoSize here, because at this point it is not available - the platform
  33 + // view was not created yet. We use the video format instead.
  34 + Format videoFormat = exoPlayer.getVideoFormat();
  35 + RotationDegrees rotationCorrection =
  36 + RotationDegrees.fromDegrees(Objects.requireNonNull(videoFormat).rotationDegrees);
  37 + int width = videoFormat.width;
  38 + int height = videoFormat.height;
  39 +
  40 + // Switch the width/height if video was taken in portrait mode and a rotation
  41 + // correction was detected.
  42 + if (rotationCorrection == RotationDegrees.ROTATE_90
  43 + || rotationCorrection == RotationDegrees.ROTATE_270) {
  44 + width = videoFormat.height;
  45 + height = videoFormat.width;
  46 +
  47 + rotationCorrection = RotationDegrees.fromDegrees(0);
  48 + }
  49 +
  50 + events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees());
  51 + }
  52 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.platformview;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.Nullable;
  10 +import androidx.annotation.VisibleForTesting;
  11 +import androidx.media3.common.MediaItem;
  12 +import androidx.media3.exoplayer.DefaultRenderersFactory;
  13 +import androidx.media3.exoplayer.ExoPlayer;
  14 +import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
  15 +import io.flutter.plugins.videoplayer.VideoAsset;
  16 +import io.flutter.plugins.videoplayer.VideoPlayer;
  17 +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
  18 +import io.flutter.plugins.videoplayer.VideoPlayerOptions;
  19 +import io.flutter.view.TextureRegistry.SurfaceProducer;
  20 +
  21 +/**
  22 + * A subclass of {@link VideoPlayer} that adds functionality related to platform view as a way of
  23 + * displaying the video in the app.
  24 + */
  25 +public class PlatformViewVideoPlayer extends VideoPlayer {
  26 + @VisibleForTesting
  27 + public PlatformViewVideoPlayer(
  28 + @NonNull VideoPlayerCallbacks events,
  29 + @NonNull MediaItem mediaItem,
  30 + @NonNull VideoPlayerOptions options,
  31 + @NonNull ExoPlayerProvider exoPlayerProvider) {
  32 + super(events, mediaItem, options, /* surfaceProducer */ null, exoPlayerProvider);
  33 + }
  34 +
  35 + /**
  36 + * Creates a platform view video player.
  37 + *
  38 + * @param context application context.
  39 + * @param events event callbacks.
  40 + * @param asset asset to play.
  41 + * @param options options for playback.
  42 + * @return a video player instance.
  43 + */
  44 + @NonNull
  45 + public static PlatformViewVideoPlayer create(
  46 + @NonNull Context context,
  47 + @NonNull VideoPlayerCallbacks events,
  48 + @NonNull VideoAsset asset,
  49 + @NonNull VideoPlayerOptions options) {
  50 + return new PlatformViewVideoPlayer(
  51 + events,
  52 + asset.getMediaItem(),
  53 + options,
  54 + () -> {
  55 + DefaultRenderersFactory renderersFactory =
  56 + new DefaultRenderersFactory(context).setEnableDecoderFallback(true);
  57 + ExoPlayer.Builder builder =
  58 + new ExoPlayer.Builder(context, renderersFactory)
  59 + .setMediaSourceFactory(asset.getMediaSourceFactory(context));
  60 + return builder.build();
  61 + });
  62 + }
  63 +
  64 + @NonNull
  65 + @Override
  66 + protected ExoPlayerEventListener createExoPlayerEventListener(
  67 + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer) {
  68 + // Platform view video player does not suspend and re-create the exoPlayer, hence initialized
  69 + // is always false. It also does not require a reference to the SurfaceProducer.
  70 + return new PlatformViewExoPlayerEventListener(exoPlayer, videoPlayerEvents, false);
  71 + }
  72 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureExoPlayerEventListener.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.texture;
  6 +
  7 +import android.os.Build;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.OptIn;
  10 +import androidx.annotation.VisibleForTesting;
  11 +import androidx.media3.common.Format;
  12 +import androidx.media3.common.VideoSize;
  13 +import androidx.media3.exoplayer.ExoPlayer;
  14 +import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
  15 +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
  16 +import java.util.Objects;
  17 +
  18 +public final class TextureExoPlayerEventListener extends ExoPlayerEventListener {
  19 + private boolean surfaceProducerHandlesCropAndRotation;
  20 +
  21 + @VisibleForTesting
  22 + public TextureExoPlayerEventListener(
  23 + @NonNull ExoPlayer exoPlayer,
  24 + @NonNull VideoPlayerCallbacks events,
  25 + boolean surfaceProducerHandlesCropAndRotation) {
  26 + this(exoPlayer, events, surfaceProducerHandlesCropAndRotation, false);
  27 + }
  28 +
  29 + public TextureExoPlayerEventListener(
  30 + @NonNull ExoPlayer exoPlayer,
  31 + @NonNull VideoPlayerCallbacks events,
  32 + boolean surfaceProducerHandlesCropAndRotation,
  33 + boolean initialized) {
  34 + super(exoPlayer, events, initialized);
  35 + this.surfaceProducerHandlesCropAndRotation = surfaceProducerHandlesCropAndRotation;
  36 + }
  37 +
  38 + @Override
  39 + protected void sendInitialized() {
  40 + VideoSize videoSize = exoPlayer.getVideoSize();
  41 + RotationDegrees rotationCorrection = RotationDegrees.ROTATE_0;
  42 + int width = videoSize.width;
  43 + int height = videoSize.height;
  44 + if (width != 0 && height != 0) {
  45 + if (Build.VERSION.SDK_INT <= 21) {
  46 + // On API 21 and below, Exoplayer may not internally handle rotation correction
  47 + // and reports it through VideoSize.unappliedRotationDegrees. We may apply it to
  48 + // fix the case of upside-down playback.
  49 + try {
  50 + RotationDegrees unappliedRotation =
  51 + RotationDegrees.fromDegrees(videoSize.unappliedRotationDegrees);
  52 + rotationCorrection = getRotationCorrectionFromUnappliedRotation(unappliedRotation);
  53 + } catch (IllegalArgumentException e) {
  54 + // Unapplied rotation other than 0, 90, 180, 270 reported by VideoSize. Because this is
  55 + // unexpected, we apply no rotation correction.
  56 + rotationCorrection = RotationDegrees.ROTATE_0;
  57 + }
  58 + } else if (surfaceProducerHandlesCropAndRotation) {
  59 + // When the SurfaceTexture backend for Impeller is used, the preview should already
  60 + // be correctly rotated.
  61 + rotationCorrection = RotationDegrees.ROTATE_0;
  62 + } else {
  63 + // The video's Format also provides a rotation correction that may be used to
  64 + // correct the rotation, so we try to use that to correct the video rotation
  65 + // when the ImageReader backend for Impeller is used.
  66 + int rawVideoFormatRotation = getRotationCorrectionFromFormat(exoPlayer);
  67 +
  68 + try {
  69 + rotationCorrection = RotationDegrees.fromDegrees(rawVideoFormatRotation);
  70 + } catch (IllegalArgumentException e) {
  71 + // Rotation correction other than 0, 90, 180, 270 reported by Format. Because this is
  72 + // unexpected we apply no rotation correction.
  73 + rotationCorrection = RotationDegrees.ROTATE_0;
  74 + }
  75 + }
  76 + }
  77 + events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees());
  78 + }
  79 +
  80 + private RotationDegrees getRotationCorrectionFromUnappliedRotation(
  81 + RotationDegrees unappliedRotationDegrees) {
  82 + RotationDegrees rotationCorrection = RotationDegrees.ROTATE_0;
  83 +
  84 + // Rotating the video with ExoPlayer does not seem to be possible with a Surface,
  85 + // so inform the Flutter code that the widget needs to be rotated to prevent
  86 + // upside-down playback for videos with unappliedRotationDegrees of 180 (other orientations
  87 + // work correctly without correction).
  88 + if (unappliedRotationDegrees == RotationDegrees.ROTATE_180) {
  89 + rotationCorrection = unappliedRotationDegrees;
  90 + }
  91 +
  92 + return rotationCorrection;
  93 + }
  94 +
  95 + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class)
  96 + // A video's Format and its rotation degrees are unstable because they are not guaranteed
  97 + // the same implementation across API versions. It is possible that this logic may need
  98 + // revisiting should the implementation change across versions of the Exoplayer API.
  99 + private int getRotationCorrectionFromFormat(ExoPlayer exoPlayer) {
  100 + Format videoFormat = Objects.requireNonNull(exoPlayer.getVideoFormat());
  101 + return videoFormat.rotationDegrees;
  102 + }
  103 +}
packages/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer.texture;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.annotation.Nullable;
  10 +import androidx.annotation.RestrictTo;
  11 +import androidx.annotation.VisibleForTesting;
  12 +import androidx.media3.common.MediaItem;
  13 +import androidx.media3.exoplayer.DefaultRenderersFactory;
  14 +import androidx.media3.exoplayer.ExoPlayer;
  15 +import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
  16 +import io.flutter.plugins.videoplayer.ExoPlayerState;
  17 +import io.flutter.plugins.videoplayer.VideoAsset;
  18 +import io.flutter.plugins.videoplayer.VideoPlayer;
  19 +import io.flutter.plugins.videoplayer.VideoPlayerCallbacks;
  20 +import io.flutter.plugins.videoplayer.VideoPlayerOptions;
  21 +import io.flutter.view.TextureRegistry.SurfaceProducer;
  22 +
  23 +/**
  24 + * A subclass of {@link VideoPlayer} that adds functionality related to texture view as a way of
  25 + * displaying the video in the app.
  26 + *
  27 + * <p>It manages the lifecycle of the texture and ensures that the video is properly displayed on
  28 + * the texture.
  29 + */
  30 +public final class TextureVideoPlayer extends VideoPlayer implements SurfaceProducer.Callback {
  31 + @Nullable private ExoPlayerState savedStateDuring;
  32 +
  33 + /**
  34 + * Creates a texture video player.
  35 + *
  36 + * @param context application context.
  37 + * @param events event callbacks.
  38 + * @param surfaceProducer produces a texture to render to.
  39 + * @param asset asset to play.
  40 + * @param options options for playback.
  41 + * @return a video player instance.
  42 + */
  43 + @NonNull
  44 + public static TextureVideoPlayer create(
  45 + @NonNull Context context,
  46 + @NonNull VideoPlayerCallbacks events,
  47 + @NonNull SurfaceProducer surfaceProducer,
  48 + @NonNull VideoAsset asset,
  49 + @NonNull VideoPlayerOptions options) {
  50 + return new TextureVideoPlayer(
  51 + events,
  52 + surfaceProducer,
  53 + asset.getMediaItem(),
  54 + options,
  55 + () -> {
  56 + DefaultRenderersFactory renderersFactory =
  57 + new DefaultRenderersFactory(context).setEnableDecoderFallback(true);
  58 + ExoPlayer.Builder builder =
  59 + new ExoPlayer.Builder(context, renderersFactory)
  60 + .setMediaSourceFactory(asset.getMediaSourceFactory(context));
  61 + return builder.build();
  62 + });
  63 + }
  64 +
  65 + @VisibleForTesting
  66 + public TextureVideoPlayer(
  67 + @NonNull VideoPlayerCallbacks events,
  68 + @NonNull SurfaceProducer surfaceProducer,
  69 + @NonNull MediaItem mediaItem,
  70 + @NonNull VideoPlayerOptions options,
  71 + @NonNull ExoPlayerProvider exoPlayerProvider) {
  72 + super(events, mediaItem, options, surfaceProducer, exoPlayerProvider);
  73 +
  74 + surfaceProducer.setCallback(this);
  75 +
  76 + this.exoPlayer.setVideoSurface(surfaceProducer.getSurface());
  77 + }
  78 +
  79 + @NonNull
  80 + @Override
  81 + protected ExoPlayerEventListener createExoPlayerEventListener(
  82 + @NonNull ExoPlayer exoPlayer, @Nullable SurfaceProducer surfaceProducer) {
  83 + if (surfaceProducer == null) {
  84 + throw new IllegalArgumentException(
  85 + "surfaceProducer cannot be null to create an ExoPlayerEventListener for TextureVideoPlayer.");
  86 + }
  87 + boolean surfaceProducerHandlesCropAndRotation = surfaceProducer.handlesCropAndRotation();
  88 + return new TextureExoPlayerEventListener(
  89 + exoPlayer,
  90 + videoPlayerEvents,
  91 + surfaceProducerHandlesCropAndRotation,
  92 + playerHasBeenSuspended());
  93 + }
  94 +
  95 + @RestrictTo(RestrictTo.Scope.LIBRARY)
  96 + public void onSurfaceAvailable() {
  97 + if (savedStateDuring != null) {
  98 + exoPlayer = createVideoPlayer();
  99 + exoPlayer.setVideoSurface(surfaceProducer.getSurface());
  100 + savedStateDuring.restore(exoPlayer);
  101 + savedStateDuring = null;
  102 + }
  103 + }
  104 +
  105 + @RestrictTo(RestrictTo.Scope.LIBRARY)
  106 + // TODO(bparrishMines): Replace with onSurfaceCleanup once available on stable. See
  107 + // https://github.com/flutter/flutter/issues/161256.
  108 + @SuppressWarnings({"deprecation", "removal"})
  109 + public void onSurfaceDestroyed() {
  110 + // Intentionally do not call pause/stop here, because the surface has already been released
  111 + // at this point (see https://github.com/flutter/flutter/issues/156451).
  112 + savedStateDuring = ExoPlayerState.save(exoPlayer);
  113 + exoPlayer.release();
  114 + }
  115 +
  116 + private boolean playerHasBeenSuspended() {
  117 + return savedStateDuring != null;
  118 + }
  119 +
  120 + public void dispose() {
  121 + // Super must be called first to ensure the player is released before the surface.
  122 + super.dispose();
  123 +
  124 + surfaceProducer.release();
  125 + // TODO(matanlurey): Remove when embedder no longer calls-back once released.
  126 + // https://github.com/flutter/flutter/issues/156434.
  127 + surfaceProducer.setCallback(null);
  128 + }
  129 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/ExoPlayerEventListenerTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.mockito.ArgumentMatchers.contains;
  8 +import static org.mockito.ArgumentMatchers.eq;
  9 +import static org.mockito.ArgumentMatchers.isNull;
  10 +import static org.mockito.Mockito.verify;
  11 +import static org.mockito.Mockito.verifyNoInteractions;
  12 +import static org.mockito.Mockito.verifyNoMoreInteractions;
  13 +import static org.mockito.Mockito.when;
  14 +
  15 +import androidx.media3.common.PlaybackException;
  16 +import androidx.media3.common.Player;
  17 +import androidx.media3.exoplayer.ExoPlayer;
  18 +import org.junit.Before;
  19 +import org.junit.Rule;
  20 +import org.junit.Test;
  21 +import org.junit.runner.RunWith;
  22 +import org.mockito.Mock;
  23 +import org.mockito.junit.MockitoJUnit;
  24 +import org.mockito.junit.MockitoRule;
  25 +import org.robolectric.RobolectricTestRunner;
  26 +
  27 +/**
  28 + * Unit tests for {@link ExoPlayerEventListener}.
  29 + *
  30 + * <p>This test suite <em>narrowly verifies</em> that the events emitted by the underlying {@link
  31 + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect
  32 + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected).
  33 + */
  34 +@RunWith(RobolectricTestRunner.class)
  35 +public final class ExoPlayerEventListenerTest {
  36 + @Mock private ExoPlayer mockExoPlayer;
  37 + @Mock private VideoPlayerCallbacks mockCallbacks;
  38 + private ExoPlayerEventListener eventListener;
  39 +
  40 + @Rule public MockitoRule initRule = MockitoJUnit.rule();
  41 +
  42 + /**
  43 + * A test subclass of {@link ExoPlayerEventListener} that exposes the abstract class for testing.
  44 + */
  45 + private static final class TestExoPlayerEventListener extends ExoPlayerEventListener {
  46 + public TestExoPlayerEventListener(ExoPlayer exoPlayer, VideoPlayerCallbacks callbacks) {
  47 + super(exoPlayer, callbacks, false);
  48 + }
  49 +
  50 + @Override
  51 + protected void sendInitialized() {
  52 + // No implementation needed.
  53 + }
  54 + }
  55 +
  56 + @Before
  57 + public void setUp() {
  58 + eventListener = new TestExoPlayerEventListener(mockExoPlayer, mockCallbacks);
  59 + }
  60 +
  61 + @Test
  62 + public void onPlaybackStateChangedBufferingSendsBufferingStartAndUpdates() {
  63 + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L);
  64 + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING);
  65 +
  66 + verify(mockCallbacks).onBufferingStart();
  67 + verify(mockCallbacks).onBufferingUpdate(10L);
  68 + verifyNoMoreInteractions(mockCallbacks);
  69 +
  70 + // If it's invoked again, only the update event is called.
  71 + verify(mockCallbacks).onBufferingUpdate(10L);
  72 + verifyNoMoreInteractions(mockCallbacks);
  73 + }
  74 +
  75 + @Test
  76 + public void onPlaybackStateChangedEndedSendsOnCompleted() {
  77 + eventListener.onPlaybackStateChanged(Player.STATE_ENDED);
  78 +
  79 + verify(mockCallbacks).onCompleted();
  80 + verifyNoMoreInteractions(mockCallbacks);
  81 + }
  82 +
  83 + @Test
  84 + public void onPlaybackStateChangedEndedAfterBufferingSendsBufferingEndAndOnCompleted() {
  85 + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L);
  86 + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING);
  87 + verify(mockCallbacks).onBufferingStart();
  88 + verify(mockCallbacks).onBufferingUpdate(10L);
  89 +
  90 + eventListener.onPlaybackStateChanged(Player.STATE_ENDED);
  91 + verify(mockCallbacks).onCompleted();
  92 + verify(mockCallbacks).onBufferingEnd();
  93 +
  94 + verifyNoMoreInteractions(mockCallbacks);
  95 + }
  96 +
  97 + @Test
  98 + public void onPlaybackStateChangedIdleDoNothing() {
  99 + eventListener.onPlaybackStateChanged(Player.STATE_IDLE);
  100 +
  101 + verifyNoInteractions(mockCallbacks);
  102 + }
  103 +
  104 + @Test
  105 + public void onPlaybackStateChangedIdleAfterBufferingSendsBufferingEnd() {
  106 + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L);
  107 + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING);
  108 + verify(mockCallbacks).onBufferingStart();
  109 + verify(mockCallbacks).onBufferingUpdate(10L);
  110 +
  111 + eventListener.onPlaybackStateChanged(Player.STATE_IDLE);
  112 + verify(mockCallbacks).onBufferingEnd();
  113 +
  114 + verifyNoMoreInteractions(mockCallbacks);
  115 + }
  116 +
  117 + @Test
  118 + public void onErrorVideoErrorWhenBufferingInProgressAlsoEndBuffering() {
  119 + when(mockExoPlayer.getBufferedPosition()).thenReturn(10L);
  120 + eventListener.onPlaybackStateChanged(Player.STATE_BUFFERING);
  121 + verify(mockCallbacks).onBufferingStart();
  122 + verify(mockCallbacks).onBufferingUpdate(10L);
  123 +
  124 + eventListener.onPlayerError(
  125 + new PlaybackException("BAD", null, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED));
  126 + verify(mockCallbacks).onBufferingEnd();
  127 + verify(mockCallbacks).onError(eq("VideoError"), contains("BAD"), isNull());
  128 + }
  129 +
  130 + @Test
  131 + public void onErrorBehindLiveWindowSeekToDefaultAndPrepare() {
  132 + eventListener.onPlayerError(
  133 + new PlaybackException("SORT_OF_OK", null, PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW));
  134 +
  135 + verify(mockExoPlayer).seekToDefaultPosition();
  136 + verify(mockExoPlayer).prepare();
  137 + verifyNoInteractions(mockCallbacks);
  138 + }
  139 +
  140 + @Test
  141 + public void onIsPlayingChangedToggled() {
  142 + eventListener.onIsPlayingChanged(true);
  143 + verify(mockCallbacks).onIsPlayingStateUpdate(true);
  144 +
  145 + eventListener.onIsPlayingChanged(false);
  146 + verify(mockCallbacks).onIsPlayingStateUpdate(false);
  147 + }
  148 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/FakeVideoAsset.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import android.content.Context;
  8 +import androidx.annotation.NonNull;
  9 +import androidx.media3.common.MediaItem;
  10 +import androidx.media3.exoplayer.source.MediaSource;
  11 +import androidx.media3.test.utils.FakeMediaSourceFactory;
  12 +
  13 +/** A fake implementation of the {@link VideoAsset} class. */
  14 +final class FakeVideoAsset extends VideoAsset {
  15 + @NonNull private final MediaSource.Factory mediaSourceFactory;
  16 +
  17 + FakeVideoAsset(String assetUrl) {
  18 + this(assetUrl, new FakeMediaSourceFactory());
  19 + }
  20 +
  21 + FakeVideoAsset(String assetUrl, @NonNull MediaSource.Factory mediaSourceFactory) {
  22 + super(assetUrl);
  23 + this.mediaSourceFactory = mediaSourceFactory;
  24 + }
  25 +
  26 + @NonNull
  27 + @Override
  28 + public MediaItem getMediaItem() {
  29 + return new MediaItem.Builder().setUri(assetUrl).build();
  30 + }
  31 +
  32 + @NonNull
  33 + @Override
  34 + public MediaSource.Factory getMediaSourceFactory(@NonNull Context context) {
  35 + return mediaSourceFactory;
  36 + }
  37 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewFactoryTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.junit.Assert.assertTrue;
  8 +import static org.mockito.Mockito.*;
  9 +
  10 +import android.content.Context;
  11 +import androidx.media3.exoplayer.ExoPlayer;
  12 +import io.flutter.plugin.platform.PlatformView;
  13 +import io.flutter.plugins.videoplayer.platformview.PlatformVideoView;
  14 +import io.flutter.plugins.videoplayer.platformview.PlatformVideoViewFactory;
  15 +import org.junit.Test;
  16 +
  17 +public class PlatformVideoViewFactoryTest {
  18 + @Test
  19 + public void createsPlatformVideoViewBasedOnSuppliedArguments() {
  20 + final PlatformVideoViewFactory.VideoPlayerProvider videoPlayerProvider =
  21 + mock(PlatformVideoViewFactory.VideoPlayerProvider.class);
  22 + final VideoPlayer videoPlayer = mock(VideoPlayer.class);
  23 + final ExoPlayer exoPlayer = mock(ExoPlayer.class);
  24 + final Context context = mock(Context.class);
  25 + final long playerId = 1L;
  26 +
  27 + when(videoPlayerProvider.getVideoPlayer(playerId)).thenReturn(videoPlayer);
  28 + when(videoPlayer.getExoPlayer()).thenReturn(exoPlayer);
  29 +
  30 + final PlatformVideoViewFactory factory = new PlatformVideoViewFactory(videoPlayerProvider);
  31 + final Messages.PlatformVideoViewCreationParams args =
  32 + new Messages.PlatformVideoViewCreationParams.Builder().setPlayerId(playerId).build();
  33 +
  34 + final PlatformView view = factory.create(context, 0, args);
  35 +
  36 + assertTrue(view instanceof PlatformVideoView);
  37 + verify(videoPlayerProvider).getVideoPlayer(playerId);
  38 + verify(videoPlayer).getExoPlayer();
  39 + }
  40 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformVideoViewTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.junit.Assert.assertNotNull;
  8 +import static org.mockito.Mockito.*;
  9 +
  10 +import android.content.Context;
  11 +import android.view.SurfaceView;
  12 +import androidx.media3.exoplayer.ExoPlayer;
  13 +import io.flutter.plugins.videoplayer.platformview.PlatformVideoView;
  14 +import java.lang.reflect.Field;
  15 +import org.junit.Test;
  16 +
  17 +/** Unit tests for {@link PlatformVideoViewTest}. */
  18 +public class PlatformVideoViewTest {
  19 + @Test
  20 + public void createsSurfaceViewAndSetsItForExoPlayer() throws Exception {
  21 + final Context mockContext = mock(Context.class);
  22 + final ExoPlayer mockExoPlayer = mock(ExoPlayer.class);
  23 +
  24 + final PlatformVideoView view = new PlatformVideoView(mockContext, mockExoPlayer);
  25 +
  26 + final Field field = PlatformVideoView.class.getDeclaredField("surfaceView");
  27 + field.setAccessible(true);
  28 + final SurfaceView surfaceView = (SurfaceView) field.get(view);
  29 +
  30 + assertNotNull(surfaceView);
  31 + verify(mockExoPlayer).setVideoSurfaceView(surfaceView);
  32 + }
  33 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/PlatformViewExoPlayerEventListenerTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.mockito.Mockito.verify;
  8 +import static org.mockito.Mockito.when;
  9 +
  10 +import androidx.media3.common.Format;
  11 +import androidx.media3.common.Player;
  12 +import androidx.media3.exoplayer.ExoPlayer;
  13 +import io.flutter.plugins.videoplayer.platformview.PlatformViewExoPlayerEventListener;
  14 +import org.junit.Before;
  15 +import org.junit.Rule;
  16 +import org.junit.Test;
  17 +import org.junit.runner.RunWith;
  18 +import org.mockito.Mock;
  19 +import org.mockito.junit.MockitoJUnit;
  20 +import org.mockito.junit.MockitoRule;
  21 +import org.robolectric.RobolectricTestRunner;
  22 +
  23 +/**
  24 + * Unit tests for {@link PlatformViewExoPlayerEventListener}.
  25 + *
  26 + * <p>This test suite <em>narrowly verifies</em> that the events emitted by the underlying {@link
  27 + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect
  28 + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected).
  29 + */
  30 +@RunWith(RobolectricTestRunner.class)
  31 +public final class PlatformViewExoPlayerEventListenerTest {
  32 + @Mock private ExoPlayer mockExoPlayer;
  33 + @Mock private VideoPlayerCallbacks mockCallbacks;
  34 + private ExoPlayerEventListener eventListener;
  35 +
  36 + @Rule public MockitoRule initRule = MockitoJUnit.rule();
  37 +
  38 + @Before
  39 + public void setUp() {
  40 + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks);
  41 + }
  42 +
  43 + @Test
  44 + public void onPlaybackStateChangedReadySendInitialized() {
  45 + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks);
  46 +
  47 + Format format = new Format.Builder().setWidth(800).setHeight(400).build();
  48 + when(mockExoPlayer.getVideoFormat()).thenReturn(format);
  49 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  50 +
  51 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  52 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  53 + }
  54 +
  55 + @Test
  56 + public void onPlaybackStateChangedReadyInPortraitMode90DegreesSwapsWidthAndHeight() {
  57 + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks);
  58 +
  59 + Format format =
  60 + new Format.Builder().setWidth(800).setHeight(400).setRotationDegrees(90).build();
  61 + when(mockExoPlayer.getVideoFormat()).thenReturn(format);
  62 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  63 +
  64 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  65 + verify(mockCallbacks).onInitialized(400, 800, 10L, 0);
  66 + }
  67 +
  68 + @Test
  69 + public void onPlaybackStateChangedReadyInPortraitMode270DegreesSwapsWidthAndHeight() {
  70 + eventListener = new PlatformViewExoPlayerEventListener(mockExoPlayer, mockCallbacks);
  71 +
  72 + Format format =
  73 + new Format.Builder().setWidth(800).setHeight(400).setRotationDegrees(270).build();
  74 + when(mockExoPlayer.getVideoFormat()).thenReturn(format);
  75 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  76 +
  77 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  78 + verify(mockCallbacks).onInitialized(400, 800, 10L, 0);
  79 + }
  80 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureExoPlayerEventListenerTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.mockito.Mockito.verify;
  8 +import static org.mockito.Mockito.when;
  9 +
  10 +import androidx.media3.common.Format;
  11 +import androidx.media3.common.Player;
  12 +import androidx.media3.common.VideoSize;
  13 +import androidx.media3.exoplayer.ExoPlayer;
  14 +import io.flutter.plugins.videoplayer.texture.TextureExoPlayerEventListener;
  15 +import org.junit.Rule;
  16 +import org.junit.Test;
  17 +import org.junit.runner.RunWith;
  18 +import org.mockito.Mock;
  19 +import org.mockito.junit.MockitoJUnit;
  20 +import org.mockito.junit.MockitoRule;
  21 +import org.robolectric.RobolectricTestRunner;
  22 +import org.robolectric.annotation.Config;
  23 +
  24 +/**
  25 + * Unit tests for {@link TextureExoPlayerEventListener}.
  26 + *
  27 + * <p>This test suite <em>narrowly verifies</em> that the events emitted by the underlying {@link
  28 + * androidx.media3.exoplayer.ExoPlayer} instance are translated to the callback interface we expect
  29 + * ({@link VideoPlayerCallbacks} and/or interface with the player instance as expected).
  30 + */
  31 +@RunWith(RobolectricTestRunner.class)
  32 +public class TextureExoPlayerEventListenerTest {
  33 + @Mock private ExoPlayer mockExoPlayer;
  34 + @Mock private VideoPlayerCallbacks mockCallbacks;
  35 +
  36 + @Rule public MockitoRule initRule = MockitoJUnit.rule();
  37 +
  38 + @Test
  39 + @Config(maxSdk = 21)
  40 + public void onPlaybackStateChangedReadySendInitialized_belowAndroid21() {
  41 + TextureExoPlayerEventListener eventListener =
  42 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  43 + VideoSize size = new VideoSize(800, 400, 0, 0);
  44 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  45 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  46 +
  47 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  48 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  49 + }
  50 +
  51 + @Test
  52 + @Config(minSdk = 22)
  53 + public void
  54 + onPlaybackStateChangedReadySendInitialized_whenSurfaceProducerHandlesCropAndRotation() {
  55 + TextureExoPlayerEventListener eventListener =
  56 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  57 + VideoSize size = new VideoSize(800, 400, 0, 0);
  58 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  59 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  60 +
  61 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  62 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  63 + }
  64 +
  65 + @Test
  66 + @Config(minSdk = 22)
  67 + public void
  68 + onPlaybackStateChangedReadySendInitializedWithRotationCorrectionAndWidthAndHeightSwap_whenSurfaceProducerDoesNotHandleCropAndRotation() {
  69 + TextureExoPlayerEventListener eventListener =
  70 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false);
  71 + VideoSize size = new VideoSize(800, 400, 0, 0);
  72 + int rotationCorrection = 90;
  73 + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
  74 +
  75 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  76 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  77 + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
  78 +
  79 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  80 + verify(mockCallbacks).onInitialized(800, 400, 10L, rotationCorrection);
  81 + }
  82 +
  83 + @Test
  84 + @Config(maxSdk = 21)
  85 + public void
  86 + onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight_belowAndroid21() {
  87 + TextureExoPlayerEventListener eventListener =
  88 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  89 + VideoSize size = new VideoSize(800, 400, 90, 0);
  90 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  91 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  92 +
  93 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  94 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  95 + }
  96 +
  97 + @Test
  98 + @Config(minSdk = 22)
  99 + public void
  100 + onPlaybackStateChangedReadyInPortraitMode90DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerHandlesCropAndRotation() {
  101 + TextureExoPlayerEventListener eventListener =
  102 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  103 + VideoSize size = new VideoSize(800, 400, 90, 0);
  104 +
  105 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  106 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  107 +
  108 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  109 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  110 + }
  111 +
  112 + @Test
  113 + @Config(minSdk = 22)
  114 + public void
  115 + onPlaybackStateChangedReadyInPortraitMode90DegreesSwapWidthAndHeight_whenSurfaceProducerDoesNotHandleCropAndRotation() {
  116 + TextureExoPlayerEventListener eventListener =
  117 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false);
  118 + VideoSize size = new VideoSize(800, 400, 0, 0);
  119 + int rotationCorrection = 90;
  120 + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
  121 +
  122 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  123 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  124 + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
  125 +
  126 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  127 + verify(mockCallbacks).onInitialized(800, 400, 10L, 90);
  128 + }
  129 +
  130 + @Test
  131 + @Config(maxSdk = 21)
  132 + public void
  133 + onPlaybackStateChangedReadyInPortraitMode270DegreesSwapWidthAndHeight_belowAndroid21() {
  134 + TextureExoPlayerEventListener eventListener =
  135 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  136 + VideoSize size = new VideoSize(800, 400, 270, 0);
  137 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  138 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  139 +
  140 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  141 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  142 + }
  143 +
  144 + @Test
  145 + @Config(minSdk = 22)
  146 + public void
  147 + onPlaybackStateChangedReadyInPortraitMode270DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerHandlesCropAndRotation() {
  148 + TextureExoPlayerEventListener eventListener =
  149 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  150 + VideoSize size = new VideoSize(800, 400, 270, 0);
  151 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  152 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  153 +
  154 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  155 + verify(mockCallbacks).onInitialized(800, 400, 10L, 0);
  156 + }
  157 +
  158 + @Test
  159 + @Config(minSdk = 22)
  160 + public void
  161 + onPlaybackStateChangedReadyInPortraitMode270DegreesDoesNotSwapWidthAndHeight_whenSurfaceProducerDoesNotHandleCropAndRotation() {
  162 + TextureExoPlayerEventListener eventListener =
  163 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, false);
  164 + VideoSize size = new VideoSize(800, 400, 0, 0);
  165 + int rotationCorrection = 270;
  166 + Format videoFormat = new Format.Builder().setRotationDegrees(rotationCorrection).build();
  167 +
  168 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  169 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  170 + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
  171 +
  172 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  173 + verify(mockCallbacks).onInitialized(800, 400, 10L, 270);
  174 + }
  175 +
  176 + @Test
  177 + @Config(maxSdk = 21)
  178 + public void onPlaybackStateChangedReadyFlipped180DegreesInformEventHandler_belowAndroid21() {
  179 + TextureExoPlayerEventListener eventListener =
  180 + new TextureExoPlayerEventListener(mockExoPlayer, mockCallbacks, true);
  181 + VideoSize size = new VideoSize(800, 400, 180, 0);
  182 + when(mockExoPlayer.getVideoSize()).thenReturn(size);
  183 + when(mockExoPlayer.getDuration()).thenReturn(10L);
  184 +
  185 + eventListener.onPlaybackStateChanged(Player.STATE_READY);
  186 + verify(mockCallbacks).onInitialized(800, 400, 10L, 180);
  187 + }
  188 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/TextureVideoPlayerTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.junit.Assert.assertEquals;
  8 +import static org.mockito.ArgumentMatchers.any;
  9 +import static org.mockito.Mockito.*;
  10 +
  11 +import android.view.Surface;
  12 +import androidx.media3.common.AudioAttributes;
  13 +import androidx.media3.common.C;
  14 +import androidx.media3.common.PlaybackParameters;
  15 +import androidx.media3.common.Player;
  16 +import androidx.media3.common.VideoSize;
  17 +import androidx.media3.exoplayer.ExoPlayer;
  18 +import io.flutter.plugins.videoplayer.texture.TextureVideoPlayer;
  19 +import io.flutter.view.TextureRegistry;
  20 +import org.junit.Before;
  21 +import org.junit.Rule;
  22 +import org.junit.Test;
  23 +import org.junit.runner.RunWith;
  24 +import org.mockito.ArgumentCaptor;
  25 +import org.mockito.Captor;
  26 +import org.mockito.InOrder;
  27 +import org.mockito.Mock;
  28 +import org.mockito.junit.MockitoJUnit;
  29 +import org.mockito.junit.MockitoRule;
  30 +import org.robolectric.RobolectricTestRunner;
  31 +
  32 +/**
  33 + * Unit tests for {@link TextureVideoPlayer}.
  34 + *
  35 + * <p>This test suite <em>narrowly verifies</em> that {@link TextureVideoPlayer} interfaces with the
  36 + * {@link ExoPlayer} interface <em>exactly</em> as it did when the test suite was created. That is,
  37 + * if the behavior changes, this test will need to change. However, this suite should catch bugs
  38 + * related to <em>"this is a safe refactor with no behavior changes"</em>.
  39 + */
  40 +@RunWith(RobolectricTestRunner.class)
  41 +public final class TextureVideoPlayerTest {
  42 + private static final String FAKE_ASSET_URL = "https://flutter.dev/movie.mp4";
  43 + private FakeVideoAsset fakeVideoAsset;
  44 +
  45 + @Mock private VideoPlayerCallbacks mockEvents;
  46 + @Mock private TextureRegistry.SurfaceProducer mockProducer;
  47 + @Mock private ExoPlayer mockExoPlayer;
  48 + @Captor private ArgumentCaptor<AudioAttributes> attributesCaptor;
  49 + @Captor private ArgumentCaptor<TextureRegistry.SurfaceProducer.Callback> callbackCaptor;
  50 + @Captor private ArgumentCaptor<Player.Listener> listenerCaptor;
  51 +
  52 + @Rule public MockitoRule initRule = MockitoJUnit.rule();
  53 +
  54 + @Before
  55 + public void setUp() {
  56 + fakeVideoAsset = new FakeVideoAsset(FAKE_ASSET_URL);
  57 + when(mockProducer.getSurface()).thenReturn(mock(Surface.class));
  58 + when(mockProducer.handlesCropAndRotation()).thenReturn(true);
  59 + }
  60 +
  61 + private VideoPlayer createVideoPlayer() {
  62 + return createVideoPlayer(new VideoPlayerOptions());
  63 + }
  64 +
  65 + private TextureVideoPlayer createVideoPlayer(VideoPlayerOptions options) {
  66 + return new TextureVideoPlayer(
  67 + mockEvents, mockProducer, fakeVideoAsset.getMediaItem(), options, () -> mockExoPlayer);
  68 + }
  69 +
  70 + @Test
  71 + public void loadsAndPreparesProvidedMediaEnablesAudioFocusByDefault() {
  72 + VideoPlayer videoPlayer = createVideoPlayer();
  73 +
  74 + verify(mockExoPlayer).setMediaItem(fakeVideoAsset.getMediaItem());
  75 + verify(mockExoPlayer).prepare();
  76 + verify(mockProducer).getSurface();
  77 + verify(mockExoPlayer).setVideoSurface(any());
  78 +
  79 + verify(mockExoPlayer).setAudioAttributes(attributesCaptor.capture(), eq(true));
  80 + assertEquals(attributesCaptor.getValue().contentType, C.AUDIO_CONTENT_TYPE_MOVIE);
  81 +
  82 + videoPlayer.dispose();
  83 + }
  84 +
  85 + @Test
  86 + public void onSurfaceProducerDestroyedAndAvailableReleasesAndThenRecreatesAndResumesPlayer() {
  87 + VideoPlayer videoPlayer = createVideoPlayer();
  88 +
  89 + verify(mockProducer).setCallback(callbackCaptor.capture());
  90 + verify(mockExoPlayer, never()).release();
  91 +
  92 + when(mockExoPlayer.getCurrentPosition()).thenReturn(10L);
  93 + when(mockExoPlayer.getRepeatMode()).thenReturn(Player.REPEAT_MODE_ALL);
  94 + when(mockExoPlayer.getVolume()).thenReturn(0.5f);
  95 + when(mockExoPlayer.getPlaybackParameters()).thenReturn(new PlaybackParameters(2.5f));
  96 +
  97 + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue();
  98 + simulateSurfaceDestruction(producerLifecycle);
  99 +
  100 + verify(mockExoPlayer).release();
  101 +
  102 + // Create a new mock exo player so that we get a new instance.
  103 + mockExoPlayer = mock(ExoPlayer.class);
  104 + producerLifecycle.onSurfaceAvailable();
  105 +
  106 + verify(mockExoPlayer).setVideoSurface(any());
  107 + verify(mockExoPlayer).seekTo(10L);
  108 + verify(mockExoPlayer).setRepeatMode(Player.REPEAT_MODE_ALL);
  109 + verify(mockExoPlayer).setVolume(0.5f);
  110 + verify(mockExoPlayer).setPlaybackParameters(new PlaybackParameters(2.5f));
  111 +
  112 + videoPlayer.dispose();
  113 + }
  114 +
  115 + @Test
  116 + public void onSurfaceProducerDestroyedDoesNotStopOrPauseVideo() {
  117 + VideoPlayer videoPlayer = createVideoPlayer();
  118 +
  119 + verify(mockProducer).setCallback(callbackCaptor.capture());
  120 + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue();
  121 + simulateSurfaceDestruction(producerLifecycle);
  122 +
  123 + verify(mockExoPlayer, never()).stop();
  124 + verify(mockExoPlayer, never()).pause();
  125 + verify(mockExoPlayer, never()).setPlayWhenReady(anyBoolean());
  126 +
  127 + videoPlayer.dispose();
  128 + }
  129 +
  130 + @Test
  131 + public void onDisposeSurfaceProducerCallbackIsDisconnected() {
  132 + // Regression test for https://github.com/flutter/flutter/issues/156158.
  133 + VideoPlayer videoPlayer = createVideoPlayer();
  134 + verify(mockProducer).setCallback(any());
  135 +
  136 + videoPlayer.dispose();
  137 + verify(mockProducer).setCallback(null);
  138 + }
  139 +
  140 + @Test
  141 + public void onInitializedCalledWhenVideoPlayerInitiallyCreated() {
  142 + VideoPlayer videoPlayer = createVideoPlayer();
  143 +
  144 + // Pretend we have a video, and capture the registered event listener.
  145 + when(mockExoPlayer.getVideoSize()).thenReturn(new VideoSize(300, 200));
  146 + verify(mockExoPlayer).addListener(listenerCaptor.capture());
  147 + Player.Listener listener = listenerCaptor.getValue();
  148 +
  149 + // Trigger an event that would trigger onInitialized.
  150 + listener.onPlaybackStateChanged(Player.STATE_READY);
  151 + verify(mockEvents).onInitialized(anyInt(), anyInt(), anyLong(), anyInt());
  152 +
  153 + videoPlayer.dispose();
  154 + }
  155 +
  156 + @Test
  157 + public void onSurfaceAvailableDoesNotSendInitializeEventAgain() {
  158 + // The VideoPlayer contract assumes that the event "initialized" is sent exactly once
  159 + // (duplicate events cause an error to be thrown at the shared Dart layer). This test verifies
  160 + // that the onInitialized event is sent exactly once per player.
  161 + //
  162 + // Regression test for https://github.com/flutter/flutter/issues/154602.
  163 + VideoPlayer videoPlayer = createVideoPlayer();
  164 + when(mockExoPlayer.getVideoSize()).thenReturn(new VideoSize(300, 200));
  165 +
  166 + // Capture the lifecycle events so we can simulate onSurfaceAvailableDestroyed.
  167 + verify(mockProducer).setCallback(callbackCaptor.capture());
  168 + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue();
  169 +
  170 + // Trigger destroyed/available.
  171 + simulateSurfaceDestruction(producerLifecycle);
  172 + producerLifecycle.onSurfaceAvailable();
  173 +
  174 + // Initial listener, and the new one from the resume.
  175 + verify(mockExoPlayer, times(2)).addListener(listenerCaptor.capture());
  176 + Player.Listener listener = listenerCaptor.getValue();
  177 +
  178 + // Now trigger that same event, which would happen in the case of a background/resume.
  179 + listener.onPlaybackStateChanged(Player.STATE_READY);
  180 +
  181 + // Was not called because it was a result of a background/resume.
  182 + verify(mockEvents, never()).onInitialized(anyInt(), anyInt(), anyLong(), anyInt());
  183 +
  184 + videoPlayer.dispose();
  185 + }
  186 +
  187 + @Test
  188 + public void onSurfaceAvailableWithoutDestroyDoesNotRecreate() {
  189 + // Initially create the video player, which creates the initial surface.
  190 + VideoPlayer videoPlayer = createVideoPlayer();
  191 + verify(mockProducer).getSurface();
  192 + verify(mockProducer).handlesCropAndRotation();
  193 +
  194 + // Capture the lifecycle events so we can simulate onSurfaceAvailable/Destroyed.
  195 + verify(mockProducer).setCallback(callbackCaptor.capture());
  196 + TextureRegistry.SurfaceProducer.Callback producerLifecycle = callbackCaptor.getValue();
  197 +
  198 + // Calling onSurfaceAvailable does not do anything, since the surface was never destroyed.
  199 + producerLifecycle.onSurfaceAvailable();
  200 + verifyNoMoreInteractions(mockProducer);
  201 +
  202 + videoPlayer.dispose();
  203 + }
  204 +
  205 + @Test
  206 + public void disposeReleasesExoPlayerBeforeTexture() {
  207 + VideoPlayer videoPlayer = createVideoPlayer();
  208 +
  209 + videoPlayer.dispose();
  210 +
  211 + // Regression test for https://github.com/flutter/flutter/issues/156158.
  212 + // The player must be destroyed before the surface it is writing to.
  213 + InOrder inOrder = inOrder(mockExoPlayer, mockProducer);
  214 + inOrder.verify(mockExoPlayer).release();
  215 + inOrder.verify(mockProducer).release();
  216 + }
  217 +
  218 + // TODO(matanlurey): Replace with inline calls to onSurfaceAvailable once
  219 + // available on stable; see https://github.com/flutter/flutter/issues/155131.
  220 + // This separate method only exists to scope the suppression.
  221 + @SuppressWarnings({"deprecation", "removal"})
  222 + void simulateSurfaceCreation(TextureRegistry.SurfaceProducer.Callback producerLifecycle) {
  223 + producerLifecycle.onSurfaceCreated();
  224 + }
  225 +
  226 + // TODO(bparrishMines): Replace with inline calls to onSurfaceCleanup once available on stable;
  227 + // see https://github.com/flutter/flutter/issues/16125. This separate method only exists to scope
  228 + // the suppression.
  229 + @SuppressWarnings({"deprecation", "removal"})
  230 + void simulateSurfaceDestruction(TextureRegistry.SurfaceProducer.Callback producerLifecycle) {
  231 + producerLifecycle.onSurfaceDestroyed();
  232 + }
  233 +}
packages/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoAssetTest.java 0 → 100644
  1 +// Copyright 2013 The Flutter Authors. All rights reserved.
  2 +// Use of this source code is governed by a BSD-style license that can be
  3 +// found in the LICENSE file.
  4 +
  5 +package io.flutter.plugins.videoplayer;
  6 +
  7 +import static org.junit.Assert.assertEquals;
  8 +import static org.junit.Assert.assertThrows;
  9 +import static org.mockito.ArgumentMatchers.anyBoolean;
  10 +import static org.mockito.ArgumentMatchers.anyMap;
  11 +import static org.mockito.ArgumentMatchers.anyString;
  12 +import static org.mockito.Mockito.mock;
  13 +import static org.mockito.Mockito.never;
  14 +import static org.mockito.Mockito.verify;
  15 +import static org.mockito.Mockito.when;
  16 +
  17 +import android.net.Uri;
  18 +import androidx.media3.common.MediaItem;
  19 +import androidx.media3.datasource.DefaultHttpDataSource;
  20 +import androidx.media3.exoplayer.source.MediaSource;
  21 +import androidx.test.core.app.ApplicationProvider;
  22 +import java.util.HashMap;
  23 +import java.util.Map;
  24 +import java.util.Objects;
  25 +import org.junit.Test;
  26 +import org.junit.runner.RunWith;
  27 +import org.robolectric.RobolectricTestRunner;
  28 +
  29 +/**
  30 + * Unit tests for {@link VideoAsset}.
  31 + *
  32 + * <p>This test suite <em>narrowly verifies</em> that the {@link VideoAsset} factory methods, {@link
  33 + * VideoAsset#fromRemoteUrl(String, VideoAsset.StreamingFormat, Map)} and {@link
  34 + * VideoAsset#fromAssetUrl(String)} follow the contract they have documented.
  35 + *
  36 + * <p>In other tests of the player, a fake asset is likely to be used.
  37 + */
  38 +@RunWith(RobolectricTestRunner.class)
  39 +public final class VideoAssetTest {
  40 + @Test
  41 + public void localVideoRequiresAssetUrl() {
  42 + assertThrows(
  43 + IllegalArgumentException.class,
  44 + () -> VideoAsset.fromAssetUrl("https://not.local/video.mp4"));
  45 + }
  46 +
  47 + @Test
  48 + public void localVideoCreatesMediaItem() {
  49 + VideoAsset asset = VideoAsset.fromAssetUrl("asset:///asset-key");
  50 + MediaItem mediaItem = asset.getMediaItem();
  51 +
  52 + assert mediaItem.localConfiguration != null;
  53 + assertEquals(mediaItem.localConfiguration.uri, Uri.parse("asset:///asset-key"));
  54 + }
  55 +
  56 + private static DefaultHttpDataSource.Factory mockHttpFactory() {
  57 + DefaultHttpDataSource.Factory httpFactory = mock(DefaultHttpDataSource.Factory.class);
  58 + when(httpFactory.setUserAgent(anyString())).thenReturn(httpFactory);
  59 + when(httpFactory.setAllowCrossProtocolRedirects(anyBoolean())).thenReturn(httpFactory);
  60 + when(httpFactory.setDefaultRequestProperties(anyMap())).thenReturn(httpFactory);
  61 + return httpFactory;
  62 + }
  63 +
  64 + @Test
  65 + public void remoteVideoByDefaultSetsUserAgentAndCrossProtocolRedirects() {
  66 + VideoAsset asset =
  67 + VideoAsset.fromRemoteUrl(
  68 + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, new HashMap<>());
  69 +
  70 + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
  71 +
  72 + // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
  73 + ((HttpVideoAsset) asset)
  74 + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
  75 +
  76 + verify(mockFactory).setUserAgent("ExoPlayer");
  77 + verify(mockFactory).setAllowCrossProtocolRedirects(true);
  78 + verify(mockFactory, never()).setDefaultRequestProperties(anyMap());
  79 + }
  80 +
  81 + @Test
  82 + public void remoteVideoOverridesUserAgentIfProvided() {
  83 + Map<String, String> headers = new HashMap<>();
  84 + headers.put("User-Agent", "FantasticalVideoBot");
  85 +
  86 + VideoAsset asset =
  87 + VideoAsset.fromRemoteUrl(
  88 + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers);
  89 +
  90 + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
  91 +
  92 + // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
  93 + ((HttpVideoAsset) asset)
  94 + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
  95 +
  96 + verify(mockFactory).setUserAgent("FantasticalVideoBot");
  97 + verify(mockFactory).setAllowCrossProtocolRedirects(true);
  98 + verify(mockFactory).setDefaultRequestProperties(headers);
  99 + }
  100 +
  101 + // This tests that without using the overrides we get a working, non-mocked object.
  102 + //
  103 + // I guess you could also start a local HTTP server, and try fetching with it, YMMV.
  104 + @Test
  105 + public void remoteVideoGetMediaSourceFactoryInProductionReturnsRealMediaSource() {
  106 + VideoAsset asset =
  107 + VideoAsset.fromRemoteUrl(
  108 + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, new HashMap<>());
  109 +
  110 + MediaSource source =
  111 + asset
  112 + .getMediaSourceFactory(ApplicationProvider.getApplicationContext())
  113 + .createMediaSource(asset.getMediaItem());
  114 + assertEquals(
  115 + Uri.parse("https://flutter.dev/video.mp4"),
  116 + Objects.requireNonNull(source.getMediaItem().localConfiguration).uri);
  117 + }
  118 +
  119 + @Test
  120 + public void remoteVideoSetsAdditionalHttpHeadersIfProvided() {
  121 + Map<String, String> headers = new HashMap<>();
  122 + headers.put("X-Cache-Forever", "true");
  123 +
  124 + VideoAsset asset =
  125 + VideoAsset.fromRemoteUrl(
  126 + "https://flutter.dev/video.mp4", VideoAsset.StreamingFormat.UNKNOWN, headers);
  127 +
  128 + DefaultHttpDataSource.Factory mockFactory = mockHttpFactory();
  129 +
  130 + // Cast to HttpVideoAsset to call a testing-only method to intercept calls.
  131 + ((HttpVideoAsset) asset)
  132 + .getMediaSourceFactory(ApplicationProvider.getApplicationContext(), mockFactory);
  133 +
  134 + verify(mockFactory).setUserAgent("ExoPlayer");
  135 + verify(mockFactory).setAllowCrossProtocolRedirects(true);
  136 + verify(mockFactory).setDefaultRequestProperties(headers);
  137 + }
  138 +
  139 + @Test
  140 + public void rtspVideoRequiresRtspUrl() {
  141 + assertThrows(
  142 + IllegalArgumentException.class, () -> VideoAsset.fromRtspUrl("https://not.rtsp/video.mp4"));
  143 + }
  144 +
  145 + @Test
  146 + public void rtspVideoCreatesMediaItem() {
  147 + VideoAsset asset = VideoAsset.fromRtspUrl("rtsp://test:pass@flutter.dev/stream");
  148 + MediaItem mediaItem = asset.getMediaItem();
  149 +
  150 + assert mediaItem.localConfiguration != null;
  151 + assertEquals(
  152 + mediaItem.localConfiguration.uri, Uri.parse("rtsp://test:pass@flutter.dev/stream"));
  153 + }
  154 +}