423 lines
13 KiB
Dart
423 lines
13 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 = AppColors.overlaySoft;
|
|
const Color _fullBarBackground = AppColors.overlayBar;
|
|
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: AppColors.shadow, 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: AppColors.muted, 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: AppColors.muted,
|
|
fontSize: _metaFontSize,
|
|
),
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: AppColors.muted,
|
|
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: AppColors.onPrimary,
|
|
fontSize: _titleFontSize,
|
|
fontWeight: FontWeight.w800,
|
|
shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)],
|
|
),
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: AppColors.onPrimary,
|
|
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: AppColors.onPrimary,
|
|
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: AppColors.onPrimary,
|
|
size: 14,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
widget.l10n.watchFullSeries(
|
|
'${widget.drama.episodeCount}',
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onPrimary,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: AppColors.onPrimary,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|