From dfbe3b1f6c8c319c1fd1587c71b8c1884412599e Mon Sep 17 00:00:00 2001 From: guozhigq Date: Tue, 29 Aug 2023 23:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=80=E5=8D=95=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- lib/http/api.dart | 2 + lib/http/danmaku.dart | 33 +++ lib/http/init.dart | 21 +- lib/http/interceptor.dart | 9 +- .../bangumi/introduction/controller.dart | 1 + lib/pages/danmaku/controller.dart | 64 +++++ lib/pages/danmaku/index.dart | 4 + lib/pages/danmaku/view.dart | 127 +++++++++ lib/pages/favDetail/controller.dart | 16 +- lib/pages/favDetail/view.dart | 41 +-- lib/pages/setting/play_setting.dart | 12 + lib/pages/video/detail/controller.dart | 6 +- .../video/detail/introduction/controller.dart | 1 + lib/pages/video/detail/introduction/view.dart | 36 ++- lib/pages/video/detail/view.dart | 254 +++++++++--------- .../video/detail/widgets/header_control.dart | 22 ++ lib/plugin/pl_player/controller.dart | 111 +++++++- lib/plugin/pl_player/view.dart | 72 +---- lib/utils/danmaku.dart | 24 ++ lib/utils/storage.dart | 2 + pubspec.lock | 9 + pubspec.yaml | 6 + 23 files changed, 646 insertions(+), 241 deletions(-) create mode 100644 lib/http/danmaku.dart create mode 100644 lib/pages/danmaku/controller.dart create mode 100644 lib/pages/danmaku/index.dart create mode 100644 lib/pages/danmaku/view.dart create mode 100644 lib/utils/danmaku.dart diff --git a/README.md b/README.md index fac2a885..6d2c1d56 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ Xcode 13.4 不支持**auto_orientation**,请注释相关代码 ``` +
+ + +## 技术交流 + +Telegram: https://t.me/+lm_oOVmF0RJiODk1 + +
## 功能 @@ -100,6 +108,7 @@ Xcode 13.4 不支持**auto_orientation**,请注释相关代码 - [x] 主题模式:亮色/暗色/跟随系统 - [x] 震动反馈(可选) - [x] 高帧率 + - [x] 自动全屏 - [ ] 等等
@@ -117,11 +126,6 @@ Xcode 13.4 不支持**auto_orientation**,请注释相关代码 感谢使用 -
- -## 技术交流 - -Telegram https://t.me/+lm_oOVmF0RJiODk1
diff --git a/lib/http/api.dart b/lib/http/api.dart index b32c7e40..e1e011eb 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -292,4 +292,6 @@ class Api { // 多少人在看 // https://api.bilibili.com/x/player/online/total?aid=913663681&cid=1203559746&bvid=BV1MM4y1s7NZ&ts=56427838 static const String onlineTotal = '/x/player/online/total'; + + static const String webDanmaku = '/x/v2/dm/web/seg.so'; } diff --git a/lib/http/danmaku.dart b/lib/http/danmaku.dart new file mode 100644 index 00000000..020c89ea --- /dev/null +++ b/lib/http/danmaku.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:pilipala/http/index.dart'; +import 'package:pilipala/models/danmaku/dm.pb.dart'; + +import 'constants.dart'; + +class DanmakaHttp { + // 获取视频弹幕 + static Future queryDanmaku({ + required int cid, + required int segmentIndex, + }) async { + // 构建参数对象 + Map params = { + 'type': 1, + 'oid': cid, + 'segment_index': segmentIndex, + }; + + // 计算函数 + Future computeTask(Map params) async { + var response = await Request().get( + Api.webDanmaku, + data: params, + extra: {'resType': ResponseType.bytes}, + ); + return DmSegMobileReply.fromBuffer(response.data); + } + + return await compute(computeTask, params); + } +} diff --git a/lib/http/init.dart b/lib/http/init.dart index 5f9e1e2b..1e821062 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -41,6 +41,7 @@ class Request { log("setCookie, ${e.toString()}"); } } + setOptionsHeaders(userInfo); } if (cookie.isEmpty) { @@ -69,6 +70,15 @@ class Request { return token; } + static setOptionsHeaders(userInfo) { + dio.options.headers['x-bili-mid'] = userInfo.mid.toString(); + dio.options.headers['env'] = 'prod'; + dio.options.headers['app-key'] = 'android64'; + dio.options.headers['x-bili-aurora-eid'] = 'UlMFQVcABlAH'; + dio.options.headers['x-bili-aurora-zone'] = 'sh001'; + dio.options.headers['referer'] = 'https://www.bilibili.com/'; + } + /* * config it and create */ @@ -87,17 +97,6 @@ class Request { }, ); - Box userInfoCache = GStrorage.userInfo; - var userInfo = userInfoCache.get('userInfoCache'); - if (userInfo != null && userInfo.mid != null) { - options.headers['x-bili-mid'] = userInfo.mid.toString(); - options.headers['env'] = 'prod'; - options.headers['app-key'] = 'android64'; - options.headers['x-bili-aurora-eid'] = 'UlMFQVcABlAH'; - options.headers['x-bili-aurora-zone'] = 'sh001'; - options.headers['referer'] = 'https://www.bilibili.com/'; - } - dio = Dio(options) ..httpClientAdapter = Http2Adapter( ConnectionManager( diff --git a/lib/http/interceptor.dart b/lib/http/interceptor.dart index 9a86fbb9..4b9e8770 100644 --- a/lib/http/interceptor.dart +++ b/lib/http/interceptor.dart @@ -17,8 +17,6 @@ class ApiInterceptor extends Interceptor { handler.next(options); } - Box localCache = GStrorage.localCache; - @override void onResponse(Response response, ResponseInterceptorHandler handler) { try { @@ -29,8 +27,11 @@ class ApiInterceptor extends Interceptor { final uri = Uri.parse(locations.first); final accessKey = uri.queryParameters['access_key']; final mid = uri.queryParameters['mid']; - localCache - .put(LocalCacheKey.accessKey, {'mid': mid, 'value': accessKey}); + try { + Box localCache = GStrorage.localCache; + localCache.put( + LocalCacheKey.accessKey, {'mid': mid, 'value': accessKey}); + } catch (_) {} } } } diff --git a/lib/pages/bangumi/introduction/controller.dart b/lib/pages/bangumi/introduction/controller.dart index e63e797d..c027f8af 100644 --- a/lib/pages/bangumi/introduction/controller.dart +++ b/lib/pages/bangumi/introduction/controller.dart @@ -258,6 +258,7 @@ class BangumiIntroController extends GetxController { Get.find(tag: Get.arguments['heroTag']); videoDetailCtr.bvid = bvid; videoDetailCtr.cid = cid; + videoDetailCtr.danmakuCid.value = cid; videoDetailCtr.queryVideoUrl(); // 重新请求评论 try { diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart new file mode 100644 index 00000000..ebe7712d --- /dev/null +++ b/lib/pages/danmaku/controller.dart @@ -0,0 +1,64 @@ +import 'package:pilipala/http/danmaku.dart'; +import 'package:pilipala/models/danmaku/dm.pb.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; + +class PlDanmakuController { + PlDanmakuController(this.cid, this.playerController); + final int cid; + final PlPlayerController playerController; + late Duration videoDuration; + // 按 6min 分段 + int segCount = 0; + List dmSegList = []; + int currentSegIndex = 0; + int currentDmIndex = 0; + + void calcSegment() { + segCount = (videoDuration.inSeconds / (60 * 6)).ceil(); + } + + Future> queryDanmaku() async { + dmSegList.clear(); + for (int segIndex = 1; segIndex <= segCount; segIndex++) { + DmSegMobileReply result = + await DanmakaHttp.queryDanmaku(cid: cid, segmentIndex: segIndex); + if (result.elems.isNotEmpty) { + result.elems.sort((a, b) => (a.progress).compareTo(b.progress)); + dmSegList.add(result); + } + } + if (dmSegList.isNotEmpty) { + findClosestPositionIndex(playerController.position.value.inMilliseconds); + } + return dmSegList; + } + + /// 查询当前最接近的弹幕 + void findClosestPositionIndex(int position) { + int segIndex = (position / (6 * 60 * 1000)).ceil() - 1; + if (segIndex < 0) segIndex = 0; + List elems = dmSegList[segIndex].elems; + + if (segIndex < dmSegList.length) { + int left = 0; + int right = elems.length; + + while (left < right) { + int mid = (right + left) ~/ 2; + var midPosition = elems[mid].progress; + + if (midPosition >= position) { + right = mid; + } else { + left = mid + 1; + } + } + + currentSegIndex = segIndex; + currentDmIndex = right; + } else { + currentSegIndex = segIndex; + currentDmIndex = 0; + } + } +} diff --git a/lib/pages/danmaku/index.dart b/lib/pages/danmaku/index.dart new file mode 100644 index 00000000..004ee0c3 --- /dev/null +++ b/lib/pages/danmaku/index.dart @@ -0,0 +1,4 @@ +library pldanmaku; + +export './controller.dart'; +export 'view.dart'; diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart new file mode 100644 index 00000000..feaf60b9 --- /dev/null +++ b/lib/pages/danmaku/view.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; +import 'package:pilipala/pages/danmaku/index.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/utils/danmaku.dart'; + +/// 传入播放器控制器,监听播放进度,加载对应弹幕 +class PlDanmaku extends StatefulWidget { + final int cid; + final PlPlayerController playerController; + + const PlDanmaku({ + super.key, + required this.cid, + required this.playerController, + }); + + @override + State createState() => _PlDanmakuState(); +} + +class _PlDanmakuState extends State { + late PlPlayerController playerController; + late PlDanmakuController _plDanmakuController; + DanmakuController? _controller; + bool danmuPlayStatus = true; + + @override + void initState() { + super.initState(); + _plDanmakuController = + PlDanmakuController(widget.cid, widget.playerController); + if (mounted) { + playerController = widget.playerController; + _plDanmakuController.videoDuration = playerController.duration.value; + _plDanmakuController + ..calcSegment() + ..queryDanmaku(); + playerController + ..addStatusLister(playerListener) + ..addPositionListener(videoPositionListen); + } + } + + // 播放器状态监听 + void playerListener(PlayerStatus? status) { + if (status == PlayerStatus.paused) { + _controller!.pause(); + } + if (status == PlayerStatus.playing) { + _controller!.onResume(); + } + } + + void videoPositionListen(Duration position) { + if (!danmuPlayStatus) { + _controller!.onResume(); + danmuPlayStatus = true; + } + PlDanmakuController ctr = _plDanmakuController; + int currentPosition = position.inMilliseconds; + + // 超出分段数返回 + if (ctr.currentSegIndex >= ctr.dmSegList.length) { + return; + } + if (ctr.dmSegList.isEmpty || + ctr.dmSegList[ctr.currentSegIndex].elems.isEmpty) { + return; + } + // 超出当前分段的弹幕总数返回 + if (ctr.currentDmIndex >= ctr.dmSegList[ctr.currentSegIndex].elems.length) { + ctr.currentDmIndex = 0; + ctr.currentSegIndex++; + return; + } + var element = ctr.dmSegList[ctr.currentSegIndex].elems[ctr.currentDmIndex]; + var delta = currentPosition - element.progress; + + if (delta >= 0 && delta < 200) { + _controller!.addItems([ + DanmakuItem( + element.content, + color: DmUtils.decimalToColor(element.color), + time: element.progress, + type: DmUtils.getPosition(element.mode), + ) + ]); + ctr.currentDmIndex++; + } else { + if (!playerController.isOpenDanmu.value) { + _controller!.pause(); + danmuPlayStatus = false; + return; + } + ctr.findClosestPositionIndex(position.inMilliseconds); + } + } + + @override + void dispose() { + playerController.removePositionListener(videoPositionListen); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Obx( + () => AnimatedOpacity( + opacity: playerController.isOpenDanmu.value ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: DanmakuView( + createdController: (DanmakuController e) async { + _controller = e; + }, + option: DanmakuOption( + fontSize: 15, + area: 0.5, + duration: 5, + ), + statusChanged: (isPlaying) {}, + ), + ), + ); + } +} diff --git a/lib/pages/favDetail/controller.dart b/lib/pages/favDetail/controller.dart index 5229ee87..8b772716 100644 --- a/lib/pages/favDetail/controller.dart +++ b/lib/pages/favDetail/controller.dart @@ -15,6 +15,8 @@ class FavDetailController extends GetxController { bool isLoadingMore = false; RxMap favInfo = {}.obs; RxList favList = [FavDetailItemData()].obs; + RxString loadingText = '加载中...'.obs; + int mediaCount = 0; @override void onInit() { @@ -27,6 +29,11 @@ class FavDetailController extends GetxController { } Future queryUserFavFolderDetail({type = 'init'}) async { + if (type == 'onLoad' && favList.length >= mediaCount) { + loadingText.value = '没有更多了'; + return; + } + isLoadingMore = true; var res = await await UserHttp.userFavFolderDetail( pn: currentPage, ps: 20, @@ -36,11 +43,16 @@ class FavDetailController extends GetxController { favInfo.value = res['data'].info; if (currentPage == 1 && type == 'init') { favList.value = res['data'].medias; - } else if (type == 'onload') { + mediaCount = res['data'].info['media_count']; + } else if (type == 'onLoad') { favList.addAll(res['data'].medias); } + if (favList.length >= mediaCount) { + loadingText.value = '没有更多了'; + } } currentPage += 1; + isLoadingMore = false; return res; } @@ -64,6 +76,6 @@ class FavDetailController extends GetxController { } onLoad() { - queryUserFavFolderDetail(type: 'onload'); + queryUserFavFolderDetail(type: 'onLoad'); } } diff --git a/lib/pages/favDetail/view.dart b/lib/pages/favDetail/view.dart index a99d1c03..426bfa8f 100644 --- a/lib/pages/favDetail/view.dart +++ b/lib/pages/favDetail/view.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; import 'package:pilipala/pages/favDetail/index.dart'; import 'widget/fav_video_card.dart'; @@ -37,10 +40,9 @@ class _FavDetailPageState extends State { if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) { - if (!_favDetailController.isLoadingMore) { - _favDetailController.isLoadingMore = true; + EasyThrottle.throttle('favDetail', const Duration(seconds: 1), () { _favDetailController.onLoad(); - } + }); } }, ); @@ -183,12 +185,7 @@ class _FavDetailPageState extends State { Map data = snapshot.data; if (data['status']) { if (_favDetailController.item!.mediaCount == 0) { - return const SliverToBoxAdapter( - child: SizedBox( - height: 300, - child: Center(child: Text('没有内容')), - ), - ); + return const NoData(); } else { return Obx( () => SliverList( @@ -207,18 +204,30 @@ class _FavDetailPageState extends State { ); } } else { - return const SliverToBoxAdapter( - child: SizedBox( - height: 300, - child: Center(child: Text('加载中')), - ), + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoCardHSkeleton(); + }, childCount: 10), ); } }, ), SliverToBoxAdapter( - child: SizedBox( - height: MediaQuery.of(context).padding.bottom + 20, + child: Container( + height: MediaQuery.of(context).padding.bottom + 60, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom), + child: Center( + child: Obx( + () => Text( + _favDetailController.loadingText.value, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 13), + ), + ), + ), ), ) ], diff --git a/lib/pages/setting/play_setting.dart b/lib/pages/setting/play_setting.dart index 8f9d8226..6d74c5b3 100644 --- a/lib/pages/setting/play_setting.dart +++ b/lib/pages/setting/play_setting.dart @@ -78,6 +78,18 @@ class _PlaySettingState extends State { setKey: SettingBoxKey.enableAutoBrightness, defaultVal: false, ), + const SetSwitchItem( + title: '自动全屏', + subTitle: '视频开始播放时进入全屏', + setKey: SettingBoxKey.enableAutoEnter, + defaultVal: false, + ), + const SetSwitchItem( + title: '自动退出', + subTitle: '视频结束播放时退出全屏', + setKey: SettingBoxKey.enableAutoExit, + defaultVal: false, + ), ListTile( dense: false, title: Text('默认画质', style: titleStyle), diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 53f0e16d..e3716f18 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -21,6 +21,7 @@ class VideoDetailController extends GetxController /// 路由传参 String bvid = Get.parameters['bvid']!; int cid = int.parse(Get.parameters['cid']!); + RxInt danmakuCid = 0.obs; String heroTag = Get.arguments['heroTag']; // 视频详情 Map videoItem = {}; @@ -73,6 +74,7 @@ class VideoDetailController extends GetxController // 默认记录历史记录 bool enableHeart = true; var userInfo; + late bool isFirstTime = true; @override void onInit() { @@ -100,6 +102,7 @@ class VideoDetailController extends GetxController localCache.get(LocalCacheKey.historyPause) == true) { enableHeart = false; } + danmakuCid.value = cid; } showReplyReplyPanel() { @@ -193,6 +196,7 @@ class VideoDetailController extends GetxController bvid: bvid, cid: cid, enableHeart: enableHeart, + isFirstTime: isFirstTime, ); } @@ -233,7 +237,6 @@ class VideoDetailController extends GetxController currentDecodeFormats = VideoDecodeFormatsCode.fromString(setting.get( SettingBoxKey.defaultDecode, defaultValue: VideoDecodeFormats.values.last.code))!; - print(currentDecodeFormats.description); try { // 当前视频没有对应格式返回第一个 bool flag = false; @@ -287,6 +290,7 @@ class VideoDetailController extends GetxController defaultST = Duration(milliseconds: data.lastPlayTime!); if (autoPlay.value) { await playerInit(); + isShowCover.value = false; } } else { if (result['code'] == -404) { diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index ea63c14d..9f756c7d 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -418,6 +418,7 @@ class VideoIntroController extends GetxController { Get.find(tag: Get.arguments['heroTag']); videoDetailCtr.bvid = bvid; videoDetailCtr.cid = cid; + videoDetailCtr.danmakuCid.value = cid; videoDetailCtr.queryVideoUrl(); // 重新请求评论 try { diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index c993fda9..75eef280 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -31,9 +31,10 @@ class VideoIntroPanel extends StatefulWidget { class _VideoIntroPanelState extends State with AutomaticKeepAliveClientMixin { - final VideoIntroController videoIntroController = - Get.put(VideoIntroController(), tag: Get.arguments['heroTag']); + late String heroTag; + late VideoIntroController videoIntroController; VideoDetailData? videoDetail; + late Future? _futureBuilderFuture; // 添加页面缓存 @override @@ -42,6 +43,11 @@ class _VideoIntroPanelState extends State @override void initState() { super.initState(); + + /// fix 全屏时参数丢失 + heroTag = Get.arguments['heroTag']; + videoIntroController = Get.put(VideoIntroController(), tag: heroTag); + _futureBuilderFuture = videoIntroController.queryVideoIntro(); videoIntroController.videoDetail.listen((value) { videoDetail = value; }); @@ -57,15 +63,20 @@ class _VideoIntroPanelState extends State Widget build(BuildContext context) { super.build(context); return FutureBuilder( - future: videoIntroController.queryVideoIntro(), + future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } if (snapshot.data['status']) { // 请求成功 return Obx( () => VideoInfo( - loadingStatus: false, - videoDetail: videoIntroController.videoDetail.value), + loadingStatus: false, + videoDetail: videoIntroController.videoDetail.value, + heroTag: heroTag, + ), ); } else { // 请求错误 @@ -79,7 +90,11 @@ class _VideoIntroPanelState extends State ); } } else { - return VideoInfo(loadingStatus: true, videoDetail: videoDetail); + return VideoInfo( + loadingStatus: true, + videoDetail: videoDetail, + heroTag: heroTag, + ); } }, ); @@ -89,8 +104,10 @@ class _VideoIntroPanelState extends State class VideoInfo extends StatefulWidget { final bool loadingStatus; final VideoDetailData? videoDetail; + final String? heroTag; - const VideoInfo({Key? key, this.loadingStatus = false, this.videoDetail}) + const VideoInfo( + {Key? key, this.loadingStatus = false, this.videoDetail, this.heroTag}) : super(key: key); @override @@ -98,7 +115,8 @@ class VideoInfo extends StatefulWidget { } class _VideoInfoState extends State with TickerProviderStateMixin { - final String heroTag = Get.arguments['heroTag']; + // final String heroTag = Get.arguments['heroTag']; + late String heroTag; late final VideoIntroController videoIntroController; late final VideoDetailController videoDetailCtr; late final Map videoItem; @@ -117,7 +135,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); - + heroTag = widget.heroTag!; videoIntroController = Get.put(VideoIntroController(), tag: heroTag); videoDetailCtr = Get.find(tag: heroTag); videoItem = videoIntroController.videoItem!; diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index b51510dc..6568a716 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -10,6 +10,7 @@ import 'package:pilipala/common/widgets/sliver_header.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/pages/bangumi/introduction/index.dart'; +import 'package:pilipala/pages/danmaku/view.dart'; import 'package:pilipala/pages/video/detail/introduction/widgets/menu_row.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart'; import 'package:pilipala/pages/video/detail/controller.dart'; @@ -41,7 +42,6 @@ class _VideoDetailPageState extends State Get.put(VideoIntroController(), tag: Get.arguments['heroTag']); PlayerStatus playerStatus = PlayerStatus.playing; - // bool isShowCover = true; double doubleOffset = 0; Box localCache = GStrorage.localCache; @@ -49,11 +49,15 @@ class _VideoDetailPageState extends State late double statusBarHeight; final videoHeight = Get.size.width * 9 / 16; late Future _futureBuilderFuture; + // 自动退出全屏 + late bool autoExitFullcreen; @override void initState() { super.initState(); statusBarHeight = localCache.get('statusBarHeight'); + autoExitFullcreen = + setting.get(SettingBoxKey.enableAutoExit, defaultValue: false); videoSourceInit(); appbarStreamListen(); } @@ -63,7 +67,7 @@ class _VideoDetailPageState extends State _futureBuilderFuture = videoDetailController.queryVideoUrl(); if (videoDetailController.autoPlay.value) { plPlayerController = videoDetailController.plPlayerController; - playerListener(); + plPlayerController!.addStatusLister(playerListener); } } @@ -79,23 +83,15 @@ class _VideoDetailPageState extends State } // 播放器状态监听 - void playerListener() { - plPlayerController!.onPlayerStatusChanged.listen( - (PlayerStatus status) async { - playerStatus = status; - if (status == PlayerStatus.playing) { - videoDetailController.isShowCover.value = false; - } else { - // 播放完成停止 or 切换下一个 - if (status == PlayerStatus.completed) { - // 当只有1p或多p未打开自动播放时,播放完成还原进度条,展示控制栏 - plPlayerController!.seekTo(Duration.zero); - plPlayerController!.onLockControl(false); - plPlayerController!.videoPlayerController!.pause(); - } - } - }, - ); + void playerListener(PlayerStatus? status) { + if (status == PlayerStatus.completed) { + // 结束播放退出全屏 + if (autoExitFullcreen) { + plPlayerController!.triggerFullScreen(status: false); + } + // 播放完展示控制栏 + plPlayerController!.onLockControl(false); + } } // 继续播放或重新播放 @@ -110,11 +106,11 @@ class _VideoDetailPageState extends State plPlayerController = videoDetailController.plPlayerController; videoDetailController.autoPlay.value = true; videoDetailController.isShowCover.value = false; - playerListener(); } @override void dispose() { + plPlayerController!.removeStatusLister(playerListener); plPlayerController!.dispose(); super.dispose(); } @@ -128,6 +124,7 @@ class _VideoDetailPageState extends State } videoDetailController.defaultST = plPlayerController!.position.value; videoIntroController.isPaused = true; + plPlayerController!.removeStatusLister(playerListener); plPlayerController!.pause(); super.didPushNext(); } @@ -135,12 +132,14 @@ class _VideoDetailPageState extends State @override // 返回当前页面时 void didPopNext() async { + videoDetailController.isFirstTime = false; videoDetailController.playerInit(); videoIntroController.isPaused = false; if (_extendNestCtr.position.pixels == 0) { await Future.delayed(const Duration(milliseconds: 300)); plPlayerController!.play(); } + plPlayerController!.addStatusLister(playerListener); super.didPopNext(); } @@ -187,120 +186,127 @@ class _VideoDetailPageState extends State builder: (context, boxConstraints) { double maxWidth = boxConstraints.maxWidth; double maxHeight = boxConstraints.maxHeight; - return Hero( - tag: videoDetailController.heroTag, - child: Stack( - children: [ - FutureBuilder( - future: _futureBuilderFuture, - builder: ((context, snapshot) { - if (snapshot.hasData && - snapshot.data['status']) { - return Obx( - () => videoDetailController - .autoPlay.value - ? PLVideoPlayer( + return Stack( + children: [ + FutureBuilder( + future: _futureBuilderFuture, + builder: ((context, snapshot) { + if (snapshot.hasData && + snapshot.data['status']) { + return Obx( + () => !videoDetailController + .autoPlay.value + ? const SizedBox() + : PLVideoPlayer( + controller: plPlayerController!, + headerControl: HeaderControl( controller: - plPlayerController!, - headerControl: HeaderControl( - controller: - plPlayerController, - videoDetailCtr: - videoDetailController, + plPlayerController, + videoDetailCtr: + videoDetailController, + ), + danmuWidget: Obx( + () => PlDanmaku( + key: Key( + videoDetailController + .danmakuCid.value + .toString()), + cid: videoDetailController + .danmakuCid.value, + playerController: + plPlayerController!, ), - ) - : const SizedBox(), - ); - } else { - return const SizedBox(); - } - }), - ), - Obx( - () => Visibility( - visible: videoDetailController - .isShowCover.value, - child: Positioned( - top: 0, - left: 0, - right: 0, - child: NetworkImgLayer( - type: 'emote', - src: videoDetailController - .videoItem['pic'], - width: maxWidth, - height: maxHeight, - ), + ), + ), + ); + } else { + return const SizedBox(); + } + }), + ), + + Obx( + () => Visibility( + visible: + videoDetailController.isShowCover.value, + child: Positioned( + top: 0, + left: 0, + right: 0, + child: NetworkImgLayer( + type: 'emote', + src: videoDetailController + .videoItem['pic'], + width: maxWidth, + height: maxHeight, ), ), ), + ), - /// 关闭自动播放时 手动播放 - Obx( - () => Visibility( - visible: videoDetailController - .isShowCover.value && - videoDetailController - .isEffective.value && - !videoDetailController - .autoPlay.value, - child: Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - child: AppBar( - primary: false, - foregroundColor: Colors.white, + /// 关闭自动播放时 手动播放 + Obx( + () => Visibility( + visible: videoDetailController + .isShowCover.value && + videoDetailController + .isEffective.value && + !videoDetailController.autoPlay.value, + child: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: AppBar( + primary: false, + foregroundColor: Colors.white, + backgroundColor: + Colors.transparent, + actions: [ + IconButton( + tooltip: '稍后再看', + onPressed: () async { + var res = await UserHttp + .toViewLater( + bvid: + videoDetailController + .bvid); + SmartDialog.showToast( + res['msg']); + }, + icon: const Icon( + Icons.history_outlined), + ), + const SizedBox(width: 14) + ], + ), + ), + Positioned( + right: 12, + bottom: 10, + child: TextButton.icon( + style: ButtonStyle( backgroundColor: - Colors.transparent, - actions: [ - IconButton( - tooltip: '稍后再看', - onPressed: () async { - var res = await UserHttp - .toViewLater( - bvid: - videoDetailController - .bvid); - SmartDialog.showToast( - res['msg']); - }, - icon: const Icon( - Icons.history_outlined), - ), - const SizedBox(width: 14) - ], + MaterialStateProperty + .resolveWith((states) { + return Theme.of(context) + .colorScheme + .primaryContainer; + }), ), - ), - Positioned( - right: 12, - bottom: 10, - child: TextButton.icon( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty - .resolveWith( - (states) { - return Theme.of(context) - .colorScheme - .primaryContainer; - }), - ), - onPressed: () => handlePlay(), - icon: const Icon( - Icons.play_circle_outline, - size: 20, - ), - label: const Text('Play'), + onPressed: () => handlePlay(), + icon: const Icon( + Icons.play_circle_outline, + size: 20, ), + label: const Text('Play'), ), - ], - )), - ), - ], - ), + ), + ], + )), + ), + ], ); }, ), diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index 0a84a193..8867233c 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -499,6 +499,28 @@ class _HeaderControlState extends State { // ), // fuc: () => _.screenshot(), // ), + SizedBox( + width: 34, + height: 34, + child: Obx( + () => IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + _.isOpenDanmu.value = !_.isOpenDanmu.value; + }, + icon: Icon( + _.isOpenDanmu.value + ? Icons.subtitles_outlined + : Icons.subtitles_off_outlined, + size: 19, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 4), Obx( () => SizedBox( width: 45, diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 861c7898..84a07070 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -11,18 +11,15 @@ import 'package:hive/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:pilipala/http/video.dart'; -import 'package:pilipala/plugin/pl_player/models/data_source.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:screen_brightness/screen_brightness.dart'; import 'package:universal_platform/universal_platform.dart'; // import 'package:wakelock_plus/wakelock_plus.dart'; -import 'models/data_status.dart'; -import 'models/play_speed.dart'; -import 'models/play_status.dart'; - Box videoStorage = GStrorage.video; +Box setting = GStrorage.setting; class PlPlayerController { Player? _videoPlayerController; @@ -84,6 +81,7 @@ class PlPlayerController { int _cid = 0; int _heartDuration = 0; bool _enableHeart = true; + bool _isFirstTime = true; Timer? _timer; Timer? _timerForSeek; @@ -105,6 +103,7 @@ class PlPlayerController { ]; PreferredSizeWidget? headerControl; + Widget? danmuWidget; /// 数据加载监听 Stream get onDataStatusChanged => dataStatus.status.stream; @@ -195,6 +194,9 @@ class PlPlayerController { /// Rx get videoType => _videoType; + /// 弹幕开关 + Rx isOpenDanmu = true.obs; + // 添加一个私有构造函数 PlPlayerController._() { _videoType = videoType; @@ -248,6 +250,8 @@ class PlPlayerController { int cid = 0, // 历史记录开关 bool enableHeart = true, + // 是否首次加载 + bool isFirstTime = true, }) async { try { _autoPlay = autoplay; @@ -261,6 +265,7 @@ class PlPlayerController { _bvid = bvid; _cid = cid; _enableHeart = enableHeart; + _isFirstTime = isFirstTime; if (_videoPlayerController != null && _videoPlayerController!.state.playing) { @@ -281,6 +286,12 @@ class PlPlayerController { if (!_listenersInitialized) { startListeners(); } + bool autoEnterFullcreen = + setting.get(SettingBoxKey.enableAutoEnter, defaultValue: false); + if (autoEnterFullcreen && _isFirstTime) { + await Future.delayed(const Duration(milliseconds: 100)); + triggerFullScreen(); + } } catch (err) { dataStatus.status.value = DataStatus.error; print('plPlayer err: $err'); @@ -397,6 +408,8 @@ class PlPlayerController { } List subscriptions = []; + final List _positionListeners = []; + final List _statusListeners = []; /// 播放事件监听 void startListeners() { @@ -408,11 +421,21 @@ class PlPlayerController { } else { // playerStatus.status.value = PlayerStatus.paused; } + + /// 触发回调事件 + for (var element in _statusListeners) { + element(event ? PlayerStatus.playing : PlayerStatus.paused); + } makeHeartBeat(_position.value.inSeconds, type: 'status'); }), videoPlayerController!.stream.completed.listen((event) { if (event) { playerStatus.status.value = PlayerStatus.completed; + + /// 触发回调事件 + for (var element in _statusListeners) { + element(PlayerStatus.completed); + } } else { // playerStatus.status.value = PlayerStatus.playing; } @@ -423,6 +446,11 @@ class PlPlayerController { if (!isSliderMoving.value) { _sliderPosition.value = event; } + + /// 触发回调事件 + for (var element in _positionListeners) { + element(event); + } makeHeartBeat(event.inSeconds); }), videoPlayerController!.stream.duration.listen((event) { @@ -714,6 +742,79 @@ class PlPlayerController { _isFullScreen.value = val; } + // 全屏 + Future triggerFullScreen({bool status = true}) async { + FullScreenMode mode = FullScreenModeCode.fromCode( + setting.get(SettingBoxKey.fullScreenMode, defaultValue: 0))!; + + if (!isFullScreen.value && status) { + /// 按照视频宽高比决定全屏方向 + switch (mode) { + case FullScreenMode.auto: + if (direction.value == 'horizontal') { + /// 进入全屏 + await enterFullScreen(); + // 横屏 + await landScape(); + } else { + // 竖屏 + await verticalScreen(); + } + break; + case FullScreenMode.vertical: + + /// 进入全屏 + await enterFullScreen(); + // 横屏 + await verticalScreen(); + break; + case FullScreenMode.horizontal: + + /// 进入全屏 + await enterFullScreen(); + // 横屏 + await landScape(); + break; + } + + toggleFullScreen(true); + print(headerControl); + print(danmuWidget); + var result = await showDialog( + context: Get.context!, + useSafeArea: false, + builder: (context) => Dialog.fullscreen( + backgroundColor: Colors.black, + child: PLVideoPlayer( + controller: this, + headerControl: headerControl, + danmuWidget: danmuWidget, + ), + ), + ); + if (result == null) { + // 退出全屏 + exitFullScreen(); + await verticalScreen(); + toggleFullScreen(false); + } + } else if (isFullScreen.value) { + Get.back(); + exitFullScreen(); + await verticalScreen(); + toggleFullScreen(false); + } + } + + void addPositionListener(Function(Duration position) listener) => + _positionListeners.add(listener); + void removePositionListener(Function(Duration position) listener) => + _positionListeners.remove(listener); + void addStatusLister(Function(PlayerStatus status) listener) => + _statusListeners.add(listener); + void removeStatusLister(Function(PlayerStatus status) listener) => + _statusListeners.remove(listener); + /// 截屏 Future screenshot() async { final Uint8List? screenshot = diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 077b5a1b..67d25223 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -89,6 +89,7 @@ class _PLVideoPlayerState extends State vsync: this, duration: const Duration(milliseconds: 300)); videoController = widget.controller.videoController!; widget.controller.headerControl = widget.headerControl; + widget.controller.danmuWidget = widget.danmuWidget; defaultBtmProgressBehavior = setting.get(SettingBoxKey.btmProgressBehavior, defaultValue: BtmProgresBehavior.values.first.code); @@ -159,67 +160,6 @@ class _PLVideoPlayerState extends State widget.controller.brightness.value = value; } - Future triggerFullScreen() async { - PlPlayerController _ = widget.controller; - mode = FullScreenModeCode.fromCode( - setting.get(SettingBoxKey.fullScreenMode, defaultValue: 0))!; - - if (!_.isFullScreen.value) { - /// 按照视频宽高比决定全屏方向 - switch (mode) { - case FullScreenMode.auto: - if (_.direction.value == 'horizontal') { - /// 进入全屏 - await enterFullScreen(); - // 横屏 - await landScape(); - } else { - // 竖屏 - await verticalScreen(); - } - break; - case FullScreenMode.vertical: - - /// 进入全屏 - await enterFullScreen(); - // 横屏 - await verticalScreen(); - break; - case FullScreenMode.horizontal: - - /// 进入全屏 - await enterFullScreen(); - // 横屏 - await landScape(); - break; - } - - _.toggleFullScreen(true); - var result = await showDialog( - context: Get.context!, - useSafeArea: false, - builder: (context) => Dialog.fullscreen( - backgroundColor: Colors.black, - child: PLVideoPlayer( - controller: _, - headerControl: _.headerControl, - ), - ), - ); - if (result == null) { - // 退出全屏 - exitFullScreen(); - await verticalScreen(); - _.toggleFullScreen(false); - } - } else { - Get.back(); - exitFullScreen(); - await verticalScreen(); - _.toggleFullScreen(false); - } - } - @override void dispose() { animationController.dispose(); @@ -472,6 +412,10 @@ class _PLVideoPlayerState extends State } }), + /// 弹幕面板 + if (widget.danmuWidget != null) + Positioned.fill(top: 4, child: widget.danmuWidget!), + /// 手势 Positioned.fill( left: 16, @@ -559,13 +503,13 @@ class _PLVideoPlayerState extends State if (dy > _distance && dy > threshold) { if (_.isFullScreen.value) { // 下滑退出全屏 - await triggerFullScreen(); + await widget.controller.triggerFullScreen(status: false); } _distance = 0.0; } else if (dy < _distance && dy < -threshold) { if (!_.isFullScreen.value) { // 上滑进入全屏 - await triggerFullScreen(); + await widget.controller.triggerFullScreen(); } _distance = 0.0; } @@ -606,7 +550,7 @@ class _PLVideoPlayerState extends State position: 'bottom', child: BottomControl( controller: widget.controller, - triggerFullScreen: triggerFullScreen), + triggerFullScreen: widget.controller.triggerFullScreen), ), ), ], diff --git a/lib/utils/danmaku.dart b/lib/utils/danmaku.dart new file mode 100644 index 00000000..a76cc77f --- /dev/null +++ b/lib/utils/danmaku.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; + +class DmUtils { + static Color decimalToColor(int decimalColor) { + int red = (decimalColor >> 16) & 0xFF; + int green = (decimalColor >> 8) & 0xFF; + int blue = decimalColor & 0xFF; + + return Color.fromARGB(255, red, green, blue); + } + + static DanmakuItemType getPosition(int mode) { + DanmakuItemType type = DanmakuItemType.scroll; + if (mode >= 1 && mode <= 3) { + type = DanmakuItemType.scroll; + } else if (mode == 4) { + type = DanmakuItemType.bottom; + } else if (mode == 5) { + type = DanmakuItemType.top; + } + return type; + } +} diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 21a8060b..91094a39 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -97,6 +97,8 @@ class SettingBoxKey { static const String enableHA = 'enableHA'; static const String enableOnlineTotal = 'enableOnlineTotal'; static const String enableAutoBrightness = 'enableAutoBrightness'; + static const String enableAutoEnter = 'enableAutoEnter'; + static const String enableAutoExit = 'enableAutoExit'; /// 隐私 static const String blackMidsList = 'blackMidsList'; diff --git a/pubspec.lock b/pubspec.lock index 17505881..e97bff54 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -805,6 +805,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + ns_danmaku: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "419a35a776f9784f07999c8f1f75eb26fd9fe90a" + url: "https://github.com/xiaoyaocz/flutter_ns_danmaku.git" + source: git + version: "0.0.5" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e2610bf5..06d6fbea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -114,6 +114,12 @@ dependencies: flutter_displaymode: ^0.6.0 # scheme跳转 appscheme: ^1.0.8 + # 弹幕 + ns_danmaku: + git: + url: https://github.com/xiaoyaocz/flutter_ns_danmaku.git + ref: master + dev_dependencies: flutter_test: