From f8500bbaca58ff9144dcbafe448329dc1c9f3d16 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 03:18:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E5=AE=8C=E5=96=84=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E9=A2=84=E8=A7=88=E6=B8=85=E5=B1=8F=E4=B8=8E=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/sprite_thumbnail_axis.dart | 3 + lib/pages/player/player_view.dart | 78 +++-- .../player/widgets/feed_pull_refresh.dart | 180 ++++++++++ .../player/widgets/home_feed_overlay.dart | 6 + .../widgets/home_feed_overlay_parts.dart | 31 +- .../widgets/home_feed_recommend_panel.dart | 80 ++--- lib/pages/player/widgets/player_overlay.dart | 209 +++++++----- .../player/widgets/player_overlay_models.dart | 9 +- .../player/widgets/player_overlay_parts.dart | 322 +++++++++++++++--- .../player/widgets/scrub_preview_card.dart | 25 +- .../player/widgets/seek_progress_bar.dart | 35 +- test/playback/seek_progress_bar_test.dart | 2 +- test/player/player_fullscreen_test.dart | 75 +++- test/player/scrub_preview_card_test.dart | 3 + test/player/seek_scrub_visibility_test.dart | 142 ++++++++ 15 files changed, 949 insertions(+), 251 deletions(-) create mode 100644 lib/pages/player/widgets/feed_pull_refresh.dart diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart index 3a63401..55f10da 100644 --- a/lib/core/data/services/sprite_thumbnail_axis.dart +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -122,6 +122,8 @@ abstract interface class SpriteThumbnailAxisSource implements Listenable { SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position); bool hasAxis(String? thumbnailsUrl); + + Future prepareAt(String? thumbnailsUrl, Duration position); } /// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。 @@ -153,6 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier List get spriteCacheKeys => _spriteCache.keys.toList(); /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 + @override Future prepareAt(String? thumbnailsUrl, Duration position) async { final url = _normalizeUrl(thumbnailsUrl); if (_disposed || url == null) return; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 12df1b8..e1e231e 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -34,6 +34,7 @@ import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; +import 'widgets/feed_pull_refresh.dart'; import 'widgets/player_center_toggle.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; @@ -49,6 +50,7 @@ part 'player_view_actions.dart'; const double _boostPlaybackSpeed = 2.0; const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; +const double _feedRefreshTopGap = 56; class PlayerPage extends StatefulWidget { const PlayerPage({ @@ -305,6 +307,16 @@ class _PlayerPageState extends State unawaited(logic.setPlaybackSpeed(speed)); } + Future _refreshHomeFeed() async { + final refresh = widget.onRefreshFeed; + if (refresh == null) return; + final ok = await refresh(); + if (!mounted || ok) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)), + ); + } + void _setScrubbing(bool value) { if (!mounted) return; if (_scrubbing == value) return; @@ -404,26 +416,37 @@ class _PlayerPageState extends State final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; - final pages = logic.pages; final content = AnimatedBuilder( animation: logic.session, - builder: (context, _) => PageView.builder( - controller: pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - itemCount: indices.length, - onPageChanged: (position) { - final index = indices[position]; - widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); - if (position >= indices.length - 3) { - final loadMore = widget.onLoadMore; - if (loadMore != null) unawaited(loadMore()); - } - }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], interactions), - ), + builder: (context, _) { + final pages = PageView.builder( + controller: logic.pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + physics: widget.onRefreshFeed == null + ? null + : const FeedRefreshPhysics(), + itemCount: indices.length, + onPageChanged: (position) { + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(logic.selectEpisode(index)); + if (position >= indices.length - 3) { + final loadMore = widget.onLoadMore; + if (loadMore != null) unawaited(loadMore()); + } + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], interactions), + ); + if (widget.onRefreshFeed == null) return pages; + return FeedPullRefresh( + pageController: logic.pages, + topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, + onRefresh: _refreshHomeFeed, + child: pages, + ); + }, ); final body = Stack( fit: StackFit.expand, @@ -504,9 +527,17 @@ class _PlayerPageState extends State final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length ? logic.sessionEpisodes[index] : entry.episode; - final episode = widget.homeMode + final displayed = widget.homeMode ? logic.displayEpisode(drama, sessionEpisode) : sessionEpisode; + final episode = + (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && + sessionEpisode.thumbnailsUrl != null + ? displayed.withVideoUrl( + displayed.videoUrl, + thumbnailsUrl: sessionEpisode.thumbnailsUrl, + ) + : displayed; final selected = index == logic.session.currentIndex; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. @@ -702,6 +733,7 @@ class _PlayerPageState extends State thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, ), + scrubbing: _scrubbing, actions: PlayerOverlayActions( onBack: widget.embedded ? null @@ -719,7 +751,13 @@ class _PlayerPageState extends State onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, episode), - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onScrubStart: ready + ? () { + _setScrubbing(true); + logic.beginScrub(videoPort!); + } + : null, + onScrubEnd: () => _setScrubbing(false), onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, diff --git a/lib/pages/player/widgets/feed_pull_refresh.dart b/lib/pages/player/widgets/feed_pull_refresh.dart new file mode 100644 index 0000000..d9e566a --- /dev/null +++ b/lib/pages/player/widgets/feed_pull_refresh.dart @@ -0,0 +1,180 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; + +const double _maxPull = 96; +const double _refreshThreshold = 56; +const double _firstPageEpsilon = 0.01; + +/// 第一条已经到顶时,向下的拖动不再翻页。 +class FeedRefreshPhysics extends PageScrollPhysics { + const FeedRefreshPhysics({super.parent}); + + @override + FeedRefreshPhysics applyTo(ScrollPhysics? ancestor) { + return FeedRefreshPhysics(parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (position.pixels <= position.minScrollExtent && offset > 0) { + return 0; + } + return super.applyPhysicsToUserOffset(position, offset); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + if (position.pixels <= position.minScrollExtent && velocity < 0) { + return super.createBallisticSimulation(position, 0); + } + return super.createBallisticSimulation(position, velocity); + } +} + +/// 竖滑到第一条后继续下拉时刷新,其它页仍交给翻页。 +class FeedPullRefresh extends StatefulWidget { + const FeedPullRefresh({ + super.key, + required this.pageController, + required this.onRefresh, + required this.child, + this.topInset = 0, + }); + + final PageController pageController; + final Future Function() onRefresh; + final Widget child; + final double topInset; + + @override + State createState() => _FeedPullRefreshState(); +} + +class _FeedPullRefreshState extends State { + Offset? _origin; + double _pull = 0; + bool _tracking = false; + bool _refreshing = false; + + bool _atFirstPage() { + final controller = widget.pageController; + if (!controller.hasClients) return false; + return (controller.page ?? 0) <= _firstPageEpsilon; + } + + void _setPull(double value) { + final next = value.clamp(0.0, _maxPull); + if (next == _pull) return; + setState(() => _pull = next); + } + + void _onPointerDown(PointerDownEvent event) { + if (_refreshing || !_atFirstPage()) { + _tracking = false; + return; + } + _origin = event.position; + _tracking = true; + } + + void _onPointerMove(PointerMoveEvent event) { + final origin = _origin; + if (!_tracking || origin == null || _refreshing) return; + final delta = event.position - origin; + if (delta.dx.abs() > delta.dy.abs() && delta.dy < _refreshThreshold) { + _tracking = false; + _setPull(0); + return; + } + if (delta.dy > 0) _holdFirstPage(); + _setPull(delta.dy); + } + + void _holdFirstPage() { + final controller = widget.pageController; + if (!controller.hasClients) return; + final position = controller.position; + if (position.pixels <= position.minScrollExtent) return; + controller.jumpTo(position.minScrollExtent); + } + + void _onPointerEnd() { + final shouldRefresh = _tracking && _pull >= _refreshThreshold; + _origin = null; + _tracking = false; + if (shouldRefresh) { + _refresh(); + return; + } + _setPull(0); + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + _setPull(_refreshThreshold); + try { + await widget.onRefresh(); + } finally { + _refreshing = false; + if (mounted) _setPull(0); + } + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: (_) => _onPointerEnd(), + onPointerCancel: (_) => _onPointerEnd(), + child: Stack( + fit: StackFit.expand, + children: [ + widget.child, + if (_pull > 0) + Positioned( + top: widget.topInset, + left: 0, + right: 0, + child: IgnorePointer( + child: Align( + alignment: Alignment.topCenter, + child: Container( + key: const Key('pull-refresh-indicator'), + width: AppDimens.refreshIndicatorSize, + height: AppDimens.refreshIndicatorSize, + padding: const EdgeInsets.all( + (AppDimens.refreshIndicatorSize - + AppDimens.refreshSpinnerSize) / + 2, + ), + decoration: BoxDecoration( + color: AppColors.refreshIndicatorSurface, + borderRadius: BorderRadius.circular( + AppDimens.refreshIndicatorRadius, + ), + ), + child: const SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + strokeWidth: AppDimens.refreshSpinnerStroke, + color: AppColors.textPrimary, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 2c028cb..f1f8eed 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -16,6 +16,7 @@ import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; import 'player_double_tap_hearts.dart'; import 'seek_progress_bar.dart'; +import 'scrub_preview_card.dart'; part 'home_feed_overlay_parts.dart'; @@ -207,6 +208,11 @@ class _HomeFeedOverlayState extends State { height: _progressHeight, hitHeight: _progressHitHeight, cancelScrubRequest: widget.cancelScrubRequest, + thumbnailService: widget.thumbnailService, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.episode.thumbnailsUrl, + episodeId: widget.episode.id, + posterUrl: widget.drama.posterUrl, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index bd377df..33e8cb4 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -19,6 +19,11 @@ class _FeedProgressBar extends StatefulWidget { required this.height, required this.hitHeight, this.cancelScrubRequest = 0, + this.thumbnailService, + this.spriteAxisService, + this.thumbnailsUrl, + this.episodeId, + this.posterUrl = '', this.onScrubStart, this.onScrubEnd, this.onSeek, @@ -29,6 +34,11 @@ class _FeedProgressBar extends StatefulWidget { final double height; final double hitHeight; final int cancelScrubRequest; + final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisSource? spriteAxisService; + final String? thumbnailsUrl; + final String? episodeId; + final String posterUrl; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; @@ -142,7 +152,26 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { clipBehavior: Clip.none, children: [ track, - if (showPreview) + if (showPreview && + widget.thumbnailService != null && + widget.episodeId != null) + Positioned( + left: scrubPreviewLeft( + trackWidth: width, + fraction: _preview!, + ), + bottom: _lineCenterFromBottom + 16, + child: ScrubPreviewCard( + service: widget.thumbnailService!, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.thumbnailsUrl, + episodeId: widget.episodeId!, + posterUrl: widget.posterUrl, + position: previewPosition, + duration: video!.duration, + ), + ) + else if (showPreview) Positioned( left: _timeBubbleLeft(width, _preview!), bottom: _lineCenterFromBottom + 16, diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 4e509f5..2c42f55 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -11,7 +11,6 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; -const double _headlineFont = 26; const double _seriesFont = 19; const double _tagFont = 13; const double _synopsisFont = 13; @@ -49,34 +48,12 @@ class _HomeFeedInfoPanelState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final dramaTitle = localizedDramaTitle(l10n, widget.drama); - final episodeTitle = widget.episode.title.trim(); - final headline = _headline(l10n, dramaTitle, episodeTitle); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final genres = _genreValues(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - key: const Key('home-feed-title-open'), - behavior: HitTestBehavior.opaque, - onTap: widget.onOpenFullPlayer, - child: Text( - headline, - key: const Key('home-feed-title'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _headlineFont, - fontWeight: FontWeight.w700, - height: 1.2, - letterSpacing: 0, - shadows: _textShadows, - ), - ), - ), - const SizedBox(height: 8), GestureDetector( key: const Key('home-feed-series-name'), behavior: HitTestBehavior.opaque, @@ -119,23 +96,6 @@ class _HomeFeedInfoPanelState extends State { ); } - String _headline( - AppLocalizations l10n, - String dramaTitle, - String episodeTitle, - ) { - if (episodeTitle.isEmpty) return dramaTitle; - final normalized = episodeTitle.toLowerCase().replaceAll(' ', ''); - final localizedEpisode = l10n - .episodeLabel(widget.episode.number) - .toLowerCase() - .replaceAll(' ', ''); - final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized); - return normalized == localizedEpisode || episodeOnly - ? dramaTitle - : episodeTitle; - } - Widget _tagMetaRow(AppLocalizations l10n, List genres) { return Row( key: const Key('home-feed-tags'), @@ -213,21 +173,7 @@ class _HomeFeedInfoPanelState extends State { ); } if (_synopsisExpanded) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - synopsis, - key: const Key('home-feed-synopsis'), - style: style, - ), - const SizedBox(height: 2), - _synopsisToggle( - key: const Key('home-feed-synopsis-more'), - label: l10n.showLess, - ), - ], - ); + return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n); } return Stack( children: [ @@ -255,6 +201,30 @@ class _HomeFeedInfoPanelState extends State { ); } + Widget _expandedSynopsis( + double maxWidth, + String synopsis, + TextStyle style, + AppLocalizations l10n, + ) { + return SizedBox( + width: maxWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(synopsis, key: const Key('home-feed-synopsis'), style: style), + Align( + alignment: Alignment.centerRight, + child: _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.showLess, + ), + ), + ], + ), + ); + } + Widget _synopsisToggle({required Key key, required String label}) { return GestureDetector( key: key, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 792c1fd..be69559 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,7 +1,12 @@ // Author: fruit.com.cn -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/catalog_controller.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -17,6 +22,7 @@ const double _topButtonSize = 36; const double _topBarHeight = 58; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; +const double _synopsisRailInset = 54; const List _playerTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; @@ -29,6 +35,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.data, required this.actions, required this.controlsVisible, + required this.scrubbing, }); /// 展示数据。 @@ -40,6 +47,9 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 控制层是否可见。 final bool controlsVisible; + /// 正在拖动进度条。拖动期间清屏,只留进度条。 + final bool scrubbing; + @override Widget build(BuildContext context) { return SafeArea( @@ -61,7 +71,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { _top(context), _bottom(context), if (controlsVisible) _rail(context), - if (!data.playing) + if (!data.playing && !scrubbing) Center( child: PlayerCenterToggle( key: const Key('player-play-toggle'), @@ -79,49 +89,55 @@ class PlayerPlaybackOverlay extends StatelessWidget { top: 0, left: 0, right: 0, - child: Container( - key: const Key('player-controls-top'), - height: _topBarHeight, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - child: Row( - children: [ - if (actions.onBack != null) - _TopButton( - key: const Key('player-back'), - tooltip: data.l10n.backToSeries, - icon: Icons.chevron_left_rounded, - onPressed: actions.onBack!, - ) - else - const SizedBox(width: _topButtonSize), - const SizedBox(width: 8), - Expanded( - child: Text( - data.l10n.episodeLabel(data.episode.number), - key: const Key('player-episode-heading'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 15, - fontWeight: FontWeight.w700, - shadows: _playerTextShadows, + child: IgnorePointer( + ignoring: scrubbing, + child: Opacity( + opacity: scrubbing ? 0 : 1, + child: Container( + key: const Key('player-controls-top'), + height: _topBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + if (actions.onBack != null) + _TopButton( + key: const Key('player-back'), + tooltip: data.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: actions.onBack!, + ) + else + const SizedBox(width: _topButtonSize), + const SizedBox(width: 8), + Expanded( + child: Text( + data.l10n.episodeLabel(data.episode.number), + key: const Key('player-episode-heading'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ), + ), ), - ), + _SpeedEntry( + key: const Key('player-speed-entry'), + speed: data.playbackSpeed, + onTap: actions.onSpeed, + ), + const SizedBox(width: 8), + _TopButton( + key: const Key('player-more'), + tooltip: data.l10n.more, + icon: Icons.more_vert_rounded, + onPressed: actions.onMore, + ), + ], ), - _SpeedEntry( - key: const Key('player-speed-entry'), - speed: data.playbackSpeed, - onTap: actions.onSpeed, - ), - const SizedBox(width: 8), - _TopButton( - key: const Key('player-more'), - tooltip: data.l10n.more, - icon: Icons.more_vert_rounded, - onPressed: actions.onMore, - ), - ], + ), ), ), ); @@ -143,43 +159,49 @@ class PlayerPlaybackOverlay extends StatelessWidget { return Positioned( right: AppDimens.spaceMd, top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), - child: InteractionRail( - key: const Key('player-interaction-rail'), - items: [ - InteractionRailItem( - key: const Key('player-rail-save'), - asset: 'assets/icon/rail/rail_save.png', - label: data.l10n.favorite, - count: data.favoriteCount, - countKey: const Key('player-rail-save-count'), - selected: data.saved, - onTap: actions.onSave, + child: IgnorePointer( + ignoring: scrubbing, + child: Opacity( + opacity: scrubbing ? 0 : 1, + child: InteractionRail( + key: const Key('player-interaction-rail'), + items: [ + InteractionRailItem( + key: const Key('player-rail-save'), + asset: 'assets/icon/rail/rail_save.png', + label: data.l10n.favorite, + count: data.favoriteCount, + countKey: const Key('player-rail-save-count'), + selected: data.saved, + onTap: actions.onSave, + ), + InteractionRailItem( + key: const Key('player-rail-comments'), + asset: 'assets/icon/rail/rail_comment.png', + label: data.l10n.comments, + count: data.commentCount, + countKey: const Key('player-rail-comments-count'), + onTap: actions.onComments, + ), + InteractionRailItem( + key: const Key('player-rail-like'), + asset: 'assets/icon/rail/rail_like.png', + label: data.l10n.like, + count: data.likeCount, + countKey: const Key('player-rail-like-count'), + selected: data.liked, + selectedColor: AppColors.doubleTapHeartRed, + onTap: actions.onLike, + ), + InteractionRailItem( + key: const Key('player-rail-share'), + asset: 'assets/icon/rail/rail_share.png', + label: data.l10n.share, + onTap: actions.onShare, + ), + ], ), - InteractionRailItem( - key: const Key('player-rail-comments'), - asset: 'assets/icon/rail/rail_comment.png', - label: data.l10n.comments, - count: data.commentCount, - countKey: const Key('player-rail-comments-count'), - onTap: actions.onComments, - ), - InteractionRailItem( - key: const Key('player-rail-like'), - asset: 'assets/icon/rail/rail_like.png', - label: data.l10n.like, - count: data.likeCount, - countKey: const Key('player-rail-like-count'), - selected: data.liked, - selectedColor: AppColors.doubleTapHeartRed, - onTap: actions.onLike, - ), - InteractionRailItem( - key: const Key('player-rail-share'), - asset: 'assets/icon/rail/rail_share.png', - label: data.l10n.share, - onTap: actions.onShare, - ), - ], + ), ), ); } @@ -192,11 +214,17 @@ class PlayerPlaybackOverlay extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (controlsVisible) _bottomInfo(context), + Offstage( + offstage: !controlsVisible || scrubbing, + child: _bottomInfo(context), + ), if (data.boosting) _boostHint(), _bottomProgress(), - // 加速态只保留进度条与提示,便于全屏快进时看清画面。 - if (!data.boosting) _bottomActions(), + // 加速或拖动进度时只留进度条,避免挡住画面。 + Offstage( + offstage: data.boosting || scrubbing, + child: _bottomActions(), + ), ], ), ); @@ -243,12 +271,16 @@ class PlayerPlaybackOverlay extends StatelessWidget { children: [ _detailRow(series), const SizedBox(height: 4), - _EpisodeSynopsis( - key: const Key('player-episode-synopsis'), - l10n: data.l10n, - episodeNumber: data.episode.number, - synopsis: synopsis, - authorNote: data.authorNote, + Padding( + padding: const EdgeInsets.only(right: _synopsisRailInset), + child: _EpisodeSynopsis( + key: const Key('player-episode-synopsis'), + l10n: data.l10n, + drama: data.drama, + episodeNumber: data.episode.number, + synopsis: synopsis, + authorNote: data.authorNote, + ), ), ], ), @@ -273,6 +305,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { posterUrl: data.drama.posterUrl, inactiveTrackColor: AppColors.inactiveTrack, onScrubStart: actions.onScrubStart, + onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, ), ); diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index b811d4c..0e26878 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:video_player/video_player.dart'; @@ -40,8 +41,8 @@ class PlayerOverlayData { /// 当前剧集。 final Episode episode; - /// 当前播放器。 - final VideoPlayerController? controller; + /// 当前播放器进度。 + final ValueListenable? controller; /// 是否已点赞。 final bool liked; @@ -98,6 +99,7 @@ class PlayerOverlayActions { required this.onShare, required this.onMore, required this.onScrubStart, + required this.onScrubEnd, required this.onSeek, required this.onLongPressCenter, required this.onBoostStart, @@ -143,6 +145,9 @@ class PlayerOverlayActions { /// 开始拖动进度。 final VoidCallback? onScrubStart; + /// 结束拖动进度。 + final VoidCallback? onScrubEnd; + /// 提交进度跳转。 final Future Function(Duration)? onSeek; diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 39a1710..7bd0744 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -109,12 +109,14 @@ class _EpisodeSynopsis extends StatefulWidget { const _EpisodeSynopsis({ super.key, required this.l10n, + required this.drama, required this.episodeNumber, required this.synopsis, required this.authorNote, }); final AppLocalizations l10n; + final Drama drama; final int episodeNumber; final String synopsis; final String authorNote; @@ -125,6 +127,9 @@ class _EpisodeSynopsis extends StatefulWidget { class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { bool _expanded = false; + String? _fullSynopsis; + int _detailRequest = 0; + bool _detailRequested = false; /// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。 static const TextStyle _lineStyle = TextStyle( @@ -134,53 +139,114 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { height: 1.35, shadows: _playerTextShadows, ); + static const TextStyle _linkStyle = TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: _playerTextShadows, + ); + static const int _collapsedMaxLines = 2; + static const double _expandGap = 6; + static const double _expandTrailing = 28; + static const double _separatorGap = 6; + + String get _synopsis => _fullSynopsis ?? widget.synopsis; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_detailRequested || widget.drama.detailLoaded) return; + _detailRequested = true; + unawaited(_loadFullSynopsis()); + } + + @override + void didUpdateWidget(covariant _EpisodeSynopsis oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.drama.id == widget.drama.id && + oldWidget.synopsis == widget.synopsis) { + return; + } + _fullSynopsis = null; + _expanded = false; + _detailRequested = !widget.drama.detailLoaded; + if (_detailRequested) unawaited(_loadFullSynopsis()); + } + + Future _loadFullSynopsis() async { + if (widget.drama.detailLoaded) return; + final request = ++_detailRequest; + final CatalogController catalog; + try { + catalog = context.read(); + } catch (_) { + return; + } + try { + final detail = await catalog.requireDetail(widget.drama); + if (!mounted || request != _detailRequest) return; + final full = localizedDramaSynopsis(widget.l10n, detail); + if (full == widget.synopsis) return; + setState(() => _fullSynopsis = full); + } catch (_) { + // 详情失败时继续用列表里的简介。 + } + } @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return LayoutBuilder( + builder: (context, constraints) => _block(constraints.maxWidth), + ); + } + + Widget _block(double maxWidth) { + final label = widget.l10n.episodeLabel(widget.episodeNumber); + if (_expanded) return _expandedBlock(label, maxWidth); + return _collapsedBlock(label, maxWidth); + } + + Widget _collapsedBlock(String label, double maxWidth) { + final link = widget.l10n.expandSynopsis; + final body = _collapsedBody(label, maxWidth, link); + final text = _synopsisText(label, body, link: null, expanded: false); + if (body == _synopsis) return text; + final caret = _caret(label, body, maxWidth); + return Stack( + clipBehavior: Clip.none, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - widget.l10n.episodeLabel(widget.episodeNumber), - key: const Key('player-episode-label'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _lineStyle, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '|', - maxLines: 1, - style: _lineStyle.copyWith( - color: AppColors.playerMuted.withValues(alpha: 0.6), - ), - ), - ), - Flexible( - child: Text( - widget.synopsis, - key: const Key('player-synopsis'), - maxLines: _expanded ? null : 1, - overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis, - style: _lineStyle, - ), - ), - if (!_expanded) _toggle(widget.l10n.expandSynopsis), - ], + text, + Positioned( + left: caret.dx, + top: caret.dy, + child: Padding( + key: const Key('player-synopsis-gap'), + padding: const EdgeInsets.only(left: _expandGap), + child: _toggle(link), + ), ), - if (_expanded) ...[ + ], + ); + } + + Widget _expandedBlock(String label, double maxWidth) { + return SizedBox( + width: maxWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _synopsisText(label, _synopsis, link: null, expanded: true), + Align( + alignment: Alignment.centerRight, + child: _toggle(widget.l10n.showLess), + ), if (widget.authorNote.isNotEmpty) ...[ const SizedBox(height: 4), Text( widget.authorNote, key: const Key('player-author-note'), - maxLines: 2, + maxLines: _collapsedMaxLines, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.playerMuted, @@ -189,31 +255,181 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { ), ), ], - _toggle(widget.l10n.showLess), ], - ], + ), ); } + Widget _synopsisText( + String label, + String body, { + required String? link, + required bool expanded, + }) { + return Text.rich( + TextSpan( + style: _lineStyle, + children: _spans(label, body, link), + ), + key: const Key('player-synopsis'), + style: _lineStyle, + maxLines: expanded ? null : _collapsedMaxLines, + overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis, + ); + } + + List _spans(String label, String body, String? link) { + return [ + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Text( + label, + key: const Key('player-episode-label'), + style: _lineStyle, + ), + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _separatorGap), + child: Text( + '|', + style: _lineStyle.copyWith( + color: AppColors.playerMuted.withValues(alpha: 0.6), + ), + ), + ), + ), + TextSpan(text: body, style: _lineStyle), + if (link != null) + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Padding( + key: const Key('player-synopsis-gap'), + padding: const EdgeInsets.only(left: _expandGap), + child: _toggle(link), + ), + ), + ]; + } + + String _collapsedBody(String label, double maxWidth, String link) { + if (_fits(label, _synopsis, maxWidth, reserve: 0)) { + return _synopsis; + } + final reserve = + _textWidth(link, _linkStyle) + _expandGap + _expandTrailing; + var low = 0; + var high = _synopsis.length; + var best = '…'; + while (low <= high) { + final mid = (low + high) >> 1; + final slice = '${_synopsis.substring(0, mid)}…'; + if (_fits(label, slice, maxWidth, reserve: reserve)) { + best = slice; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; + } + + bool _fits( + String label, + String body, + double maxWidth, { + required double reserve, + }) { + final painter = TextPainter( + text: TextSpan( + children: [ + TextSpan(text: label, style: _lineStyle), + const WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: _separatorGap), + ), + TextSpan( + text: '|', + style: _lineStyle.copyWith( + color: AppColors.playerMuted.withValues(alpha: 0.6), + ), + ), + const WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: _separatorGap), + ), + TextSpan(text: body, style: _lineStyle), + if (reserve > 0) + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: reserve), + ), + ], + ), + maxLines: _collapsedMaxLines, + textDirection: TextDirection.ltr, + ); + painter.setPlaceholderDimensions(_slots(reserve)); + painter.layout(maxWidth: maxWidth); + return !painter.didExceedMaxLines; + } + + List _slots(double reserve) { + return [ + const PlaceholderDimensions( + size: Size(_separatorGap, 1), + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + ), + const PlaceholderDimensions( + size: Size(_separatorGap, 1), + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + ), + if (reserve > 0) + PlaceholderDimensions( + size: Size(reserve, _lineStyle.fontSize ?? 12), + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + ), + ]; + } + + Offset _caret(String label, String body, double maxWidth) { + final painter = TextPainter( + text: TextSpan(children: _spans(label, body, null)), + maxLines: _collapsedMaxLines, + textDirection: TextDirection.ltr, + ); + painter.setPlaceholderDimensions(_slots(0)); + painter.layout(maxWidth: maxWidth); + return painter.getOffsetForCaret( + TextPosition(offset: painter.plainText.length), + Rect.zero, + ); + } + double _textWidth(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + return painter.width; + } + Widget _toggle(String label) { return InkWell( key: const Key('player-synopsis-toggle'), onTap: () => setState(() => _expanded = !_expanded), borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w700, - shadows: _playerTextShadows, - ), - ), - ), + child: Text(label, maxLines: 1, style: _linkStyle), ); } } diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 6bb199d..266d2db 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -11,8 +11,9 @@ import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; -const double scrubPreviewWidth = 120; +const double scrubPreviewWidth = 96; const double scrubPreviewSafeMargin = 12; +const double _portraitFrameAspect = 9 / 16; /// 把预览卡片限制在进度条可用宽度内。 double scrubPreviewLeft({ @@ -64,6 +65,7 @@ class _ScrubPreviewCardState extends State { void initState() { super.initState(); widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); + _prepareSprite(); _syncFallbackSource(); } @@ -74,6 +76,10 @@ class _ScrubPreviewCardState extends State { oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); } + if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl || + oldWidget.position != widget.position) { + _prepareSprite(); + } if (oldWidget.service != widget.service || oldWidget.episodeId != widget.episodeId || oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { @@ -97,6 +103,12 @@ class _ScrubPreviewCardState extends State { super.dispose(); } + void _prepareSprite() { + final service = widget.spriteAxisService; + if (service == null) return; + unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position)); + } + void _handleSpriteAxisChanged() { if (!mounted) return; if (_spriteFrame != null) { @@ -190,7 +202,8 @@ class _ScrubPreviewCardState extends State { ), ), AspectRatio( - aspectRatio: 16 / 9, + key: const Key('scrub-preview-frame-box'), + aspectRatio: _frameAspect(spriteFrame), child: Stack( fit: StackFit.expand, children: [ @@ -229,6 +242,14 @@ class _ScrubPreviewCardState extends State { } } +double _frameAspect(SpriteThumbnailFrame? frame) { + final rect = frame?.sourceRect; + if (rect == null || rect.width <= 0 || rect.height <= 0) { + return _portraitFrameAspect; + } + return rect.width / rect.height; +} + /// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。 class SpriteCropPreview extends StatelessWidget { const SpriteCropPreview({ diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 9090b6a..0e23564 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -170,11 +170,28 @@ class _SeekProgressBarState extends State { setState(() => _buffering = buffering); } + void _endPointerDrag() { + if (!_dragging) { + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; + return; + } + final duration = widget.valueListenable?.value.duration ?? Duration.zero; + final fraction = _previewFraction.value ?? _dragStartFraction ?? 0; + unawaited(_finish(fraction, duration)); + } + Future _finish(double fraction, Duration duration) async { final startedAt = _dragStartFraction; _dragStartFraction = null; if (widget.locked || !_dragging || startedAt == null) return; - setState(() => _dragging = false); + _dragging = false; + if (!mounted) { + widget.onScrubEnd?.call(); + return; + } + setState(() {}); _pointerMoved = false; _pointerStart = null; widget.thumbnailService?.cancelPending(); @@ -287,20 +304,8 @@ class _SeekProgressBarState extends State { _pointerMoved = true; } }, - onPointerUp: (_) { - if (!_dragging) { - _pointerMoved = false; - _pointerStart = null; - _dragStartFraction = null; - } - }, - onPointerCancel: (_) { - if (!_dragging) { - _pointerMoved = false; - _pointerStart = null; - _dragStartFraction = null; - } - }, + onPointerUp: (_) => _endPointerDrag(), + onPointerCancel: (_) => _endPointerDrag(), child: Slider( value: fraction, // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index b7713c9..d4a2f02 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -294,7 +294,7 @@ void main() { expect(find.textContaining('/ 00:12'), findsOneWidget); expect( tester.getSize(find.byKey(const Key('scrub-preview-card'))).width, - 120, + 96, ); await gesture.up(); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 691023d..6eb8625 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -570,14 +570,11 @@ void main() { 32, ); - await tester.tap(find.byKey(const Key('player-synopsis-toggle'))); - await tester.pumpAndSettle(); - final expandedBottom = tester.getRect( + final bottomPanel = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); - // K29.9 展开态展示简介全文,高度会增长,但仍需留在画面下半区。 - expect(expandedBottom.height, lessThanOrEqualTo(330)); - expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.5)); + expect(bottomPanel.height, lessThanOrEqualTo(330)); + expect(bottomPanel.top, greaterThanOrEqualTo(screenHeight * 0.5)); expect(tester.takeException(), isNull); }); @@ -753,8 +750,33 @@ void main() { }); testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async { - await _pumpApp(tester); - await _openSeriesPlayer(tester); + final longCopy = Drama( + id: 'long-copy-demo', + title: 'Long Title', + synopsis: + 'A very long synopsis that keeps describing the story far beyond ' + 'the two lines available under the title in the fullscreen player.', + posterUrl: '/files/long-copy-demo.png', + genre: 'Drama', + detailLoaded: true, + episodes: const [ + Episode( + id: 'long-copy-demo-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/long-demo-1.mp4', + ), + ], + ); + await _pumpApp( + tester, + catalog: FakeCatalogRepository(dramas: [longCopy]), + ); + await _openSeriesPlayer( + tester, + cardKey: const Key('home-feed-card-long-copy-demo'), + ); final toggle = find.byKey(const Key('player-synopsis-toggle')); final synopsis = find.byKey(const Key('player-synopsis')); @@ -762,10 +784,26 @@ void main() { find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); - expect(tester.widget(synopsis).maxLines, 1); expect( - tester.getRect(toggle).left - tester.getRect(synopsis).right, - lessThanOrEqualTo(4), + tester + .widget( + find.descendant(of: toggle, matching: find.text('More')), + ) + .style + ?.color, + AppColors.textPrimary, + ); + expect(tester.widget(synopsis).maxLines, 2); + expect( + tester.widget(find.byKey(const Key('player-synopsis-gap'))).padding, + const EdgeInsets.only(left: 6), + ); + final synopsisRect = tester.getRect(synopsis); + final toggleRect = tester.getRect(toggle); + expect(toggleRect.left, greaterThan(synopsisRect.left)); + expect( + toggleRect.center.dy, + greaterThan(synopsisRect.top + synopsisRect.height * 0.45), ); await tester.tap(toggle); @@ -774,7 +812,12 @@ void main() { find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); - expect(tester.widget(synopsis).maxLines, isNull); + expect(tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, isNull); + final lessRect = tester.getRect(toggle); + final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); + final block = tester.getRect(find.byKey(const Key('player-episode-synopsis'))); + expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1)); + expect(block.right - lessRect.right, lessThanOrEqualTo(2)); await tester.tap(toggle); await tester.pumpAndSettle(); @@ -782,7 +825,7 @@ void main() { find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); - expect(tester.widget(synopsis).maxLines, 1); + expect(tester.widget(synopsis).maxLines, 2); }); testWidgets('should_share_one_muted_style_across_label_and_synopsis', ( @@ -860,7 +903,11 @@ void main() { final synopsis = tester.widget( find.byKey(const Key('player-synopsis')), ); - expect(synopsis.maxLines, 1); + expect( + tester.getRect(find.byKey(const Key('player-synopsis'))).right, + lessThanOrEqualTo(320 - 14 - 54), + ); + expect(synopsis.maxLines, 2); expect(synopsis.overflow, TextOverflow.ellipsis); expect( tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, diff --git a/test/player/scrub_preview_card_test.dart b/test/player/scrub_preview_card_test.dart index 2503b48..edb7d62 100644 --- a/test/player/scrub_preview_card_test.dart +++ b/test/player/scrub_preview_card_test.dart @@ -140,6 +140,9 @@ class _FakeAxisSource extends ChangeNotifier Duration position, ) => frame; + @override + Future prepareAt(String? thumbnailsUrl, Duration position) async {} + @override bool hasAxis(String? thumbnailsUrl) => axisReady; } diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart index 0497128..d1f0dfa 100644 --- a/test/player/seek_scrub_visibility_test.dart +++ b/test/player/seek_scrub_visibility_test.dart @@ -5,6 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; +import 'package:fruitshorts/pages/player/widgets/player_overlay.dart'; +import 'package:fruitshorts/pages/player/widgets/player_overlay_models.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -151,6 +153,56 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); }); + + testWidgets('full player drag clears chrome and release restores it', ( + tester, + ) async { + await _pumpFullPlayerOverlay(tester); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('player-progress'))), + ); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + + expect(_opacity(tester, const Key('player-controls-top')), 0); + expect(_opacity(tester, const Key('player-interaction-rail')), 0); + expect(_offstage(tester, const Key('player-controls-bottom')), isTrue); + expect(_offstage(tester, const Key('player-bottom-actions')), isTrue); + expect(find.byKey(const Key('player-progress')), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(_opacity(tester, const Key('player-controls-top')), 1); + expect(_opacity(tester, const Key('player-interaction-rail')), 1); + expect(_offstage(tester, const Key('player-bottom-actions')), isFalse); + }); +} + +double _opacity(WidgetTester tester, Key key) { + return tester + .widget( + find + .ancestor(of: find.byKey(key), matching: find.byType(Opacity)) + .first, + ) + .opacity; +} + +bool _offstage(WidgetTester tester, Key key) { + return tester + .widget( + find + .ancestor( + of: find.byKey(key, skipOffstage: false), + matching: find.byType(Offstage), + ) + .first, + ) + .offstage; } Future<_HarnessState> _pumpHarness(WidgetTester tester) async { @@ -245,6 +297,96 @@ Future> _pumpFeedOverlay( return controller; } +class _FullPlayerHarness extends StatefulWidget { + const _FullPlayerHarness({required this.controller}); + + final ValueNotifier controller; + + @override + State<_FullPlayerHarness> createState() => _FullPlayerHarnessState(); +} + +class _FullPlayerHarnessState extends State<_FullPlayerHarness> { + bool _scrubbing = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PlayerPlaybackOverlay( + scrubbing: _scrubbing, + controlsVisible: true, + data: PlayerOverlayData( + l10n: l10n, + drama: const Drama( + id: 'full-drama', + title: 'Full Drama', + synopsis: 'A short synopsis for the full player.', + posterUrl: '', + totalEpisodes: 8, + detailLoaded: true, + ), + episode: const Episode( + id: 'full-episode', + number: 2, + title: 'Episode 2', + isFree: true, + ), + controller: widget.controller, + locked: false, + liked: false, + likeCount: 1, + commentCount: 1, + favoriteCount: 1, + saved: false, + playing: true, + playbackSpeed: 1, + totalEpisodes: 8, + boosting: false, + ), + actions: PlayerOverlayActions( + onBack: () {}, + onTogglePlay: () {}, + onClear: () {}, + onExitClear: () {}, + onDetail: () {}, + onSpeed: () {}, + onEpisodes: () {}, + onComments: () {}, + onLike: () {}, + onSave: () {}, + onShare: () {}, + onMore: () {}, + onScrubStart: () => setState(() => _scrubbing = true), + onScrubEnd: () => setState(() => _scrubbing = false), + onSeek: (_) async {}, + onLongPressCenter: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + ), + ); + } +} + +Future _pumpFullPlayerOverlay(WidgetTester tester) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(minutes: 2), + position: Duration(seconds: 20), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold(body: _FullPlayerHarness(controller: controller)), + ), + ); + await tester.pumpAndSettle(); +} + class _Harness extends StatefulWidget { const _Harness({super.key, required this.controller});