本文基于官方視頻播放plugin進(jìn)行封裝
https://github.com/flutter/plugins/tree/master/packages/video_player/video_player
在日常的開(kāi)發(fā)中洞就,難免會(huì)遇到視頻開(kāi)發(fā)需求楷兽;隨著Flutter技術(shù)日漸活躍所以在所難逃會(huì)有視頻功能的需求吧秕,如果完完整整把官方提供的video_player
功能直接搬進(jìn)來(lái)使用會(huì)發(fā)現(xiàn)在很多地方需要進(jìn)一步封裝蒂秘。
一、集成視頻播放功能
首先悟泵,由于公司的Android
采用的是ijkplayer
來(lái)實(shí)現(xiàn)視頻播放功能而官方的這個(gè)插件采用的是exoplayer
勾邦,所以在集成的時(shí)候我們把VideoPlayerPlugin
對(duì)應(yīng)的類進(jìn)行改造把相關(guān)的exoplayer
換成自家App中的ijkplayer
刻蟹,這樣子做的好處不僅僅可以減少因?yàn)橐肓?code>exoplayer給App帶來(lái)了更大的包大小,而且也可以復(fù)用原生端的代碼淘捡。
其次藕各,需要大概了解一些官方的視頻播放插件原理,而對(duì)于原理可以用一句話來(lái)概括就是:外接紋理 Texture
焦除;
在Flutter
端中的Textture
類的定義如下:
class Texture extends LeafRenderObjectWidget {
const Texture({
Key key,
@required this.textureId,
}) : assert(textureId != null),
super(key: key);
}
所以也就說(shuō)每一個(gè)紋理Textture
對(duì)應(yīng)著一個(gè)必須的textureId
激况,這就是實(shí)現(xiàn)視頻播放供的關(guān)鍵點(diǎn);對(duì)于原生如何生成textureId
膘魄,可以大概看下官方的插件的源碼:
TextureRegistry textures = registrar.textures();
TextureRegistry.SurfaceTextureEntry textureEntry = textures.createSurfaceTexture();
textureId = textureEntry.id()
問(wèn)題1:什么時(shí)候生成這個(gè)textureId
呢乌逐?
當(dāng)我們?cè)贔lutter端調(diào)用視頻初始化的時(shí)候,會(huì)調(diào)用plugin的create
方法:
final Map<dynamic, dynamic> response = await _channel.invokeMethod(
'create',
dataSourceDescription,
);
_textureId = response['textureId'];
問(wèn)題2:獲取了textureId
是如何進(jìn)行傳遞數(shù)據(jù)呢创葡?
當(dāng)Flutter端調(diào)用create
的時(shí)候浙踢,原生端會(huì)生成一個(gè)textureId
并注冊(cè)了一個(gè)新的EventChannel
;至此視頻播放的相關(guān)數(shù)據(jù)如:initialized
(初始化)灿渴、completed
(播放完成)洛波、bufferingUpdate
(進(jìn)度更新)、bufferingStart
(緩沖開(kāi)始)骚露、bufferingEnd
(緩沖結(jié)束)就會(huì)回調(diào)給Flutter端具體的每一個(gè)視頻Widget
蹬挤,從而實(shí)現(xiàn)下一步邏輯。
原生端如何生成一個(gè)新的EventChannel
:
TextureRegistry.SurfaceTextureEntry textureEntry = textures.createSurfaceTexture();
String eventChannelName = "flutter.io/videoPlayer/videoEvents" + textureEntry.id();
EventChannel eventChannel =
new EventChannel(
registrar.messenger(), eventChannelName);
Flutter端如何監(jiān)聽(tīng):
void eventListener(dynamic event) {
final Map<dynamic, dynamic> map = event;
switch (map['event']) {
case 'initialized':
value = value.copyWith(
duration: Duration(milliseconds: map['duration']),
size: Size(map['width']?.toDouble() ?? 0.0,
map['height']?.toDouble() ?? 0.0),
);
initializingCompleter.complete(null);
_applyLooping();
_applyVolume();
_applyPlayPause();
break;
......
}
}
void errorListener(Object obj) {
final PlatformException e = obj;
LogUtil.d("----------- ErrorListener Code = ${e.code}");
value = VideoPlayerValue.erroneous(e.code);
_timer?.cancel();
}
_eventSubscription = _eventChannelFor(_textureId)
.receiveBroadcastStream()
.listen(eventListener, onError: errorListener);
return initializingCompleter.future;
}
EventChannel _eventChannelFor(int textureId) {
return EventChannel('flutter.io/videoPlayer/videoEvents$textureId');
}
當(dāng)然對(duì)于類似暫停/播放/快進(jìn)...
等一些需要觸發(fā)操作的走的邏輯有點(diǎn)不同棘幸;走的是跟調(diào)用create
方法的同一個(gè)plugin
(即"flutter.io/videoPlayer"對(duì)應(yīng)的plugin):
// 如播放/暫停調(diào)用方式
final MethodChannel _channel = const MethodChannel('flutter.io/videoPlayer')
_channel.invokeMethod( 'play', <String, dynamic>{'textureId': _textureId});
至此焰扳;就可以實(shí)現(xiàn)了Flutter的視頻播放功能了。
二、視頻列表界面實(shí)現(xiàn)
在Flutter跟普通的列表式界面一樣使用一個(gè)ListView
這種可滑動(dòng)的控件即可實(shí)現(xiàn)吨悍;但是對(duì)于視頻界面有點(diǎn)不同的是需要處理當(dāng)當(dāng)前播放的item不可見(jiàn)的時(shí)候需要暫停播放光绕,當(dāng)點(diǎn)擊另一個(gè)視頻的時(shí)候前一個(gè)播放中的視頻需要暫停。
首先畜份,針對(duì)點(diǎn)擊另一個(gè)視頻的時(shí)候前一個(gè)播放中的視頻需要暫停的處理方式:
這種情況還是比較好處理诞帐,我這里的處理方式是給每一個(gè)視頻的Widget
都注冊(cè)一個(gè)點(diǎn)擊回調(diào),當(dāng)另一個(gè)點(diǎn)擊播放的時(shí)候遍歷回調(diào)時(shí)發(fā)現(xiàn)當(dāng)前視頻處于播放中就執(zhí)行暫停操作:
/// 控制當(dāng)點(diǎn)擊播放的時(shí)候上一個(gè)視頻需要暫停
playCallback = () {
if (controller.value.isPlaying) {
setState(() {
controller.pause();
});
}
};
VideoPlayerController.playCallbacks.add(playCallback);
其次爆雹,滑動(dòng)的時(shí)候當(dāng)item不可見(jiàn)的時(shí)候需要停止播放停蕉;相對(duì)這種情況主要的原理就是獲取可滑動(dòng)視圖的Rect
(區(qū)域),然后當(dāng)視頻Rect
的底部小于可滑動(dòng)視圖的頂部或者當(dāng)前的視頻的視圖的頂部小于可滑動(dòng)視圖的底部就執(zhí)行暫停操作钙态。
問(wèn)題1:如何獲取Widget的Rect
呢慧起?
/// 返回對(duì)應(yīng)的Rect區(qū)域...
static Rect getRectFromKey(BuildContext currentContext) {
var object = currentContext?.findRenderObject();
var translation = object?.getTransformTo(null)?.getTranslation();
var size = object?.semanticBounds?.size;
if (translation != null && size != null) {
return new Rect.fromLTWH(translation.x, translation.y, size.width, size.height);
} else {
return null;
}
}
問(wèn)題2:如何根據(jù)滑動(dòng)來(lái)判斷滑出了屏幕呢?
給視頻Widget注冊(cè)一個(gè)監(jiān)聽(tīng)册倒,當(dāng)滑動(dòng)的時(shí)候進(jìn)行回調(diào)判斷:
/// 滑動(dòng)ListView的時(shí)候進(jìn)行回調(diào)給視頻Widget
scrollController = ScrollController();
scrollController.addListener(() {
if (videoScrollController.scrollOffsetCallbacks.isNotEmpty) {
for (ScrollOffsetCallback callback in videoScrollController.scrollOffsetCallbacks) {
callback();
}
}
});
當(dāng)視頻Widget接收到滑動(dòng)回調(diào)的時(shí)候:
scrollOffsetCallback = () {
itemRect = VideoScrollController.getRectFromKey(videoBuildContext);
/// 狀態(tài)欄 + 標(biāo)題欄的高度(存在一點(diǎn)偏差)
int toolBarAndStatusBarHeight = 44 + 25;
if (itemRect != null && videoScrollController.rect != null &&
(itemRect.top > videoScrollController.rect.bottom || itemRect.bottom - toolBarAndStatusBarHeight < videoScrollController.rect.top)) {
if (controller.value.isPlaying) {
setState(() {
LogUtil.d("=============== 正在播放中蚓挤,被移出屏幕外需要暫停播放 ======");
controller.pause();
});
}
}
};
videoScrollController?.scrollOffsetCallbacks?.add(scrollOffsetCallback);
至此,真的列表式視頻界面就解決了相關(guān)滑動(dòng)或者點(diǎn)擊的時(shí)候執(zhí)行暫停/播放的功能了驻子。
三灿意、全屏切換
對(duì)于視頻功能很常見(jiàn)的就是全屏的需求,所以在Flutter端自然也是少不了這種功能了崇呵;針對(duì)全屏的功能參考了開(kāi)源庫(kù)https://github.com/brianegan/chewie的思路缤剧。
而主要的原理還是利用每個(gè)紋理Textture
的textureId
唯一的原理,跟原生的扣View
的方式有一些差別域慷,按照開(kāi)源庫(kù)的整體代碼還是比較簡(jiǎn)單沒(méi)有非常多的麻煩問(wèn)題:
/// 退出全屏
_popFullScreenWidget() {
Navigator.of(context).pop();
}
/// 切換至全屏狀態(tài)
_pushFullScreenWidget() async {
final TransitionRoute<Null> route = new PageRouteBuilder<Null>(
settings: new RouteSettings(isInitialRoute: false),
pageBuilder: _fullScreenRoutePageBuilder,
);
SystemChrome.setEnabledSystemUIOverlays([]);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await Navigator.of(context).push(route);
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
那么荒辕,以上大概就是在Flutter端開(kāi)發(fā)時(shí)常見(jiàn)的需求功能了;當(dāng)然在具體的或者更變態(tài)的視頻需求時(shí)需要基于該方案再進(jìn)一步完善犹褒。
附上幾張demo的效果圖: