401 lines
12 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|