diff --git a/lib/models/video/play/quality.dart b/lib/models/video/play/quality.dart new file mode 100644 index 000000000..7536b9714 --- /dev/null +++ b/lib/models/video/play/quality.dart @@ -0,0 +1,91 @@ +enum VideoQuality { + speed240, + flunt360, + clear480, + high720, + high72060, + high1080, + high1080plus, + high108060, + super4K, + hdr, + dolbyVision, + super8k +} + +extension VideoQualityCode on VideoQuality { + static final List _codeList = [ + 6, + 16, + 32, + 64, + 74, + 80, + 112, + 116, + 120, + 125, + 126, + 127, + ]; + int get code => _codeList[index]; + + static VideoQuality? fromCode(int code) { + final index = _codeList.indexOf(code); + if (index != -1) { + return VideoQuality.values[index]; + } + return null; + } +} + +extension VideoQualityDesc on VideoQuality { + static final List _descList = [ + '240P 极速', + '360P 流畅', + '480P 清晰', + '720P 高清', + '720P60 高帧率', + '1080P 高清', + '1080P+ 高码率', + '1080P60 高帧率', + '4K 超清', + 'HDR 真彩色', + '杜比视界', + '8K 超高清' + ]; + get description => _descList[index]; +} + +/// +enum AudioQuality { k64, k132, k192, dolby, hiRes } + +extension AudioQualityCode on AudioQuality { + static final List _codeList = [ + 30216, + 30232, + 30280, + 30250, + 30251, + ]; + int get code => _codeList[index]; + + static AudioQuality? fromCode(int code) { + final index = _codeList.indexOf(code); + if (index != -1) { + return AudioQuality.values[index]; + } + return null; + } +} + +extension AudioQualityDesc on AudioQuality { + static final List _descList = [ + '64K', + '132K', + '192K', + '杜比全景声', + 'Hi-Res无损', + ]; + get description => _descList[index]; +} diff --git a/lib/models/video/play/url.dart b/lib/models/video/play/url.dart index a879fefb3..8944d797b 100644 --- a/lib/models/video/play/url.dart +++ b/lib/models/video/play/url.dart @@ -1,3 +1,5 @@ +import 'package:pilipala/models/video/play/quality.dart'; + class PlayUrlModel { PlayUrlModel({ this.from, @@ -32,7 +34,7 @@ class PlayUrlModel { String? seekParam; String? seekType; Dash? dash; - List? supportFormats; + List? supportFormats; // String? highFormat; int? lastPlayTime; int? lastPlayCid; @@ -51,7 +53,11 @@ class PlayUrlModel { seekParam = json['seek_param']; seekType = json['seek_type']; dash = Dash.fromJson(json['dash']); - supportFormats = json['support_formats']; + supportFormats = json['support_formats'] != null + ? json['support_formats'] + .map((e) => FormatItem.fromJson(e)) + .toList() + : []; lastPlayTime = json['last_play_time']; lastPlayCid = json['last_play_cid']; } @@ -101,6 +107,7 @@ class VideoItem { this.startWithSap, this.segmentBase, this.codecid, + this.quality, }); int? id; @@ -116,6 +123,7 @@ class VideoItem { int? startWithSap; Map? segmentBase; int? codecid; + VideoQuality? quality; VideoItem.fromJson(Map json) { id = json['id']; @@ -131,6 +139,7 @@ class VideoItem { startWithSap = json['startWithSap']; segmentBase = json['segmentBase']; codecid = json['codecid']; + quality = VideoQuality.values.firstWhere((i) => i.code == json['id']); } } @@ -149,6 +158,7 @@ class AudioItem { this.startWithSap, this.segmentBase, this.codecid, + this.quality, }); int? id; @@ -164,6 +174,7 @@ class AudioItem { int? startWithSap; Map? segmentBase; int? codecid; + String? quality; AudioItem.fromJson(Map json) { id = json['id']; @@ -179,5 +190,31 @@ class AudioItem { startWithSap = json['startWithSap']; segmentBase = json['segmentBase']; codecid = json['codecid']; + quality = + AudioQuality.values.firstWhere((i) => i.code == json['id']).description; + } +} + +class FormatItem { + FormatItem({ + this.quality, + this.format, + this.newDesc, + this.displayDesc, + this.codecs, + }); + + int? quality; + String? format; + String? newDesc; + String? displayDesc; + List? codecs; + + FormatItem.fromJson(Map json) { + quality = json['quality']; + format = json['format']; + newDesc = json['new_description']; + displayDesc = json['display_desc']; + codecs = json['codecs']; } } diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 3051ba550..d5ab79ff9 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -5,6 +5,7 @@ import 'package:hive/hive.dart'; import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/models/common/reply_type.dart'; +import 'package:pilipala/models/video/play/quality.dart'; import 'package:pilipala/models/video/play/url.dart'; import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/pages/video/detail/replyReply/index.dart'; @@ -21,6 +22,11 @@ class VideoDetailController extends GetxController // 视频aid String bvid = Get.parameters['bvid']!; int cid = int.parse(Get.parameters['cid']!); + late PlayUrlModel data; + // 当前画质 + late VideoQuality currentVideoQa; + // 当前音质 + late AudioQuality currentAudioQa; // 是否预渲染 骨架屏 bool preRender = false; @@ -44,6 +50,10 @@ class VideoDetailController extends GetxController Box user = GStrorage.user; Box localCache = GStrorage.localCache; PlPlayerController plPlayerController = PlPlayerController(); + // 是否开始自动播放 存在多p的情况下,第二p需要为true + RxBool autoPlay = true.obs; + // 视频资源是否有效 + RxBool isEffective = true.obs; @override void onInit() { @@ -86,6 +96,29 @@ class VideoDetailController extends GetxController }); } + /// 更新画质、音质 + /// TODO 继续进度播放 + updatePlayer() { + Duration position = plPlayerController.position.value; + plPlayerController.removeListeners(); + plPlayerController.isBuffering.value = false; + plPlayerController.buffered.value = Duration.zero; + + /// 暂不匹配解码规则 + + /// 根据currentVideoQa 重新设置videoUrl + VideoItem firstVideo = + data.dash!.video!.firstWhere((i) => i.id == currentVideoQa.code); + String videoUrl = firstVideo.baseUrl!; + + /// 根据currentAudioQa 重新设置audioUrl + AudioItem firstAudio = + data.dash!.audio!.firstWhere((i) => i.id == currentAudioQa.code); + String audioUrl = firstAudio.baseUrl ?? ''; + + playerInit(videoUrl, audioUrl, defaultST: position); + } + playerInit(source, audioSource, {Duration defaultST = Duration.zero, int duration = 0}) async { plPlayerController.setDataSource( @@ -101,24 +134,42 @@ class VideoDetailController extends GetxController ), // 硬解 enableHA: true, - autoplay: true, + autoplay: autoPlay.value, seekTo: defaultST, duration: Duration(milliseconds: duration), ); } + // 手动点击播放 + handlePlay() { + plPlayerController.togglePlay(); + } + // 视频链接 queryVideoUrl() async { var result = await VideoHttp.videoUrl(cid: cid, bvid: bvid); if (result['status']) { - PlayUrlModel data = result['data']; - // 指定质量的视频 -> 最高质量的视频 - String videoUrl = data.dash!.video!.first.baseUrl!; - String audioUrl = - data.dash!.audio!.isNotEmpty ? data.dash!.audio!.first.baseUrl! : ''; - playerInit(videoUrl, audioUrl, - defaultST: Duration(milliseconds: data.lastPlayTime!), - duration: data.timeLength ?? 0); + data = result['data']; + + /// 优先顺序 省流模式 -> 设置中指定质量 -> 当前可选的最高质量 + VideoItem firstVideo = data.dash!.video!.first; + String videoUrl = firstVideo.baseUrl!; + // + currentVideoQa = VideoQualityCode.fromCode(firstVideo.id!)!; + + /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 + AudioItem firstAudio = + data.dash!.audio!.isNotEmpty ? data.dash!.audio!.first : AudioItem(); + String audioUrl = firstAudio.baseUrl ?? ''; + // + currentAudioQa = AudioQualityCode.fromCode(firstAudio.id!)!; + + playerInit( + videoUrl, + audioUrl, + defaultST: Duration(milliseconds: data.lastPlayTime!), + duration: data.timeLength ?? 0, + ); } } diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 056c7629b..68edd92aa 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -13,6 +13,7 @@ import 'package:pilipala/pages/video/detail/related/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'widgets/app_bar.dart'; +import 'widgets/header_control.dart'; class VideoDetailPage extends StatefulWidget { const VideoDetailPage({Key? key}) : super(key: key); @@ -54,7 +55,11 @@ class _VideoDetailPageState extends State isPlay = true; setState(() {}); // 播放完成停止 or 切换下一个 - if (status == PlayerStatus.completed) {} + if (status == PlayerStatus.completed) { + // 当只有1p或多p未打开自动播放时,播放完成还原进度条,展示控制栏 + plPlayerController!.seekTo(Duration.zero); + plPlayerController!.onLockControl(false); + } } }, ); @@ -158,7 +163,12 @@ class _VideoDetailPageState extends State .videoPlayerController != null) PLVideoPlayer( - controller: plPlayerController!), + controller: plPlayerController!, + headerControl: HeaderControl( + controller: plPlayerController, + videoDetailCtr: videoDetailController, + ), + ), Visibility( visible: isShowCover, child: Positioned( @@ -174,6 +184,38 @@ class _VideoDetailPageState extends State ), ), ), + + /// 关闭自动播放时 手动播放 + Obx( + () => Visibility( + visible: isShowCover && + videoDetailController + .isEffective.value && + !videoDetailController.autoPlay.value, + child: Positioned( + right: 12, + bottom: 6, + child: TextButton.icon( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty + .resolveWith((states) { + return Theme.of(context) + .colorScheme + .primaryContainer; + }), + ), + onPressed: () => videoDetailController + .handlePlay(), + icon: const Icon( + Icons.play_circle_outline, + size: 20, + ), + label: const Text('Play'), + ), + ), + ), + ), ], ), ); @@ -194,37 +236,41 @@ class _VideoDetailPageState extends State color: Theme.of(context).colorScheme.background, child: Column( children: [ - Container( - width: double.infinity, - height: 0, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: - Theme.of(context).dividerColor.withOpacity(0.1), - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Container( - width: 280, - margin: const EdgeInsets.only(left: 20), - child: Obx( - () => TabBar( - controller: videoDetailController.tabCtr, - dividerColor: Colors.transparent, - indicatorColor: - Theme.of(context).colorScheme.background, - tabs: videoDetailController.tabs - .map((String name) => Tab(text: name)) - .toList(), - ), + Opacity( + opacity: 0, + child: Container( + width: double.infinity, + height: 0, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context) + .dividerColor + .withOpacity(0.1), ), ), - ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + width: 280, + margin: const EdgeInsets.only(left: 20), + child: Obx( + () => TabBar( + controller: videoDetailController.tabCtr, + dividerColor: Colors.transparent, + indicatorColor: + Theme.of(context).colorScheme.background, + tabs: videoDetailController.tabs + .map((String name) => Tab(text: name)) + .toList(), + ), + ), + ), + ], + ), ), ), Expanded( diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart new file mode 100644 index 000000000..b4a50749f --- /dev/null +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/models/video/play/quality.dart'; +import 'package:pilipala/models/video/play/url.dart'; +import 'package:pilipala/pages/home/index.dart'; +import 'package:pilipala/pages/video/detail/index.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; + +class HeaderControl extends StatefulWidget implements PreferredSizeWidget { + final PlPlayerController? controller; + final VideoDetailController? videoDetailCtr; + const HeaderControl({ + this.controller, + this.videoDetailCtr, + Key? key, + }) : super(key: key); + + @override + State createState() => _HeaderControlState(); + + @override + Size get preferredSize => throw UnimplementedError(); +} + +class _HeaderControlState extends State { + late PlayUrlModel videoInfo; + List playSpeed = PlaySpeed.values; + + Size get preferredSize => const Size(double.infinity, kToolbarHeight); + + @override + void initState() { + super.initState(); + videoInfo = widget.videoDetailCtr!.data; + } + + /// 设置面板 + void showSettingSheet() { + TextStyle subTitleStyle = + TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.outline); + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) { + return Container( + width: double.infinity, + height: 420, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: EdgeInsets.only( + left: 12, + right: 12, + bottom: MediaQuery.of(context).padding.bottom + 23, + ), + child: Column( + children: [ + SizedBox( + height: 35, + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer + .withOpacity(0.5), + borderRadius: + const BorderRadius.all(Radius.circular(3))), + ), + ), + ), + Expanded( + child: Material( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + children: [ + ListTile( + onTap: () {}, + dense: true, + enabled: false, + leading: + const Icon(Icons.network_cell_outlined, size: 20), + title: const Text('省流模式'), + subtitle: Text('低画质 | 减少视频缓存', style: subTitleStyle), + trailing: Transform.scale( + scale: 0.75, + child: Switch( + thumbIcon: MaterialStateProperty.resolveWith( + (Set states) { + if (states.isNotEmpty && + states.first == MaterialState.selected) { + return const Icon(Icons.done); + } + return null; // All other states will use the default thumbIcon. + }), + value: false, + onChanged: (value) => {}, + ), + ), + ), + Obx( + () => ListTile( + onTap: () => {Get.back(), showSetSpeedSheet()}, + dense: true, + leading: const Icon(Icons.speed_outlined, size: 20), + title: const Text('播放速度'), + subtitle: Text( + '当前倍速 x${widget.controller!.playbackSpeed}', + style: subTitleStyle), + ), + ), + ListTile( + onTap: () => {Get.back(), showSetVideoQa()}, + dense: true, + leading: const Icon(Icons.play_circle_outline, size: 20), + title: const Text('选择画质'), + subtitle: Text( + '当前画质 ${widget.videoDetailCtr!.currentVideoQa.description}', + style: subTitleStyle), + ), + ListTile( + onTap: () => {Get.back(), showSetAudioQa()}, + dense: true, + leading: const Icon(Icons.album_outlined, size: 20), + title: const Text('选择音质'), + subtitle: Text( + '当前音质 ${widget.videoDetailCtr!.currentAudioQa.description}', + style: subTitleStyle), + ), + ListTile( + onTap: () {}, + dense: true, + enabled: false, + leading: const Icon(Icons.play_circle_outline, size: 20), + title: const Text('播放设置'), + ), + ListTile( + onTap: () {}, + dense: true, + enabled: false, + leading: const Icon(Icons.subtitles_outlined, size: 20), + title: const Text('弹幕设置'), + ), + ], + ), + )) + ], + ), + ); + }, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, + ); + } + + /// 选择倍速 + void showSetSpeedSheet() { + showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Container( + width: double.infinity, + height: 450, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: EdgeInsets.only( + left: 12, + right: 12, + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Material( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + children: [ + const SizedBox( + height: 45, + child: Center( + child: Text('播放速度'), + ), + ), + for (var i in playSpeed) ...[ + ListTile( + onTap: () { + widget.controller!.setPlaybackSpeed(i.value); + Get.back(result: {'playbackSpeed': i.value}); + }, + dense: true, + contentPadding: const EdgeInsets.only(left: 20, right: 20), + title: Text(i.description), + trailing: i.value == widget.controller!.playbackSpeed + ? Icon( + Icons.done, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + ] + ], + ), + ), + ); + }, + ); + } + + /// 选择画质 + void showSetVideoQa() { + List videoFormat = videoInfo.supportFormats!; + VideoQuality currentVideoQa = widget.videoDetailCtr!.currentVideoQa; + + /// 总质量分类 + int totalQaSam = videoFormat.length; + + /// 可用的质量分类 + int userfulQaSam = 0; + List video = videoInfo.dash!.video!; + Set idSet = {}; + for (var item in video) { + int id = item.id!; + if (!idSet.contains(id)) { + idSet.add(id); + userfulQaSam++; + } + } + + TextStyle subTitleStyle = + TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.outline); + showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Container( + width: double.infinity, + height: 310, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: EdgeInsets.only( + left: 12, + right: 12, + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Column( + children: [ + SizedBox( + height: 45, + child: GestureDetector( + onTap: () { + SmartDialog.showToast('标灰画质可能需要bilibili会员'); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('选择画质'), + const SizedBox(width: 4), + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.outline, + ) + ], + ), + ), + ), + Expanded( + child: Material( + child: Scrollbar( + thumbVisibility: true, + child: ListView( + children: [ + for (var i = 0; i < totalQaSam; i++) ...[ + ListTile( + onTap: () { + final int quality = videoFormat[i].quality!; + widget.videoDetailCtr!.currentVideoQa = + VideoQualityCode.fromCode(quality)!; + widget.videoDetailCtr!.updatePlayer(); + Get.back(); + }, + dense: true, + // 可能包含会员解锁画质 + enabled: i >= totalQaSam - userfulQaSam, + contentPadding: + const EdgeInsets.only(left: 20, right: 20), + title: Text(videoFormat[i].newDesc!), + subtitle: Text( + videoFormat[i].format!, + style: subTitleStyle, + ), + trailing: currentVideoQa.code == + videoFormat[i].quality + ? Icon( + Icons.done, + color: + Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + ), + ] + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// 选择音质 + void showSetAudioQa() { + List videoFormat = videoInfo.supportFormats!; + AudioQuality currentAudioQa = widget.videoDetailCtr!.currentAudioQa; + + List audio = videoInfo.dash!.audio!; + TextStyle subTitleStyle = + TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.outline); + showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return Container( + width: double.infinity, + height: 250, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: EdgeInsets.only( + left: 12, + right: 12, + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Column( + children: [ + const SizedBox(height: 45, child: Center(child: Text('选择音质'))), + Expanded( + child: Material( + child: ListView( + children: [ + for (var i in audio) ...[ + ListTile( + onTap: () { + final int quality = i.id!; + widget.videoDetailCtr!.currentAudioQa = + AudioQualityCode.fromCode(quality)!; + widget.videoDetailCtr!.updatePlayer(); + Get.back(); + }, + dense: true, + contentPadding: + const EdgeInsets.only(left: 20, right: 20), + title: Text(i.quality!), + subtitle: Text( + i.codecs!, + style: subTitleStyle, + ), + trailing: currentAudioQa.code == i.id + ? Icon( + Icons.done, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + ), + ] + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final _ = widget.controller!; + const textStyle = TextStyle( + color: Colors.white, + fontSize: 12, + ); + return AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + primary: false, + centerTitle: false, + automaticallyImplyLeading: false, + titleSpacing: 14, + title: Row( + children: [ + ComBtn( + icon: const Icon( + FontAwesomeIcons.arrowLeft, + size: 15, + color: Colors.white, + ), + fuc: () => Get.back(), + ), + const SizedBox(width: 4), + ComBtn( + icon: const Icon( + FontAwesomeIcons.house, + size: 15, + color: Colors.white, + ), + fuc: () => Get.offAll(const HomePage()), + ), + const Spacer(), + // ComBtn( + // icon: const Icon( + // FontAwesomeIcons.cropSimple, + // size: 15, + // color: Colors.white, + // ), + // fuc: () => _.screenshot(), + // ), + Obx( + () => SizedBox( + width: 45, + height: 34, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + _.togglePlaybackSpeed(); + }, + child: Text( + '${_.playbackSpeed.toString()}X', + style: textStyle, + ), + ), + ), + ), + const SizedBox(width: 4), + ComBtn( + icon: const Icon( + FontAwesomeIcons.sliders, + size: 15, + color: Colors.white, + ), + fuc: () => showSettingSheet(), + ), + ], + ), + ); + } +} diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 94b5354bf..8f41c6745 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -7,6 +7,7 @@ import 'package:hive/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:pilipala/plugin/pl_player/models/data_source.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'; @@ -14,6 +15,7 @@ import 'package:volume_controller/volume_controller.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; @@ -37,6 +39,7 @@ class PlPlayerController { // 播放位置 final Rx _position = Rx(Duration.zero); final Rx _sliderPosition = Rx(Duration.zero); + final Rx _sliderTempPosition = Rx(Duration.zero); final Rx _duration = Rx(Duration.zero); final Rx _buffered = Rx(Duration.zero); @@ -49,13 +52,13 @@ class PlPlayerController { final Rx _showVolumeStatus = false.obs; final Rx _showBrightnessStatus = false.obs; final Rx _doubleSpeedStatus = false.obs; - final Rx _controlsClose = false.obs; + final Rx _controlsLock = false.obs; Rx videoFitChanged = false.obs; final Rx _videoFit = Rx(BoxFit.fill); /// - bool _isSliderMoving = false; + Rx _isSliderMoving = false.obs; PlaylistMode _looping = PlaylistMode.none; bool _autoPlay = false; final bool _listenersInitialized = false; @@ -110,10 +113,15 @@ class PlPlayerController { /// [videoController] instace of Player VideoController? get videoController => _videoController; + Rx get isSliderMoving => _isSliderMoving; + /// 进度条位置及监听 Rx get sliderPosition => _sliderPosition; Stream get onSliderPositionChanged => _sliderPosition.stream; + Rx get sliderTempPosition => _sliderTempPosition; + // Stream get onSliderPositionChanged => _sliderPosition.stream; + /// 是否展示控制条及监听 Rx get showControls => _showControls; Stream get onShowControlsChanged => _showControls.stream; @@ -149,9 +157,11 @@ class PlPlayerController { Rx isBuffering = true.obs; - Rx get controlsClose => _controlsClose; + /// 屏幕锁 为true时,关闭控制栏 + Rx get controlsLock => _controlsLock; PlPlayerController({ + // 直播间 传false 关闭控制栏 this.controlsEnabled = true, this.fits = const [ BoxFit.contain, @@ -177,7 +187,7 @@ class PlPlayerController { DataSource dataSource, { bool autoplay = true, // 默认不循环 - PlaylistMode looping = PlaylistMode.single, + PlaylistMode looping = PlaylistMode.none, // 初始化播放位置 Duration seekTo = Duration.zero, // 初始化播放速度 @@ -195,6 +205,7 @@ class PlPlayerController { _duration.value = duration ?? Duration.zero; // 初始化视频倍速 _playbackSpeed.value = speed; + // 初始化数据加载状态 dataStatus.status.value = DataStatus.loading; if (_videoPlayerController != null && @@ -202,10 +213,12 @@ class PlPlayerController { await pause(notify: false); } + // 配置Player 音轨、字幕等等 _videoPlayerController = await _createVideoController( dataSource, _looping, enableHA, width, height); - + // 获取视频时长 00:00 _duration.value = _videoPlayerController!.state.duration; + // 数据加载完成 dataStatus.status.value = DataStatus.loaded; await _initializePlayer(seekTo: seekTo); @@ -344,7 +357,7 @@ class PlPlayerController { }), videoPlayerController!.stream.position.listen((event) { _position.value = event; - if (!_isSliderMoving) { + if (!isSliderMoving.value) { _sliderPosition.value = event; } }), @@ -413,11 +426,11 @@ class PlPlayerController { /// 设置倍速 Future togglePlaybackSpeed() async { - List allowedSpeeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.50, 1.75, 2.0]; - if (allowedSpeeds.indexOf(_playbackSpeed.value) < - allowedSpeeds.length - 1) { - setPlaybackSpeed( - allowedSpeeds[allowedSpeeds.indexOf(_playbackSpeed.value) + 1]); + List allowedSpeeds = + PlaySpeed.values.map((e) => e.value).toList(); + int index = allowedSpeeds.indexOf(_playbackSpeed.value); + if (index < allowedSpeeds.length - 1) { + setPlaybackSpeed(allowedSpeeds[index + 1]); } else { setPlaybackSpeed(allowedSpeeds[0]); } @@ -451,6 +464,7 @@ class PlPlayerController { /// 更改播放状态 Future togglePlay() async { + feedBack(); if (playerStatus.playing) { pause(); } else { @@ -461,7 +475,7 @@ class PlPlayerController { /// 隐藏控制条 void _hideTaskControls() { _timer = Timer(const Duration(milliseconds: 3000), () { - if (!_isSliderMoving) { + if (!isSliderMoving.value) { controls = false; } _timer = null; @@ -474,11 +488,16 @@ class PlPlayerController { } void onChangedSliderStart() { - _isSliderMoving = true; + feedBack(); + _isSliderMoving.value = true; + } + + void onUodatedSliderProgress(value) { + _sliderTempPosition.value = value; } void onChangedSliderEnd() { - _isSliderMoving = false; + _isSliderMoving.value = false; _hideTaskControls(); } @@ -599,8 +618,9 @@ class PlPlayerController { } /// 关闭控制栏 - void onCloseControl(bool val) { - _controlsClose.value = val; + void onLockControl(bool val) { + feedBack(); + _controlsLock.value = val; showControls.value = !val; } @@ -630,10 +650,12 @@ class PlPlayerController { _position.close(); _playerEventSubs?.cancel(); _sliderPosition.close(); + _sliderTempPosition.close(); + _isSliderMoving.close(); _duration.close(); _buffered.close(); _showControls.close(); - _controlsClose.close(); + _controlsLock.close(); playerStatus.status.close(); dataStatus.status.close(); diff --git a/lib/plugin/pl_player/index.dart b/lib/plugin/pl_player/index.dart index cab7264ea..997776f9f 100644 --- a/lib/plugin/pl_player/index.dart +++ b/lib/plugin/pl_player/index.dart @@ -5,3 +5,5 @@ export './view.dart'; export './models/data_source.dart'; export './models/play_status.dart'; export './models/data_status.dart'; +export './widgets/common_btn.dart'; +export './models/play_speed.dart'; diff --git a/lib/plugin/pl_player/models/duration.dart b/lib/plugin/pl_player/models/duration.dart new file mode 100644 index 000000000..3ce3cc470 --- /dev/null +++ b/lib/plugin/pl_player/models/duration.dart @@ -0,0 +1,29 @@ +extension DurationExtension on Duration { + /// Returns clamp of [Duration] between [min] and [max]. + Duration clamp(Duration min, Duration max) { + if (this < min) return min; + if (this > max) return max; + return this; + } + + /// Returns a [String] representation of [Duration]. + String label({Duration? reference}) { + reference ??= this; + if (reference > const Duration(days: 1)) { + final days = inDays.toString().padLeft(3, '0'); + final hours = (inHours - (inDays * 24)).toString().padLeft(2, '0'); + final minutes = (inMinutes - (inHours * 60)).toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$days:$hours:$minutes:$seconds'; + } else if (reference > const Duration(hours: 1)) { + final hours = inHours.toString().padLeft(2, '0'); + final minutes = (inMinutes - (inHours * 60)).toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$hours:$minutes:$seconds'; + } else { + final minutes = inMinutes.toString().padLeft(2, '0'); + final seconds = (inSeconds - (inMinutes * 60)).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + } +} diff --git a/lib/plugin/pl_player/models/play_speed.dart b/lib/plugin/pl_player/models/play_speed.dart new file mode 100644 index 000000000..01226ed57 --- /dev/null +++ b/lib/plugin/pl_player/models/play_speed.dart @@ -0,0 +1,37 @@ +enum PlaySpeed { + pointTwoFive, + pointFive, + pointSevenFive, + one, + onePointTwoFive, + onePointFive, + onePointSevenFive, + two +} + +extension PlaySpeedExtension on PlaySpeed { + static final List _descList = [ + '0.25倍', + '0.5倍', + '0.75倍', + '正常速度', + '1.25倍', + '1.5倍', + '1.75倍', + '2.0倍', + ]; + get description => _descList[index]; + + static final List _valueList = [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0 + ]; + get value => _valueList[index]; + get defaultValue => _valueList[3]; +} diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 854c72771..0dac67ebe 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -2,20 +2,32 @@ import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:pilipala/common/widgets/app_bar_ani.dart'; import 'package:pilipala/plugin/pl_player/controller.dart'; +import 'package:pilipala/plugin/pl_player/models/duration.dart'; import 'package:pilipala/plugin/pl_player/models/play_status.dart'; +import 'package:pilipala/plugin/pl_player/utils.dart'; import 'package:pilipala/utils/feed_back.dart'; +import 'widgets/backward_seek.dart'; import 'widgets/bottom_control.dart'; import 'widgets/common_btn.dart'; -import 'widgets/header_control.dart'; +import 'widgets/forward_seek.dart'; +import 'widgets/play_pause_btn.dart'; class PLVideoPlayer extends StatefulWidget { final PlPlayerController controller; + final PreferredSizeWidget? headerControl; + final Widget? danmuWidget; - const PLVideoPlayer({required this.controller, super.key}); + const PLVideoPlayer({ + required this.controller, + this.headerControl, + this.danmuWidget, + super.key, + }); @override State createState() => _PLVideoPlayerState(); @@ -26,6 +38,23 @@ class _PLVideoPlayerState extends State late AnimationController animationController; late VideoController videoController; + bool _mountSeekBackwardButton = false; + bool _mountSeekForwardButton = false; + bool _hideSeekBackwardButton = false; + bool _hideSeekForwardButton = false; + + void onDoubleTapSeekBackward() { + setState(() { + _mountSeekBackwardButton = true; + }); + } + + void onDoubleTapSeekForward() { + setState(() { + _mountSeekForwardButton = true; + }); + } + @override void initState() { super.initState(); @@ -53,10 +82,33 @@ class _PLVideoPlayerState extends State fontWeight: FontWeight.normal, backgroundColor: Color(0xaa000000), ); + const textStyle = TextStyle( + color: Colors.white, + fontSize: 12, + ); return Stack( clipBehavior: Clip.hardEdge, fit: StackFit.passthrough, children: [ + // Wrap [Video] widget with [MaterialVideoControlsTheme]. + // MaterialVideoControlsTheme( + // normal: MaterialVideoControlsThemeData( + // // Modify theme options: + // buttonBarButtonSize: 24.0, + // buttonBarButtonColor: Colors.white, + // ), + // fullscreen: const MaterialVideoControlsThemeData( + // // Modify theme options: + // displaySeekBar: false, + // automaticallyImplySkipNextButton: false, + // automaticallyImplySkipPreviousButton: false, + // ), + // child: Scaffold( + // body: Video( + // controller: videoController, + // ), + // ), + // ), Video( controller: videoController, controls: NoVideoControls, @@ -72,11 +124,29 @@ class _PLVideoPlayerState extends State onTap: () { _.controls = !_.showControls.value; }, - onDoubleTap: () { - if (_.playerStatus.status.value == PlayerStatus.playing) { - _.togglePlay(); + // onDoubleTap: () { + // if (_.playerStatus.status.value == PlayerStatus.playing) { + // _.togglePlay(); + // } else { + // _.play(); + // } + // }, + onDoubleTapDown: (details) { + final totalWidth = MediaQuery.of(context).size.width; + final tapPosition = details.localPosition.dx; + final sectionWidth = totalWidth / 3; + if (tapPosition < sectionWidth) { + // 双击左边区域 👈 + onDoubleTapSeekBackward(); + } else if (tapPosition < sectionWidth * 2) { + if (_.playerStatus.status.value == PlayerStatus.playing) { + _.togglePlay(); + } else { + _.play(); + } } else { - _.play(); + // 双击右边区域 👈 + onDoubleTapSeekForward(); } }, onLongPressStart: (detail) { @@ -97,6 +167,7 @@ class _PLVideoPlayerState extends State onVerticalDragUpdate: (DragUpdateDetails details) {}, onVerticalDragEnd: (DragEndDetails details) {}), ), + // 头部、底部控制条 if (_.controlsEnabled) Obx( () => Column( @@ -105,9 +176,9 @@ class _PLVideoPlayerState extends State clipBehavior: Clip.hardEdge, child: AppBarAni( controller: animationController, - visible: !_.controlsClose.value && _.showControls.value, + visible: !_.controlsLock.value && _.showControls.value, position: 'top', - child: HeaderControl(controller: widget.controller), + child: widget.headerControl!, ), ), const Spacer(), @@ -115,7 +186,7 @@ class _PLVideoPlayerState extends State clipBehavior: Clip.hardEdge, child: AppBarAni( controller: animationController, - visible: !_.controlsClose.value && _.showControls.value, + visible: !_.controlsLock.value && _.showControls.value, position: 'bottom', child: BottomControl(controller: widget.controller), ), @@ -133,7 +204,7 @@ class _PLVideoPlayerState extends State return Container(); } return Positioned( - bottom: -4, + bottom: -3, left: 0, right: 0, child: SlideTransition( @@ -154,22 +225,22 @@ class _PLVideoPlayerState extends State Theme.of(context).colorScheme.primary.withOpacity(0.4), timeLabelLocation: TimeLabelLocation.none, thumbColor: colorTheme, - barHeight: 3, + barHeight: 2, thumbRadius: 0.0, - onDragStart: (duration) { - _.onChangedSliderStart(); - }, - onDragEnd: () { - _.onChangedSliderEnd(); - }, + // onDragStart: (duration) { + // _.onChangedSliderStart(); + // }, + // onDragEnd: () { + // _.onChangedSliderEnd(); + // }, // onDragUpdate: (details) { // print(details); // }, - onSeek: (duration) { - print(duration); - _.onChangedSlider(duration.inSeconds.toDouble()); - _.seekTo(duration); - }, + // onSeek: (duration) { + // feedBack(); + // _.onChangedSlider(duration.inSeconds.toDouble()); + // _.seekTo(duration); + // }, )), ); }, @@ -195,27 +266,28 @@ class _PLVideoPlayerState extends State ), ), // 锁 - Obx( - () => Align( - alignment: Alignment.centerLeft, - child: FractionalTranslation( - translation: const Offset(0.5, 0.0), - child: Visibility( - visible: _.showControls.value, - child: ComBtn( - icon: Icon( - _.controlsClose.value - ? FontAwesomeIcons.lock - : FontAwesomeIcons.lockOpen, - size: 15, - color: Colors.white, + if (_.controlsEnabled) + Obx( + () => Align( + alignment: Alignment.centerLeft, + child: FractionalTranslation( + translation: const Offset(0.5, 0.0), + child: Visibility( + visible: _.showControls.value, + child: ComBtn( + icon: Icon( + _.controlsLock.value + ? FontAwesomeIcons.lock + : FontAwesomeIcons.lockOpen, + size: 15, + color: Colors.white, + ), + fuc: () => _.onLockControl(!_.controlsLock.value), ), - fuc: () => _.onCloseControl(!_.controlsClose.value), ), ), ), ), - ), // Obx(() { if (_.dataStatus.loading || _.isBuffering.value) { @@ -229,6 +301,141 @@ class _PLVideoPlayerState extends State return Container(); } }), + // 时间进度 + /// TDDO 样式 + Obx( + () => Align( + alignment: Alignment.topCenter, + child: FractionalTranslation( + translation: const Offset(0.0, 2.5), // 上下偏移量(负数向上偏移) + child: Visibility( + visible: _.isSliderMoving.value, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Obx(() { + return Text( + _.sliderTempPosition.value.inMinutes >= 60 + ? printDurationWithHours(_.sliderTempPosition.value) + : printDuration(_.sliderTempPosition.value), + style: textStyle, + ); + }), + const SizedBox(width: 2), + const Text('/', style: textStyle), + const SizedBox(width: 2), + Obx( + () => Text( + _.duration.value.inMinutes >= 60 + ? printDurationWithHours(_.duration.value) + : printDuration(_.duration.value), + style: textStyle, + ), + ), + ], + ), + ), + ), + ), + ), + // 点击 快进/快退 + if (_mountSeekBackwardButton || _mountSeekForwardButton) + Positioned.fill( + child: Row( + children: [ + Expanded( + child: _mountSeekBackwardButton + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: _hideSeekBackwardButton ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_hideSeekBackwardButton) { + setState(() { + _hideSeekBackwardButton = false; + _mountSeekBackwardButton = false; + }); + } + }, + child: BackwardSeekIndicator( + onChanged: (value) { + print(value); + // _seekBarDeltaValueNotifier.value = -value; + }, + onSubmitted: (value) { + setState(() { + _hideSeekBackwardButton = true; + }); + Player player = + widget.controller.videoPlayerController!; + var result = player.state.position - value; + result = result.clamp( + Duration.zero, + player.state.duration, + ); + player.seek(result); + widget.controller.play(); + }, + ), + ) + : const SizedBox(), + ), + Expanded( + child: SizedBox( + width: MediaQuery.of(context).size.width / 4, + ), + ), + Expanded( + child: _mountSeekForwardButton + ? TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: _hideSeekForwardButton ? 0.0 : 1.0, + ), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) => Opacity( + opacity: value, + child: child, + ), + onEnd: () { + if (_hideSeekForwardButton) { + setState(() { + _hideSeekForwardButton = false; + _mountSeekForwardButton = false; + }); + } + }, + child: ForwardSeekIndicator( + onChanged: (value) { + // _seekBarDeltaValueNotifier.value = value; + }, + onSubmitted: (value) { + setState(() { + _hideSeekForwardButton = true; + }); + Player player = + widget.controller.videoPlayerController!; + var result = player.state.position + value; + result = result.clamp( + Duration.zero, + player.state.duration, + ); + player.seek(result); + widget.controller.play(); + }, + ), + ) + : const SizedBox(), + ), + ], + ), + ), ], ); } @@ -248,5 +455,3 @@ class MSliderTrackShape extends RoundedRectSliderTrackShape { return Rect.fromLTWH(trackLeft, -1, trackWidth, 3); } } - -class PLPlayerCtr extends GetxController {} diff --git a/lib/plugin/pl_player/widgets/backward_seek.dart b/lib/plugin/pl_player/widgets/backward_seek.dart new file mode 100644 index 000000000..35de0ae63 --- /dev/null +++ b/lib/plugin/pl_player/widgets/backward_seek.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class BackwardSeekIndicator extends StatefulWidget { + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const BackwardSeekIndicator({ + Key? key, + required this.onChanged, + required this.onSubmitted, + }) : super(key: key); + + @override + State createState() => BackwardSeekIndicatorState(); +} + +class BackwardSeekIndicatorState extends State { + Duration value = const Duration(seconds: 10); + + Timer? timer; + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + // 重复点击 快退秒数累加10 + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x88767676), + Color(0x00767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_rewind, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '快退${value.inSeconds}秒', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index e1a507688..08a3ef9f5 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; +import 'package:pilipala/plugin/pl_player/widgets/play_pause_btn.dart'; import '../utils.dart'; -import 'common_btn.dart'; class BottomControl extends StatelessWidget implements PreferredSizeWidget { final PlPlayerController? controller; @@ -57,6 +57,9 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { onDragStart: (duration) { _.onChangedSliderStart(); }, + onDragUpdate: (duration) { + _.onUodatedSliderProgress(duration.timeStamp); + }, onSeek: (duration) { _.onChangedSliderEnd(); _.onChangedSlider(duration.inSeconds.toDouble()); @@ -67,21 +70,24 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), Row( children: [ - Obx( - () => ComBtn( - icon: Icon( - _.playerStatus.paused - ? FontAwesomeIcons.play - : _.playerStatus.playing - ? FontAwesomeIcons.pause - : FontAwesomeIcons.rotateRight, - size: 15, - color: Colors.white, - ), - fuc: () => _.togglePlay(), - ), + // Obx( + // () => ComBtn( + // icon: Icon( + // _.playerStatus.paused + // ? FontAwesomeIcons.play + // : _.playerStatus.playing + // ? FontAwesomeIcons.pause + // : FontAwesomeIcons.rotateRight, + // size: 15, + // color: Colors.white, + // ), + // fuc: () => _.togglePlay(), + // ), + // ), + PlayOrPauseButton( + controller: _, ), - const SizedBox(width: 6), + const SizedBox(width: 4), // 播放时间 Obx(() { return Text( @@ -104,33 +110,33 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), const Spacer(), // 倍速 - Obx( - () => SizedBox( - width: 45, - height: 34, - child: TextButton( - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - ), - onPressed: () { - _.togglePlaybackSpeed(); - }, - child: Text( - '${_.playbackSpeed.toString()}X', - style: textStyle, - ), - ), - ), - ), - ComBtn( - icon: const Icon( - Icons.fit_screen_sharp, - size: 18, - color: Colors.white, - ), - fuc: () => _.toggleVideoFit(), - ), - const SizedBox(width: 4), + // Obx( + // () => SizedBox( + // width: 45, + // height: 34, + // child: TextButton( + // style: ButtonStyle( + // padding: MaterialStateProperty.all(EdgeInsets.zero), + // ), + // onPressed: () { + // _.togglePlaybackSpeed(); + // }, + // child: Text( + // '${_.playbackSpeed.toString()}X', + // style: textStyle, + // ), + // ), + // ), + // ), + // ComBtn( + // icon: const Icon( + // Icons.fit_screen_sharp, + // size: 18, + // color: Colors.white, + // ), + // fuc: () => _.toggleVideoFit(), + // ), + // const SizedBox(width: 4), // 全屏 ComBtn( icon: const Icon( diff --git a/lib/plugin/pl_player/widgets/forward_seek.dart b/lib/plugin/pl_player/widgets/forward_seek.dart new file mode 100644 index 000000000..43ddd322a --- /dev/null +++ b/lib/plugin/pl_player/widgets/forward_seek.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class ForwardSeekIndicator extends StatefulWidget { + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const ForwardSeekIndicator({ + Key? key, + required this.onChanged, + required this.onSubmitted, + }) : super(key: key); + + @override + State createState() => ForwardSeekIndicatorState(); +} + +class ForwardSeekIndicatorState extends State { + Duration value = const Duration(seconds: 10); + + Timer? timer; + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + // 重复点击 快进秒数累加10 + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00767676), + Color(0x88767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_forward, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '快进${value.inSeconds}秒', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/plugin/pl_player/widgets/header_control.dart b/lib/plugin/pl_player/widgets/header_control.dart deleted file mode 100644 index ade38f807..000000000 --- a/lib/plugin/pl_player/widgets/header_control.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:get/get.dart'; -import 'package:pilipala/plugin/pl_player/index.dart'; - -import 'common_btn.dart'; - -class HeaderControl extends StatelessWidget implements PreferredSizeWidget { - final PlPlayerController? controller; - const HeaderControl({this.controller, Key? key}) : super(key: key); - - @override - Size get preferredSize => const Size(double.infinity, kToolbarHeight); - @override - Widget build(BuildContext context) { - final _ = controller!; - return AppBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - elevation: 0, - scrolledUnderElevation: 0, - primary: false, - centerTitle: false, - automaticallyImplyLeading: false, - titleSpacing: 14, - title: Row( - children: [ - ComBtn( - icon: const Icon( - FontAwesomeIcons.arrowLeft, - size: 15, - color: Colors.white, - ), - fuc: () => Get.back(), - ), - const SizedBox(width: 4), - ComBtn( - icon: const Icon( - FontAwesomeIcons.house, - size: 15, - color: Colors.white, - ), - fuc: () => Get.back(), - ), - const Spacer(), - ComBtn( - icon: const Icon( - FontAwesomeIcons.cropSimple, - size: 15, - color: Colors.white, - ), - fuc: () => _.screenshot(), - ), - const SizedBox(width: 4), - ComBtn( - icon: const Icon( - FontAwesomeIcons.sliders, - size: 15, - color: Colors.white, - ), - fuc: () => _.screenshot(), - ), - ], - ), - ); - } -} diff --git a/lib/plugin/pl_player/widgets/play_pause_btn.dart b/lib/plugin/pl_player/widgets/play_pause_btn.dart new file mode 100644 index 000000000..6cbe31f08 --- /dev/null +++ b/lib/plugin/pl_player/widgets/play_pause_btn.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; + +class PlayOrPauseButton extends StatefulWidget { + final double? iconSize; + final Color? iconColor; + final PlPlayerController? controller; + + const PlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + this.controller, + }); + + @override + PlayOrPauseButtonState createState() => PlayOrPauseButtonState(); +} + +class PlayOrPauseButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animation; + + StreamSubscription? subscription; + late Player player; + bool isOpacity = false; + + @override + void initState() { + super.initState(); + player = widget.controller!.videoPlayerController!; + animation = AnimationController( + vsync: this, + value: player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= player.stream.playing.listen((event) { + if (event) { + animation.forward().then((value) => { + isOpacity = true, + }); + } else { + animation.reverse().then((value) => {isOpacity = false}); + } + setState(() {}); + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: player.playOrPause, + color: Colors.white, + iconSize: 20, + // iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + // color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + color: Colors.white, + size: 20, + // size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + // color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ), + ); + } +}