From ed6353e6d5e38b386360c37985c3ba70465ff8f4 Mon Sep 17 00:00:00 2001 From: dom Date: Tue, 30 Jun 2026 18:40:02 +0800 Subject: [PATCH] opt video header Signed-off-by: dom --- lib/common/widgets/sliver/video_header.dart | 59 +++ lib/pages/live_room/controller.dart | 2 +- lib/pages/live_room/view.dart | 8 +- .../live_room/widgets/bottom_control.dart | 4 +- lib/pages/video/controller.dart | 34 +- lib/pages/video/view.dart | 341 +++++++++--------- lib/pages/video/widgets/player_focus.dart | 4 +- lib/plugin/pl_player/controller.dart | 8 +- 8 files changed, 244 insertions(+), 216 deletions(-) create mode 100644 lib/common/widgets/sliver/video_header.dart diff --git a/lib/common/widgets/sliver/video_header.dart b/lib/common/widgets/sliver/video_header.dart new file mode 100644 index 000000000..cd5a571dc --- /dev/null +++ b/lib/common/widgets/sliver/video_header.dart @@ -0,0 +1,59 @@ +import 'package:PiliPlus/common/widgets/sliver/sliver_pinned_dynamic_header.dart'; +import 'package:PiliPlus/utils/extension/num_ext.dart'; +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/material.dart'; + +class VideoHeader extends SliverPinnedDynamicHeader { + const VideoHeader({ + super.key, + required super.minExtent, + required super.maxExtent, + required this.minVideoHeight, + required this.onScrollRatioChanged, + required super.child, + }); + + final double minVideoHeight; + final ValueChanged onScrollRatioChanged; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderVideoHeader( + minExtent: minExtent, + maxExtent: maxExtent, + minVideoHeight: minVideoHeight, + onScrollRatioChanged: onScrollRatioChanged, + ); + } +} + +class RenderVideoHeader extends RenderSliverPinnedDynamicHeader { + RenderVideoHeader({ + required super.minExtent, + required super.maxExtent, + required this.minVideoHeight, + required this.onScrollRatioChanged, + }); + + double? _scrollRatio; + final double minVideoHeight; + final ValueChanged onScrollRatioChanged; + + @override + void performLayout() { + super.performLayout(); + final scrollOffset = constraints.scrollOffset; + final offset = scrollOffset - (maxExtent - minVideoHeight); + final scrollRatio = clampDouble( + offset.toPrecision(2) / (minVideoHeight - kToolbarHeight).toPrecision(2), + 0.0, + 1.0, + ); + if (_scrollRatio != scrollRatio) { + _scrollRatio = scrollRatio; + WidgetsBinding.instance.addPostFrameCallback((_) { + onScrollRatioChanged(scrollRatio); + }); + } + } +} diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 2cd59efe6..a76479688 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -486,7 +486,7 @@ class LiveRoomController extends GetxController { void addDm(dynamic msg, [DanmakuContentItem? item]) { if (plPlayerController.showDanmaku) { - if (item != null) { + if (item != null && plPlayerController.enableShowLiveDanmaku.value) { danmakuController?.addDanmaku(item); } if (autoScroll && !disableAutoScroll.value) { diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index d931cbf71..8ed9a008a 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -151,7 +151,6 @@ class _LiveRoomPageState extends State plPlayerController.removeStatusLister(playerListener); _liveRoomController ..danmakuController?.clear() - ..danmakuController?.pause() ..cancelLiveTimer() ..closeLiveMsg() ..isPlaying = plPlayerController.playerStatus.isPlaying; @@ -809,7 +808,7 @@ class _LiveRoomPageState extends State Obx( () { final enableShowLiveDanmaku = - plPlayerController.enableShowDanmaku.value; + plPlayerController.enableShowLiveDanmaku.value; return SizedBox( width: 34, height: 34, @@ -817,7 +816,8 @@ class _LiveRoomPageState extends State style: IconButton.styleFrom(padding: .zero), onPressed: () { final newVal = !enableShowLiveDanmaku; - plPlayerController.enableShowDanmaku.value = newVal; + plPlayerController.enableShowLiveDanmaku.value = + newVal; if (!plPlayerController.tempPlayerConf) { GStorage.setting.put( SettingBoxKey.enableShowLiveDanmaku, @@ -1091,7 +1091,7 @@ class _LiveDanmakuState extends State { final option = DanmakuOptions.get(notFullscreen: widget.notFullscreen); return Obx( () => AnimatedOpacity( - opacity: plPlayerController.enableShowDanmaku.value + opacity: plPlayerController.enableShowLiveDanmaku.value ? plPlayerController.danmakuOpacity.value : 0, duration: const Duration(milliseconds: 100), diff --git a/lib/pages/live_room/widgets/bottom_control.dart b/lib/pages/live_room/widgets/bottom_control.dart index 2e90bea82..c1e009701 100644 --- a/lib/pages/live_room/widgets/bottom_control.dart +++ b/lib/pages/live_room/widgets/bottom_control.dart @@ -88,7 +88,7 @@ class _BottomControlState extends State with HeaderMixin { Obx( () { final enableShowLiveDanmaku = - plPlayerController.enableShowDanmaku.value; + plPlayerController.enableShowLiveDanmaku.value; return ComBtn( height: 30, tooltip: "${enableShowLiveDanmaku ? '关闭' : '开启'}弹幕", @@ -105,7 +105,7 @@ class _BottomControlState extends State with HeaderMixin { ), onTap: () { final newVal = !enableShowLiveDanmaku; - plPlayerController.enableShowDanmaku.value = newVal; + plPlayerController.enableShowLiveDanmaku.value = newVal; if (!plPlayerController.tempPlayerConf) { GStorage.setting.put( SettingBoxKey.enableShowLiveDanmaku, diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index efaf5c92b..8a99d1686 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -65,7 +65,8 @@ import 'package:PiliPlus/utils/theme_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/video_utils.dart'; import 'package:collection/collection.dart'; -import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart' + show ExtendedNestedScrollViewState; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -169,8 +170,7 @@ class VideoDetailController extends GetxController late final RxDouble scrollRatio = 0.0.obs; ScrollController? _scrollCtr; - ScrollController get scrollCtr => - _scrollCtr ??= ScrollController()..addListener(scrollListener); + ScrollController get scrollCtr => _scrollCtr ??= ScrollController(); late bool isExpanding = false; late bool isCollapsing = false; @@ -315,26 +315,6 @@ class VideoDetailController extends GetxController } catch (_) {} } - void scrollListener() { - if (scrollCtr.hasClients) { - if (scrollCtr.offset == 0) { - scrollRatio.value = 0; - } else { - double offset = scrollCtr.offset - (videoHeight - minVideoHeight); - if (offset > 0) { - scrollRatio.value = clampDouble( - offset.toPrecision(2) / - (minVideoHeight - kToolbarHeight).toPrecision(2), - 0.0, - 1.0, - ); - } else { - scrollRatio.value = 0; - } - } - } - } - final isLoginVideo = Accounts.get(AccountType.video).isLogin; late final watchProgress = GStorage.watchProgress; @@ -1247,9 +1227,7 @@ class VideoDetailController extends GetxController introScrollCtr?.dispose(); introScrollCtr = null; tabCtr.dispose(); - _scrollCtr - ?..removeListener(scrollListener) - ..dispose(); + _scrollCtr?.dispose(); animController ?..removeListener(_animListener) ..dispose(); @@ -1268,10 +1246,6 @@ class VideoDetailController extends GetxController videoUrl = null; audioUrl = null; - if (scrollRatio.value != 0) { - scrollRatio.refresh(); - } - // danmaku savedDanmaku = null; diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index 6e20e1aa3..4256fc094 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -11,7 +11,7 @@ import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart'; import 'package:PiliPlus/common/widgets/route_aware_mixin.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; -import 'package:PiliPlus/common/widgets/sliver/sliver_pinned_dynamic_header.dart'; +import 'package:PiliPlus/common/widgets/sliver/video_header.dart'; import 'package:PiliPlus/common/widgets/svg/play_icon.dart'; import 'package:PiliPlus/models/common/episode_panel_type.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.dart'; @@ -493,24 +493,21 @@ class _VideoDetailPageVState extends State () { final scrollRatio = videoDetailController.scrollRatio.value; - final flag = - isPortrait && - videoDetailController.scrollCtr.offset != 0; return AppBar( - backgroundColor: flag && scrollRatio > 0 + toolbarHeight: 0, + backgroundColor: isPortrait && scrollRatio > 0 ? Color.lerp( Colors.black, themeData.colorScheme.surface, scrollRatio, ) : Colors.black, - toolbarHeight: 0, systemOverlayStyle: Platform.isAndroid ? SystemUiOverlayStyle( statusBarIconBrightness: - flag && scrollRatio >= 0.5 + isPortrait && scrollRatio >= 0.5 ? themeData.brightness.reverse - : Brightness.light, + : .light, systemNavigationBarIconBrightness: themeData.brightness.reverse, ) @@ -557,179 +554,20 @@ class _VideoDetailPageVState extends State ? videoDetailController.animHeight : videoDetailController.videoHeight; return [ - SliverPinnedDynamicHeader( + VideoHeader( minExtent: kToolbarHeight, maxExtent: height, + minVideoHeight: videoDetailController.minVideoHeight, + onScrollRatioChanged: videoDetailController.scrollRatio.call, child: Stack( - clipBehavior: Clip.none, + clipBehavior: .none, children: [ SizedBox( width: maxWidth, height: height, - child: videoPlayer( - width: maxWidth, - height: height, - ), - ), - Obx( - () { - Widget toolbar() => Opacity( - opacity: videoDetailController.scrollRatio.value, - child: Container( - color: themeData.colorScheme.surface, - alignment: Alignment.topCenter, - child: SizedBox( - height: kToolbarHeight, - child: Stack( - clipBehavior: Clip.none, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 42, - height: 34, - child: IconButton( - tooltip: '返回', - icon: Icon( - FontAwesomeIcons.arrowLeft, - size: 15, - color: themeData - .colorScheme - .onSurface, - ), - onPressed: Get.back, - ), - ), - SizedBox( - width: 42, - height: 34, - child: IconButton( - tooltip: '返回主页', - icon: Icon( - FontAwesomeIcons.house, - size: 15, - color: themeData - .colorScheme - .onSurface, - ), - onPressed: videoDetailController - .plPlayerController - .onCloseAll, - ), - ), - ], - ), - ), - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.play_arrow_rounded, - color: - themeData.colorScheme.primary, - ), - Text( - '${videoDetailController.playedTime == null - ? '立即' - : plPlayerController!.isCompleted - ? '重新' - : '继续'}播放', - style: TextStyle( - color: - themeData.colorScheme.primary, - ), - ), - ], - ), - ), - Align( - alignment: Alignment.centerRight, - child: - videoDetailController.playedTime == - null - ? _moreBtn( - themeData.colorScheme.onSurface, - ) - : SizedBox( - width: 42, - height: 34, - child: IconButton( - tooltip: "更多设置", - style: const ButtonStyle( - padding: - WidgetStatePropertyAll( - EdgeInsets.zero, - ), - ), - onPressed: () => - (videoDetailController - .headerCtrKey - .currentState - as HeaderControlState?) - ?.showSettingSheet(), - icon: Icon( - Icons.more_vert_outlined, - size: 19, - color: themeData - .colorScheme - .onSurface, - ), - ), - ), - ), - ], - ), - ), - ), - ); - return videoDetailController.scrollRatio.value == 0 || - videoDetailController.scrollCtr.offset == 0 || - !isPortrait - ? const SizedBox.shrink() - : Positioned.fill( - bottom: -2, - child: GestureDetector( - onTap: () { - if (!videoDetailController.isFileSource) { - if (videoDetailController.isQuerying) { - if (kDebugMode) { - debugPrint('handlePlay: querying'); - } - return; - } - if (videoDetailController.videoUrl == - null || - videoDetailController.audioUrl == - null) { - if (kDebugMode) { - debugPrint( - 'handlePlay: videoUrl/audioUrl not initialized', - ); - } - videoDetailController.queryVideoUrl(); - return; - } - } - videoDetailController.scrollRatio.value = - 0; - if (plPlayerController == null || - videoDetailController.playedTime == - null) { - handlePlay(); - } else { - plPlayerController!.onDoubleTapCenter(); - } - }, - behavior: HitTestBehavior.opaque, - child: toolbar(), - ), - ); - }, + child: videoPlayer(width: maxWidth, height: height), ), + _buildHeaderOverlay(), ], ), ), @@ -766,6 +604,163 @@ class _VideoDetailPageVState extends State ); } + Widget _buildOverlayToolBar(double scrollRatio) { + final Icon icon; + final double spacing; + final String playStat; + if (videoDetailController.playedTime == null) { + spacing = 2; + icon = Icon( + Icons.play_arrow_rounded, + color: themeData.colorScheme.primary, + ); + playStat = '立即'; + } else if (plPlayerController!.isCompleted) { + spacing = 4; + icon = Icon( + size: 18, + Icons.replay_rounded, + color: themeData.colorScheme.primary, + ); + playStat = '重新'; + } else { + spacing = 2; + icon = Icon( + Icons.play_arrow_rounded, + color: themeData.colorScheme.primary, + ); + playStat = '继续'; + } + final playBtn = Row( + spacing: spacing, + mainAxisSize: .min, + children: [ + icon, + Text( + '$playStat播放', + style: TextStyle(color: themeData.colorScheme.primary), + ), + ], + ); + return Opacity( + opacity: videoDetailController.scrollRatio.value, + child: Container( + color: themeData.colorScheme.surface, + alignment: .topCenter, + child: SizedBox( + height: kToolbarHeight, + child: Stack( + clipBehavior: .none, + children: [ + Align( + alignment: .centerLeft, + child: Row( + mainAxisSize: .min, + children: [ + SizedBox( + width: 42, + height: 34, + child: IconButton( + tooltip: '返回', + icon: Icon( + FontAwesomeIcons.arrowLeft, + size: 15, + color: themeData.colorScheme.onSurface, + ), + onPressed: Get.back, + ), + ), + SizedBox( + width: 42, + height: 34, + child: IconButton( + tooltip: '返回主页', + icon: Icon( + FontAwesomeIcons.house, + size: 15, + color: themeData.colorScheme.onSurface, + ), + onPressed: + videoDetailController.plPlayerController.onCloseAll, + ), + ), + ], + ), + ), + Center(child: playBtn), + Align( + alignment: .centerRight, + child: videoDetailController.playedTime == null + ? _moreBtn(themeData.colorScheme.onSurface) + : SizedBox( + width: 42, + height: 34, + child: IconButton( + tooltip: "更多设置", + style: const ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.zero), + ), + onPressed: () => + (videoDetailController.headerCtrKey.currentState + as HeaderControlState?) + ?.showSettingSheet(), + icon: Icon( + Icons.more_vert_outlined, + size: 19, + color: themeData.colorScheme.onSurface, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeaderOverlay() { + return Obx( + () { + final scrollRatio = videoDetailController.scrollRatio.value; + if (scrollRatio == 0) { + return const SizedBox.shrink(); + } + return Positioned.fill( + bottom: -2, + child: GestureDetector( + onTap: () { + if (!videoDetailController.isFileSource) { + if (videoDetailController.isQuerying) { + if (kDebugMode) { + debugPrint('handlePlay: querying'); + } + return; + } + if (videoDetailController.videoUrl == null || + videoDetailController.audioUrl == null) { + if (kDebugMode) { + debugPrint('handlePlay: videoUrl/audioUrl not initialized'); + } + videoDetailController.queryVideoUrl(); + return; + } + } + if (plPlayerController == null || + videoDetailController.playedTime == null) { + handlePlay(); + } else { + plPlayerController!.onDoubleTapCenter(); + } + }, + behavior: .opaque, + child: _buildOverlayToolBar(scrollRatio), + ), + ); + }, + ); + } + Widget get childWhenDisabledLandscape => Obx( () { final isFullScreen = this.isFullScreen; diff --git a/lib/pages/video/widgets/player_focus.dart b/lib/pages/video/widgets/player_focus.dart index 8c98da417..9f935207f 100644 --- a/lib/pages/video/widgets/player_focus.dart +++ b/lib/pages/video/widgets/player_focus.dart @@ -180,8 +180,8 @@ class PlayerFocus extends StatelessWidget { return true; case LogicalKeyboardKey.keyD: - final newVal = !plPlayerController.enableShowDanmaku.value; - plPlayerController.enableShowDanmaku.value = newVal; + final newVal = !plPlayerController.enableShowDanmakuAdaptive.value; + plPlayerController.enableShowDanmakuAdaptive.value = newVal; if (!plPlayerController.tempPlayerConf) { GStorage.setting.put( plPlayerController.isLive diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index b7efe4bcf..26e199f32 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -184,10 +184,10 @@ class PlPlayerController with BlockConfigMixin { bool get isVertical => _isVertical; /// 弹幕开关 - late final RxBool _enableShowDanmaku = Pref.enableShowDanmaku.obs; - late final RxBool _enableShowLiveDanmaku = Pref.enableShowLiveDanmaku.obs; - RxBool get enableShowDanmaku => - isLive ? _enableShowLiveDanmaku : _enableShowDanmaku; + late final RxBool enableShowDanmaku = Pref.enableShowDanmaku.obs; + late final RxBool enableShowLiveDanmaku = Pref.enableShowLiveDanmaku.obs; + RxBool get enableShowDanmakuAdaptive => + isLive ? enableShowLiveDanmaku : enableShowDanmaku; late final bool autoPiP = Pref.autoPiP; bool get isPipMode =>