diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 9480288..ecc7036 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -11,7 +11,7 @@ part 'playback_neighbors.dart'; part 'playback_session_controls.dart'; /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 -const Duration _qualityHandoffHold = Duration(milliseconds: 200); +const Duration _qualityHandoffHold = Duration(milliseconds: 500); typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 5167541..3ff2094 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -144,6 +144,14 @@ class WatchRecordsController extends ChangeNotifier { return _detailCache[dramaId]; } + /// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。 + WatchRecord? recordForDrama(String dramaId) { + for (final record in records) { + if (record.dramaId == dramaId) return record; + } + return null; + } + /// 删除单条记录,先更新 UI,失败按原位置回滚。 Future remove(String dramaId) async { final index = _recordIndex(dramaId); diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index b281f23..b97f73a 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -149,33 +149,6 @@ class _HomeFeedOverlayState extends State { onInteractionEnd: widget.onBoostEnd, ), ), - Positioned( - left: _pageHorizontal, - right: _railRight, - bottom: _bottomInfoInset, - child: AnimatedOpacity( - key: const Key('home-feed-content-opacity'), - duration: _overlayFadeDuration, - opacity: widget.visible && !_scrubbing && !widget.boosting - ? 1 - : 0, - child: IgnorePointer( - key: const Key('home-feed-content-ignore'), - ignoring: !widget.visible || _scrubbing || widget.boosting, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _bottomInfo()), - const SizedBox(width: _railGap), - Padding( - padding: const EdgeInsets.only(bottom: _railBottomOffset), - child: _rail(), - ), - ], - ), - ), - ), - ), Positioned( left: 0, right: 0, @@ -227,6 +200,36 @@ class _HomeFeedOverlayState extends State { ), ), ), + // 信息面板放在最后(最上层):进度条 32px 触控盒向上伸进底部避让区, + // 若面板在前,简介/展开按钮底部的触控留白会被进度条吞掉点击。 + // 视觉上避让条带为空,提层不改变观感。 + Positioned( + left: _pageHorizontal, + right: _railRight, + bottom: _bottomInfoInset, + child: AnimatedOpacity( + key: const Key('home-feed-content-opacity'), + duration: _overlayFadeDuration, + opacity: widget.visible && !_scrubbing && !widget.boosting + ? 1 + : 0, + child: IgnorePointer( + key: const Key('home-feed-content-ignore'), + ignoring: !widget.visible || _scrubbing || widget.boosting, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), + ), + ), + ), ], ), ); diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 7352b31..61ed40c 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -10,9 +10,9 @@ import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; import 'interaction_rail.dart'; -const double _seriesFont = 21; -const double _tagFont = 14; -const double _synopsisFont = 15; +const double _seriesFont = 17; +const double _tagFont = 12; +const double _synopsisFont = 14; const double _watchAllHeight = 44; const List _textShadows = [ Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), @@ -90,7 +90,7 @@ class _HomeFeedInfoPanelState extends State { style: const TextStyle( color: AppColors.textPrimary, fontSize: _seriesFont, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w400, shadows: _textShadows, ), ), @@ -98,7 +98,7 @@ class _HomeFeedInfoPanelState extends State { const SizedBox(width: 2), const Icon( Icons.chevron_right_rounded, - size: 20, + size: 18, color: AppColors.textPrimary, ), ], @@ -127,13 +127,13 @@ class _HomeFeedInfoPanelState extends State { ]; return SizedBox( key: const Key('home-feed-tags'), - height: 25, + height: 22, child: ListView.separated( scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), + physics: const BouncingScrollPhysics(), shrinkWrap: true, itemCount: chips.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), + separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (_, index) => chips[index], ), ); @@ -151,7 +151,15 @@ class _HomeFeedInfoPanelState extends State { children: [ TextSpan( text: prefix, - style: baseStyle.copyWith(fontWeight: FontWeight.w600), + style: baseStyle, + ), + TextSpan( + text: ' | ', + style: baseStyle.copyWith( + color: AppColors.textPrimary.withValues(alpha: 0.45), + fontSize: _synopsisFont - 2, + fontWeight: FontWeight.w400, + ), ), TextSpan(text: synopsis, style: baseStyle), ], @@ -164,6 +172,7 @@ class _HomeFeedInfoPanelState extends State { final toggle = _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.expandSynopsis, + padding: const EdgeInsets.only(left: 6), ); final painter = TextPainter( text: span, @@ -180,21 +189,28 @@ class _HomeFeedInfoPanelState extends State { ); } // 设计稿:单行省略号 + 行内固定「展开」。 - return Row( - key: const Key('home-feed-synopsis-collapsed'), - children: [ - Expanded( - child: Text.rich( - span, - key: const Key('home-feed-synopsis'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: baseStyle, + // 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。 + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _synopsisExpanded = true), + child: Row( + key: const Key('home-feed-synopsis-collapsed'), + // 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text.rich( + span, + key: const Key('home-feed-synopsis'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ), ), - ), - const SizedBox(width: 2), - toggle, - ], + const SizedBox(width: 2), + toggle, + ], + ), ); }, ); @@ -205,38 +221,50 @@ class _HomeFeedInfoPanelState extends State { TextStyle style, AppLocalizations l10n, ) { - return Column( - key: const Key('home-feed-synopsis-expanded'), - crossAxisAlignment: CrossAxisAlignment.start, + // 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间 + // 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。 + final expandedSpan = TextSpan( + style: style, children: [ - Text.rich( - span, - key: const Key('home-feed-synopsis'), - style: style, - ), - const SizedBox(height: 2), - Align( - alignment: Alignment.centerRight, + span, + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, child: _synopsisToggle( key: const Key('home-feed-synopsis-more'), label: l10n.showLess, + padding: const EdgeInsets.only(left: 8), ), ), ], ); + return Text.rich( + expandedSpan, + key: const Key('home-feed-synopsis-expanded'), + ); } - Widget _synopsisToggle({required Key key, required String label}) { + Widget _synopsisToggle({ + required Key key, + required String label, + required EdgeInsets padding, + }) { return GestureDetector( key: key, + // opaque + 留白扩大命中区;留白方向由调用方决定, + // 展开态不能加 top padding,否则「收起」距简介正文过远。 + behavior: HitTestBehavior.opaque, onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), - child: Text( - label, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _synopsisFont, - fontWeight: FontWeight.w600, - shadows: _textShadows, + child: Padding( + padding: padding, + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w600, + shadows: _textShadows, + ), ), ), ); @@ -317,7 +345,7 @@ class _TagChip extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), alignment: Alignment.center, decoration: BoxDecoration( color: filled @@ -426,11 +454,11 @@ class HomeFeedActionRail extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return InteractionRail( - itemGap: 18, + itemGap: 12, buttonSize: 48, iconSize: 36, countFontSize: 15, - iconCountGap: 6, + iconCountGap: 2, items: [ InteractionRailItem( key: const Key('rail-save'), @@ -462,8 +490,7 @@ class HomeFeedActionRail extends StatelessWidget { ), InteractionRailItem( key: const Key('rail-share'), - icon: Icons.reply_rounded, - mirrorIcon: true, + asset: 'assets/icon/rail/rail_share.png', label: l10n.share, countKey: const Key('rail-share-label'), onTap: onShare, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index a89f284..b510ee0 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -158,16 +158,20 @@ class PlayerPlaybackOverlay extends StatelessWidget { } Widget _rail(BuildContext context) { - final compact = MediaQuery.sizeOf(context).height < 700; return Positioned( right: AppDimens.spaceMd, - top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), + // 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。 + bottom: 120, child: IgnorePointer( ignoring: scrubbing, child: Opacity( opacity: scrubbing ? 0 : 1, child: InteractionRail( key: const Key('player-interaction-rail'), + itemGap: 12, + buttonSize: 48, + iconSize: 36, + countFontSize: 15, items: [ InteractionRailItem( key: const Key('player-rail-save'), @@ -336,7 +340,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { color: AppColors.onPrimary, fontFamily: 'Outfit', fontSize: 18, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w400, shadows: _playerTextShadows, ), ), diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 7bd0744..bd8320f 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -131,9 +131,9 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { int _detailRequest = 0; bool _detailRequested = false; - /// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。 + /// 「第 n 集」、分隔符与简介正文共用同一套白字规格。 static const TextStyle _lineStyle = TextStyle( - color: AppColors.playerMuted, + color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w500, height: 1.35, @@ -142,13 +142,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { static const TextStyle _linkStyle = TextStyle( color: AppColors.textPrimary, fontSize: 12, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, 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; @@ -200,236 +197,116 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { ); } + /// 与首页推荐 feed 的简介展开/收起保持一致: + /// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。 Widget _block(double maxWidth) { final label = widget.l10n.episodeLabel(widget.episodeNumber); - if (_expanded) return _expandedBlock(label, maxWidth); - return _collapsedBlock(label, maxWidth); + final span = TextSpan( + style: _lineStyle, + children: [ + TextSpan(text: label, style: _lineStyle), + _separatorSpan(), + TextSpan(text: _synopsis, style: _lineStyle), + ], + ); + if (_expanded) return _expandedBlock(span); + + final painter = TextPainter( + text: span, + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: maxWidth); + if (!painter.didExceedMaxLines) { + return Text.rich( + span, + key: const Key('player-synopsis'), + maxLines: 1, + style: _lineStyle, + ); + } + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = true), + child: Row( + key: const Key('player-synopsis-collapsed'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text.rich( + span, + key: const Key('player-synopsis'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _lineStyle, + ), + ), + const SizedBox(width: 2), + _toggle(widget.l10n.expandSynopsis, + padding: const EdgeInsets.only(left: 6)), + ], + ), + ); } - 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, + Widget _expandedBlock(InlineSpan span) { + final expandedSpan = TextSpan( + style: _lineStyle, children: [ - 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), + span, + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _toggle( + widget.l10n.showLess, + padding: const EdgeInsets.only(left: 8), ), ), ], ); - } - - 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: _collapsedMaxLines, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.playerMuted, - fontSize: 10, - shadows: _playerTextShadows, - ), + return Column( + key: const Key('player-synopsis-expanded'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich(expandedSpan, key: const Key('player-synopsis')), + if (widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + widget.authorNote, + key: const Key('player-author-note'), + maxLines: _collapsedMaxLines, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.playerMuted, + fontSize: 10, + shadows: _playerTextShadows, ), - ], + ), ], - ), + ], ); } - 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, + /// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。 + /// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。 + TextSpan _separatorSpan() => TextSpan( + text: ' | ', + style: _lineStyle.copyWith( + color: AppColors.onPrimary.withValues(alpha: 0.45), + fontWeight: FontWeight.w400, ), - ), - 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( + Widget _toggle(String label, {required EdgeInsets padding}) { + return GestureDetector( key: const Key('player-synopsis-toggle'), + behavior: HitTestBehavior.opaque, onTap: () => setState(() => _expanded = !_expanded), - borderRadius: BorderRadius.circular(4), - child: Text(label, maxLines: 1, style: _linkStyle), + child: Padding( + padding: padding, + child: Text(label, maxLines: 1, style: _linkStyle), + ), ); } } diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 4cf3c56..e731d2b 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -53,12 +53,16 @@ class PlayerRenderStack extends StatefulWidget { required this.pageController, required this.slots, this.onCurrentVideoVisible, + this.resumeSignal = 0, }); final PageController pageController; final Map slots; final ValueChanged? onCurrentVideoVisible; + /// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。 + final int resumeSignal; + @override State createState() => _PlayerRenderStackState(); } @@ -200,6 +204,7 @@ class _PlayerRenderStackState extends State { onVideoVisible: slot.role == PlayerRenderSlotRole.current ? () => widget.onCurrentVideoVisible?.call(slot.index) : null, + resumeSignal: widget.resumeSignal, ), ), ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 49f52f6..614215f 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -29,6 +29,7 @@ class PlayerVideoSurface extends StatefulWidget { this.debugIndex, this.showFallbackBackground = true, this.onVideoVisible, + this.resumeSignal = 0, }); final VideoPlayerController? controller; @@ -60,6 +61,10 @@ class PlayerVideoSurface extends StatefulWidget { final bool theater; final VoidCallback? onVideoVisible; + /// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧, + /// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。 + final int resumeSignal; + @override State createState() => _PlayerVideoSurfaceState(); } @@ -76,6 +81,11 @@ class _PlayerVideoSurfaceState extends State bool _transitioning = false; bool _videoVisible = false; bool _videoVisibleNotified = false; + /// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。 + Duration? _confirmBasePosition; + /// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准, + /// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。 + DateTime? _confirmNotBefore; String? _lastLayerState; @override @@ -99,6 +109,13 @@ class _PlayerVideoSurfaceState extends State @override void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.resumeSignal != widget.resumeSignal) { + _logSlot('pip-resume-reset', widget.controller?.value); + _resetForControllerChange(); + _confirmNotBefore = DateTime.now().add(const Duration(milliseconds: 450)); + setState(() {}); + if (oldWidget.controller == widget.controller) return; + } if (oldWidget.controller != widget.controller) { _logSlot('controller-change-before', oldWidget.controller?.value); _detachController(oldWidget.controller); @@ -261,6 +278,16 @@ class _PlayerVideoSurfaceState extends State return false; } if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; + // 清晰度切换:新源 seek 完成后 position 立即大于 0,但不代表纹理已有 + // 画面;等 position 越过切换时刻的值(解码循环已在出帧)再确认,避免闪黑。 + final base = _confirmBasePosition; + if (base != null) { + final notBefore = _confirmNotBefore; + if (notBefore != null && DateTime.now().isBefore(notBefore)) { + return false; + } + return value.position > base || value.isCompleted; + } if (value.position > Duration.zero || value.isCompleted) return true; return _playbackStarted && !value.isPlaying && !value.isBuffering; } @@ -321,6 +348,8 @@ class _PlayerVideoSurfaceState extends State _videoVisible = false; _videoVisibleNotified = false; _lastLayerState = null; + // 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。 + _confirmBasePosition = widget.controller?.value.position; } void _notifyVideoVisible() {