// 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; const double _progressHitHeight = 32; const double _pageHorizontal = 20; const double _railRight = 14; const double _railGap = 12; // 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。 // 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。 const double _bottomInfoInset = homeFeedWatchAllHeight + 16; 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: homeFeedWatchAllHeight + 12, child: AnimatedOpacity( duration: _overlayFadeDuration, opacity: widget.boosting ? 1 : 0, child: IgnorePointer( ignoring: !widget.boosting, child: _boostHint(), ), ), ), Positioned( left: _pageHorizontal, right: _pageHorizontal, bottom: homeFeedWatchAllHeight, 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: 0, right: 0, bottom: 0, 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, ), ), ), ), // 信息面板放在最后(最上层):进度条 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(), ), ], ), ), ), ), ], ), ); } 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, ); } }