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

423 lines
12 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.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/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
import 'loading_progress_track.dart';
import 'scrub_preview_card.dart';
import 'seek_progress_bar.dart';
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = Color(0x66000000);
const Color _fullBarBackground = Color(0x4D000000);
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
const double _fullBarHeight = 44;
const double _progressHeight = 2;
const double _progressHitHeight = 24;
const List<Shadow> _boostTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
];
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 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,
required this.onTogglePlay,
required this.onBoostStart,
required this.onBoostEnd,
required this.onMore,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.authorNote = '',
this.onScrubStart,
this.onSeek,
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 onTogglePlay;
final VoidCallback onBoostStart;
final VoidCallback onBoostEnd;
final VoidCallback onMore;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
@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(),
child: GestureDetector(
key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque,
onTap: widget.onTogglePlay,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(),
onLongPressCancel: widget.onBoostEnd,
),
),
),
),
if (widget.boosting)
Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()])
else
Column(
children: [
const Spacer(),
if (widget.visible) _bottomInfo(),
_fullSeriesBar(),
],
),
if (widget.visible && !widget.boosting)
Positioned(right: 10, bottom: 56, child: _rail()),
],
),
);
}
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 InteractionRail(
items: [
InteractionRailItem(
key: const Key('rail-save'),
asset: 'assets/icon/rail/rail_save.png',
label: widget.l10n.favorite,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
selected: widget.saved,
onTap: widget.onSave,
),
InteractionRailItem(
key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png',
label: widget.l10n.comments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
onTap: widget.onComments,
),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: widget.l10n.like,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
selected: widget.liked,
onTap: widget.onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
asset: 'assets/icon/rail/rail_share.png',
label: widget.l10n.share,
onTap: widget.onShare,
),
],
);
}
Widget _bottomInfo() {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 56, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_seriesMetaRow(),
const SizedBox(height: 2),
_titleRow(),
const SizedBox(height: 6),
_tagsRow(),
if (widget.authorNote.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
key: const Key('home-feed-author-note'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
],
),
);
}
Widget _seriesMetaRow() {
return GestureDetector(
key: const Key('home-feed-series-meta'),
onTap: widget.onDetail,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.l10n.seriesMeta(
'${widget.drama.episodeCount}',
formatInteractionCount(widget.drama.viewCount),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: _metaFontSize,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white70,
size: 16,
),
],
),
);
}
Widget _titleRow() {
return GestureDetector(
onTap: widget.onDetail,
child: Row(
children: [
Flexible(
child: Text(
localizedDramaTitle(widget.l10n, widget.drama),
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: Colors.white,
fontSize: _titleFontSize,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 20,
),
],
),
);
}
Widget _tagsRow() {
final tags = [
widget.l10n.episodeLabel(widget.episode.number),
..._tagValues(),
].take(_maxTags).toList();
return Wrap(
key: const Key('home-feed-tags'),
spacing: 6,
runSpacing: 4,
children: [
for (final tag in tags)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _overlayPill,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: _tagFontSize,
),
),
),
],
);
}
List<String> _tagValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result;
}
Widget _fullSeriesBar() {
return GestureDetector(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
child: SizedBox(
height: _fullBarHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(
key: const Key('home-feed-full-series-bg'),
color: _fullBarBackground,
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.l10n.watchFullSeries(
'${widget.drama.episodeCount}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 18,
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
),
),
],
),
),
);
}
}