// Author: fruit.com.cn // 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。 import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; import 'player_tap_gesture_region.dart'; import 'seek_progress_bar.dart'; import 'scrub_preview_card.dart'; part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; /// 进度条触控带高度:没有滑块圆点后收到 16pt,够手指按住这条线拖动。 const double _progressHitHeight = 16; const double _pageHorizontal = 20; const double _railRight = 14; const double _railShiftRight = 8; const double _railGap = 12; const double _railWidth = 56; const double _infoWatchAllGap = 2; // 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。 const double _watchAllBottomInset = 8; // 简介与观看条留出明确间距,展开后仍保留相同的底部留白。 const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap; const double _railBottomOffset = 40; const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; /// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。 class HomeFeedOverlay extends StatefulWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, required this.controller, required this.locked, required this.liked, required this.saved, required this.visible, required this.boosting, required this.onLike, required this.onSave, required this.onComments, required this.onShare, required this.onDetail, required this.onOpenFullPlayer, this.onEpisodes, this.onOpenSearch, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, required this.onMore, this.onDoubleTapLike, this.now, this.likeCount, this.favoriteCount, this.commentCount, this.authorNote = '', this.onScrubStart, this.onScrubEnd, this.onScrubVisibilityChanged, this.onSeek, this.cancelScrubRequest = 0, this.thumbnailService, this.spriteAxisService, this.suppressBufferLoading = false, }); final AppLocalizations l10n; final Drama drama; final Episode episode; final ValueListenable? controller; final bool locked; final bool liked; final int? likeCount; final int? favoriteCount; final int? commentCount; final bool saved; final String authorNote; final bool visible; final bool boosting; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; final VoidCallback onShare; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; final VoidCallback? onEpisodes; /// 底部热搜胶囊点击(降级版:跳搜索页)。 final VoidCallback? onOpenSearch; final VoidCallback onTogglePlay; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; final VoidCallback onMore; final ValueChanged? onDoubleTapLike; final DateTime Function()? now; final VoidCallback? onScrubStart; final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; final ValueChanged? onSeek; final int cancelScrubRequest; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final bool suppressBufferLoading; @override State createState() => _HomeFeedOverlayState(); } class _HomeFeedOverlayState extends State { bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { _scrubbing = false; } } @override Widget build(BuildContext context) { return SafeArea( bottom: false, child: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: PlayerTapGestureRegion( key: const ValueKey('home-feed-tap-gesture-region'), gestureKey: const Key('home-feed-gesture'), enabled: !_scrubbing, doubleTapEnabled: !widget.locked, now: widget.now, onSingleTap: widget.onTogglePlay, onDoubleTapLike: widget.onDoubleTapLike, onLongPressStart: _handleLongPressStart, onInteractionEnd: widget.onBoostEnd, ), ), Positioned( left: 0, right: 0, bottom: _watchAllBottomInset + homeFeedWatchAllHeight + 12, child: AnimatedOpacity( duration: _overlayFadeDuration, opacity: widget.boosting ? 1 : 0, child: IgnorePointer( ignoring: !widget.boosting, child: _boostHint(), ), ), ), // 观看条在下、进度条在上:进度条 32pt 触控盒要盖住观看条下沿的空白, // 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。 Positioned( left: _pageHorizontal, right: _railRight + _railGap + _railWidth, bottom: _watchAllBottomInset, child: AnimatedOpacity( duration: _overlayFadeDuration, opacity: widget.visible && !_scrubbing && !widget.boosting ? 1 : 0, child: IgnorePointer( ignoring: !widget.visible || _scrubbing || widget.boosting, child: HomeFeedWatchAllBar( drama: widget.drama, onTap: widget.onOpenFullPlayer, ), ), ), ), Positioned( left: _pageHorizontal, right: _pageHorizontal, bottom: 0, child: _FeedProgressBar( controller: widget.controller, locked: widget.locked, height: _progressHeight, hitHeight: _progressHitHeight, cancelScrubRequest: widget.cancelScrubRequest, thumbnailService: widget.thumbnailService, spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, suppressBufferLoading: widget.suppressBufferLoading, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, ), ), // 信息面板放在最后(最上层),底部避让区永远优先响应简介/展开按钮的点击。 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), SizedBox( width: _railWidth, child: Padding( padding: const EdgeInsets.only( bottom: _railBottomOffset, ), child: Transform.translate( offset: const Offset(_railShiftRight, 0), child: _rail(), ), ), ), ], ), ), ), ), ], ), ); } void _startScrub() { if (!mounted) return; _wasVisibleBeforeScrub = widget.visible; if (!_scrubbing) setState(() => _scrubbing = true); widget.onScrubVisibilityChanged?.call(true); widget.onScrubStart?.call(); } void _endScrub() { if (!mounted) return; final wasVisible = _wasVisibleBeforeScrub; if (_scrubbing) setState(() => _scrubbing = false); widget.onScrubVisibilityChanged?.call(false); widget.onScrubEnd?.call(wasVisible); } void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { widget.onBoostStart(); return; } widget.onMore(); } Widget _boostHint() { return Padding( key: const Key('home-feed-speed-boost-hint'), padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.play_arrow_rounded, color: AppColors.blue, size: 16, shadows: _boostTextShadows, ), const SizedBox(width: 4), Text( widget.l10n.speedBoosting('2.0'), style: const TextStyle( color: AppColors.blue, fontSize: 13, fontWeight: FontWeight.w700, shadows: _boostTextShadows, ), ), ], ), ); } Widget _rail() { return HomeFeedActionRail( key: const Key('home-feed-action-rail'), saved: widget.saved, liked: widget.liked, favoriteCount: widget.favoriteCount, likeCount: widget.likeCount, commentCount: widget.commentCount, onSave: widget.onSave, onLike: widget.onLike, onComments: widget.onComments, onShare: widget.onShare, ); } Widget _bottomInfo() { return HomeFeedInfoPanel( drama: widget.drama, episode: widget.episode, onDetail: widget.onDetail, onOpenFullPlayer: widget.onOpenFullPlayer, onOpenSearch: widget.onOpenSearch, ); } }