Files
ReelShort/lib/pages/player/widgets/home_feed_overlay.dart
T

401 lines
12 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_double_tap_hearts.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 = 14;
const double _railRight = 8;
const double _railGap = 12;
const double _bottomInfoInset = 32;
const double _railBottomOffset = 60;
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,
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,
});
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 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;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
static const Duration _singleTapDelay = Duration(milliseconds: 400);
bool _scrubbing = false;
bool _wasVisibleBeforeScrub = true;
Timer? _singleTapTimer;
DateTime? _lastDownAt;
DateTime? _blockSingleTapUntil;
bool _doubleTapDown = false;
bool _likedThisPair = false;
int _downSerial = 0;
int _tapSeq = 0;
int _nextHeartBurstId = 0;
final List<({int id, Offset position})> _heartBursts = [];
@override
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
_scrubbing = false;
}
}
@override
void dispose() {
_tapSeq++;
_singleTapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Stack(
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerUp: (_) => widget.onBoostEnd(),
onPointerCancel: (_) => widget.onBoostEnd(),
onPointerDown: _handlePointerDown,
child: GestureDetector(
key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque,
onTapUp: _handleTapUp,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(),
onLongPressCancel: 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: 20,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0,
child: IgnorePointer(
ignoring: !widget.boosting,
child: _boostHint(),
),
),
),
Positioned(
left: 0,
right: 0,
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,
posterUrl: widget.drama.posterUrl,
onScrubStart: _startScrub,
onScrubEnd: _endScrub,
onSeek: widget.onSeek,
),
),
if (_heartBursts.isNotEmpty)
Positioned.fill(
child: IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
for (final burst in _heartBursts)
Positioned(
left: burst.position.dx,
top: burst.position.dy - 10,
child: PlayerDoubleTapHeartBurst(
key: ValueKey(burst.id),
onCompleted: () => _removeHeartBurst(burst.id),
),
),
],
),
),
),
],
),
);
}
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);
}
DateTime _now() => widget.now?.call() ?? DateTime.now();
void _handlePointerDown(PointerDownEvent event) {
final now = _now();
final previous = _lastDownAt;
_downSerial++;
_lastDownAt = now;
final repeated =
previous != null && now.difference(previous) <= _singleTapDelay;
if (!repeated) {
if (_singleTapBlocked(now)) return;
_doubleTapDown = false;
_likedThisPair = false;
return;
}
_doubleTapDown = true;
_blockSingleTapUntil = now.add(_singleTapDelay);
_cancelPendingSingleTap();
}
bool _singleTapBlocked(DateTime now) {
final blockedUntil = _blockSingleTapUntil;
return blockedUntil != null && now.isBefore(blockedUntil);
}
void _cancelPendingSingleTap() {
_tapSeq++;
_singleTapTimer?.cancel();
_singleTapTimer = null;
}
void _handleTapUp(TapUpDetails details) {
final now = _now();
if (_doubleTapDown || _singleTapBlocked(now)) {
_showDoubleTapLike(details.localPosition);
return;
}
final serial = _downSerial;
final tapSeq = ++_tapSeq;
_singleTapTimer?.cancel();
late final Timer pending;
pending = Timer(_singleTapDelay, () {
if (!identical(_singleTapTimer, pending)) return;
_singleTapTimer = null;
if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return;
if (_doubleTapDown || _singleTapBlocked(_now())) return;
_lastDownAt = null;
widget.onTogglePlay();
});
_singleTapTimer = pending;
}
void _showDoubleTapLike(Offset position) {
if (_likedThisPair) return;
_likedThisPair = true;
_blockSingleTapUntil = _now().add(_singleTapDelay);
_cancelPendingSingleTap();
_showHearts(position);
}
void _showHearts(Offset position) {
final id = _nextHeartBurstId++;
setState(() => _heartBursts.add((id: id, position: position)));
widget.onDoubleTapLike?.call(position);
}
void _removeHeartBurst(int id) {
if (!mounted) return;
setState(() => _heartBursts.removeWhere((burst) => burst.id == id));
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
final third = width / 3;
_tapSeq++;
_cancelPendingSingleTap();
_doubleTapDown = false;
_lastDownAt = null;
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,
onSave: widget.onSave,
onLike: widget.onLike,
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer,
);
}
Widget _bottomInfo() {
return HomeFeedInfoPanel(
drama: widget.drama,
episode: widget.episode,
onDetail: widget.onDetail,
onOpenFullPlayer: widget.onOpenFullPlayer,
);
}
}