diff --git a/lib/pages/setting/widgets/model.dart b/lib/pages/setting/widgets/model.dart index 05a32a232..e83385836 100644 --- a/lib/pages/setting/widgets/model.dart +++ b/lib/pages/setting/widgets/model.dart @@ -1922,6 +1922,14 @@ List get extraSettings => [ setKey: SettingBoxKey.enableLivePhoto, defaultVal: true, ), + SettingsModel( + settingsType: SettingsType.sw1tch, + title: '视频进度条缩略图', + subtitle: '滑动进度条时显示视频缩略图', + leading: Icon(Icons.preview_outlined), + setKey: SettingBoxKey.showSeekPreview, + defaultVal: true, + ), SettingsModel( settingsType: SettingsType.sw1tch, enableFeedback: true, diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 67f0bcec3..0dccfc28d 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -5,8 +5,10 @@ import 'dart:typed_data'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/segment_progress_bar.dart'; +import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/models/common/audio_normalization.dart'; import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:canvas_danmaku/canvas_danmaku.dart'; import 'package:easy_debounce/easy_throttle.dart'; @@ -509,6 +511,12 @@ class PlPlayerController { _subType = subType; _enableHeart = enableHeart; + if (showSeekPreview) { + videoShot = null; + showPreview.value = false; + localPosition.value = Offset.zero; + } + if (_videoPlayerController != null && _videoPlayerController!.state.playing) { await pause(notify: false); @@ -1564,4 +1572,33 @@ class PlPlayerController { videoPlayerController?.setVideoTrack( _onlyPlayAudio.value ? VideoTrack.no() : VideoTrack.auto()); } + + late final showSeekPreview = GStorage.showSeekPreview; + late bool _isQueryingVideoShot = false; + Map? videoShot; + late final RxBool showPreview = false.obs; + late final Rx localPosition = Offset.zero.obs; + + void getVideoShot() async { + if (_isQueryingVideoShot) { + return; + } + _isQueryingVideoShot = true; + dynamic res = await Request().get( + 'https://api.bilibili.com/x/player/videoshot', + queryParameters: { + 'aid': IdUtils.bv2av(_bvid), + 'cid': _cid, + }, + ); + if (res.data['code'] == 0) { + videoShot = { + 'status': true, + 'data': res.data['data'], + }; + } else { + videoShot = {'status': false}; + } + _isQueryingVideoShot = false; + } } diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index c77a09179..d4bfeec97 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -5,7 +5,9 @@ import 'package:PiliPlus/common/widgets/segment_progress_bar.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/common/super_resolution_type.dart'; import 'package:PiliPlus/pages/video/detail/introduction/controller.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/id_utils.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -1090,6 +1092,7 @@ class _PLVideoPlayerState extends State value: '${(value / max * 100).round()}%', // enabled: false, child: Stack( + clipBehavior: Clip.none, alignment: Alignment.bottomCenter, children: [ if (plPlayerController.viewPointList.isNotEmpty && @@ -1137,35 +1140,28 @@ class _PLVideoPlayerState extends State thumbColor: colorTheme, barHeight: 3.5, thumbRadius: draggingFixedProgressBar.value ? 7 : 2.5, - // onDragStart: (duration) { - // draggingFixedProgressBar.value = true; - // feedBack(); - // _.onChangedSliderStart(); - // }, - // onDragUpdate: (duration) { - // double newProgress = duration.timeStamp.inSeconds / max; - // if ((newProgress - _lastAnnouncedValue).abs() > 0.02) { - // _accessibilityDebounce?.cancel(); - // _accessibilityDebounce = - // Timer(const Duration(milliseconds: 200), () { - // SemanticsService.announce( - // "${(newProgress * 100).round()}%", - // TextDirection.ltr); - // _lastAnnouncedValue = newProgress; - // }); - // } - // _.onUpdatedSliderProgress(duration.timeStamp); - // }, - // onSeek: (duration) { - // draggingFixedProgressBar.value = false; - // _.onChangedSliderEnd(); - // _.onChangedSlider(duration.inSeconds.toDouble()); - // _.seekTo(Duration(seconds: duration.inSeconds), - // type: 'slider'); - // SemanticsService.announce( - // "${(duration.inSeconds / max * 100).round()}%", - // TextDirection.ltr); - // }, + onDragStart: (duration) { + feedBack(); + plPlayerController.onChangedSliderStart(); + }, + onDragUpdate: (duration) { + plPlayerController + .onUpdatedSliderProgress(duration.timeStamp); + if (plPlayerController.showPreview.value.not) { + plPlayerController.showPreview.value = true; + } + plPlayerController.localPosition.value = + duration.localPosition; + }, + onSeek: (duration) { + plPlayerController.showPreview.value = false; + plPlayerController.onChangedSliderEnd(); + plPlayerController + .onChangedSlider(duration.inSeconds.toDouble()); + plPlayerController.seekTo( + Duration(seconds: duration.inSeconds), + type: 'slider'); + }, ), if (plPlayerController.segmentList.isNotEmpty) Positioned( @@ -1196,6 +1192,13 @@ class _PLVideoPlayerState extends State ), ), ), + if (plPlayerController.showSeekPreview) + Positioned( + left: 0, + right: 0, + bottom: 12, + child: buildSeekPreviewWidget(plPlayerController), + ), ], ), // SlideTransition( @@ -1478,3 +1481,90 @@ class _PLVideoPlayerState extends State ); } } + +Widget buildSeekPreviewWidget(PlPlayerController plPlayerController) { + return Obx(() { + if (plPlayerController.showPreview.value.not) { + return SizedBox.shrink( + key: ValueKey(plPlayerController.localPosition.value), + ); + } + if (plPlayerController.videoShot == null) { + plPlayerController.getVideoShot(); + return SizedBox.shrink( + key: ValueKey(plPlayerController.localPosition.value), + ); + } else if (plPlayerController.videoShot!['status'] == false) { + return SizedBox.shrink( + key: ValueKey(plPlayerController.localPosition.value), + ); + } + + return LayoutBuilder( + key: ValueKey(plPlayerController.localPosition.value), + builder: (context, constraints) { + try { + double scale = 1.5; + // offset + double left = + (plPlayerController.localPosition.value.dx - 48 * scale / 2) + .clamp(8, constraints.maxWidth - 48 * scale - 8); + + // index + int index = plPlayerController.sliderPositionSeconds.value ~/ 5; + + // pageIndex + int pageIndex = (index ~/ 100).clamp( + 0, + (plPlayerController.videoShot!['data']['image'] as List).length, + ); + + // alignment + double cal(m) { + return -1 + 2 / 9 * m; + } + + int align = index % 100; + int x = align % 10; + int y = align ~/ 10; + double dx = cal(x); + double dy = cal(y); + Alignment alignment = Alignment(dx, dy); + + // url + String parseUrl(String url) { + return url.startsWith('//') ? 'https:$url' : url; + } + + return Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.only(left: left), + child: SizedBox( + width: 48 * scale, + height: 27 * scale, + child: UnconstrainedBox( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Align( + widthFactor: 0.1, + heightFactor: 0.1, + alignment: alignment, + child: CachedNetworkImage( + width: 480 * scale, + height: 270 * scale, + imageUrl: parseUrl(plPlayerController.videoShot!['data'] + ['image'][pageIndex]), + ), + ), + ), + ), + ), + ); + } catch (_) { + return SizedBox.shrink( + key: ValueKey(plPlayerController.localPosition.value), + ); + } + }); + }); +} diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index bfc366247..e95c9385d 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/segment_progress_bar.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; import 'package:nil/nil.dart'; -import 'package:PiliPlus/plugin/pl_player/index.dart'; +import 'package:PiliPlus/plugin/pl_player/index.dart' + show PlPlayerController, buildSeekPreviewWidget; import 'package:PiliPlus/utils/feed_back.dart'; import '../../../common/widgets/audio_video_progress_bar.dart'; @@ -30,7 +32,7 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { double lastAnnouncedValue = -1; return Container( color: Colors.transparent, - height: 90, + height: 120, padding: const EdgeInsets.symmetric(horizontal: 10), child: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -50,6 +52,7 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { value: '${(value / max * 100).round()}%', // enabled: false, child: Stack( + clipBehavior: Clip.none, alignment: Alignment.bottomCenter, children: [ if (controller?.viewPointList.isNotEmpty == true && @@ -101,6 +104,11 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { onDragUpdate: (duration) { double newProgress = duration.timeStamp.inSeconds / max; + if (controller!.showPreview.value.not) { + controller!.showPreview.value = true; + } + controller!.localPosition.value = + duration.localPosition; if ((newProgress - lastAnnouncedValue).abs() > 0.02) { accessibilityDebounce?.cancel(); accessibilityDebounce = @@ -115,6 +123,7 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { .onUpdatedSliderProgress(duration.timeStamp); }, onSeek: (duration) { + controller!.showPreview.value = false; controller!.onChangedSliderEnd(); controller! .onChangedSlider(duration.inSeconds.toDouble()); @@ -155,6 +164,13 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { ), ), ), + if (controller?.showSeekPreview == true) + Positioned( + left: 0, + right: 0, + bottom: 16, + child: buildSeekPreviewWidget(controller!), + ), ], ), ), diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index d7437f9a7..89678373e 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -357,6 +357,9 @@ class GStorage { static bool get enableLivePhoto => GStorage.setting.get(SettingBoxKey.enableLivePhoto, defaultValue: true); + static bool get showSeekPreview => + GStorage.setting.get(SettingBoxKey.showSeekPreview, defaultValue: true); + static List get dynamicDetailRatio => List.from(setting .get(SettingBoxKey.dynamicDetailRatio, defaultValue: [60.0, 40.0])); @@ -585,6 +588,7 @@ class SettingBoxKey { searchSuggestion = 'searchSuggestion', showDynDecorate = 'showDynDecorate', enableLivePhoto = 'enableLivePhoto', + showSeekPreview = 'showSeekPreview', // Sponsor Block enableSponsorBlock = 'enableSponsorBlock',