Files
ReelShort/lib/pages/player/widgets/home_feed_overlay.dart
T
Vicky c027acc91f feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流
- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类
- 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格
- 分类内容支持懒加载、下拉刷新与上拉分页加载
- 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色
- 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口)
- l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
2026-10-27 19:57:42 +08:00

314 lines
9.7 KiB
Dart

// 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<Shadow> _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<VideoPlayerValue>? 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<Offset>? onDoubleTapLike;
final DateTime Function()? now;
final VoidCallback? onScrubStart;
final ValueChanged<bool>? onScrubEnd;
final ValueChanged<bool>? onScrubVisibilityChanged;
final ValueChanged<Duration>? onSeek;
final int cancelScrubRequest;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
final bool suppressBufferLoading;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
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: _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,
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,
),
),
),
),
],
),
);
}
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,
);
}
}