feat(flutter): 按参考图重做首页 feed 浮层

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:46:20 +08:00
1 parent d6b055dc62
commit ee6f0d7e72
14 files changed
+1062 -398

No files matched your search

+10 -1
View File
@@ -367,8 +367,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id),
likeCount:
interactions.likeCount(drama.id) ?? drama.likeCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
commentCount: drama.commentCount,
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
@@ -404,6 +410,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onEpisodes: () => logic.showEpisodes(context),
onDetail: () => unawaited(logic.openDetail(context)),
onFullscreen: () =>
unawaited(_openFullscreen(context, drama, episode)),
onDownload: () => requestEpisodeDownload(context, episode.id),
onClear: () => setState(() => _chromeVisible = false),
onSearch:
+19
View File
@@ -43,6 +43,25 @@ extension _PlayerViewActions on _PlayerPageState {
);
}
/// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。
Future<void> _openFullscreen(
BuildContext context,
Drama drama,
Episode episode,
) async {
final index = drama.episodes.indexWhere((item) => item.id == episode.id);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index < 0 ? 0 : index,
wallet: widget.wallet,
downloads: widget.downloads,
),
),
);
}
Future<void> _openUnlockSheet(
BuildContext context,
int index,
+361 -304
View File
@@ -1,422 +1,479 @@
// 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/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/glass_panel.dart';
import '../../../l10n/app_localizations.dart';
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
class HomeFeedOverlay extends StatelessWidget {
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = Color(0x66000000);
const Color _fullBarColor = Color(0xCC000000);
const double _topIconSize = 36;
const double _topIconGlyph = 22;
const double _pillHeight = 34;
const double _tabGap = 22;
const double _tabFontSize = 15;
const double _underlineWidth = 18;
const double _underlineHeight = 2;
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
const double _railIconSize = 22;
const double _fullBarHeight = 48;
const double _progressHeight = 2;
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
class HomeFeedOverlay extends StatefulWidget {
const HomeFeedOverlay({
super.key,
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
this.likeCount,
required this.saved,
required this.onLike,
required this.onSave,
required this.onComments,
required this.onShare,
required this.onEpisodes,
required this.onDetail,
required this.onFullscreen,
required this.onDownload,
required this.onClear,
required this.onSearch,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.authorNote = '',
this.onPip,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final ValueListenable<VideoPlayerValue>? controller;
final bool liked;
final int? likeCount;
final int? favoriteCount;
final int? commentCount;
final bool saved;
final String authorNote;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onEpisodes;
final VoidCallback onDetail;
final VoidCallback onFullscreen;
final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback onSearch;
final VoidCallback? onPip;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
_FeedTab _tab = _FeedTab.recommend;
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Column(
child: Stack(
children: [
_header(context),
Expanded(
child: Align(
alignment: const Alignment(1.0, 0.45),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
child: _rail(),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(child: _gradient(top: true)),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: _infoCard(),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(child: _gradient(top: false)),
),
Column(
children: [
_header(context),
Expanded(
child: Align(
alignment: const Alignment(1.0, 0.45),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 10, 16),
child: _rail(),
),
),
),
_bottomInfo(),
_fullSeriesBar(),
],
),
],
),
);
}
Widget _gradient({required bool top}) {
return Container(
height: top ? 140 : 360,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: top ? Alignment.topCenter : Alignment.bottomCenter,
end: top ? Alignment.bottomCenter : Alignment.topCenter,
colors: top
? const [Color(0xB3000000), Color(0x00000000)]
: const [Color(0xE6000000), Color(0x00000000)],
),
),
);
}
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(
children: [
_TopIconButton(
key: const Key('home-feed-menu'),
tooltip: widget.l10n.more,
icon: Icons.menu_rounded,
dimension: _topIconSize,
glyphSize: _topIconGlyph,
onPressed: () => _openMore(context),
),
Expanded(
child: Text(
localizedDramaTitle(l10n, drama),
key: const Key('home-feed-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
child: Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < _FeedTab.values.length; index++)
_tabSlot(_FeedTab.values[index], index),
],
),
),
),
),
_RoundIcon(
_TopIconButton(
key: const Key('home-feed-search'),
tooltip: l10n.search,
tooltip: widget.l10n.search,
icon: Icons.search_rounded,
onPressed: onSearch,
),
const SizedBox(width: 8),
_RoundIcon(
key: const Key('home-feed-clear-screen'),
tooltip: l10n.clearScreen,
icon: Icons.fullscreen_rounded,
onPressed: onClear,
),
const SizedBox(width: 8),
_RoundIcon(
key: const Key('home-feed-more'),
tooltip: l10n.more,
icon: Icons.menu_rounded,
onPressed: () => _openMore(context),
dimension: _topIconSize,
glyphSize: _topIconGlyph,
onPressed: widget.onSearch,
),
],
),
);
}
Widget _tabSlot(_FeedTab tab, int index) {
final item = _FeedTabItem(
tab: tab,
label: tab.label(widget.l10n),
selected: _tab == tab,
fontSize: _tabFontSize,
underlineWidth: _underlineWidth,
underlineHeight: _underlineHeight,
onTap: () => _selectTab(tab),
);
if (index == 0) return item;
return Padding(
padding: const EdgeInsets.only(left: _tabGap),
child: item,
);
}
void _selectTab(_FeedTab tab) {
if (tab.comingSoon) {
_showComingSoon();
return;
}
setState(() => _tab = tab);
}
Widget _rail() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_RailItem(
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: likeCount?.toString() ?? l10n.like,
countKey: likeCount == null ? null : const Key('rail-like-count'),
selected: liked,
onTap: onLike,
_FeedRailItem(
key: const Key('rail-save'),
icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
label: widget.l10n.favorite,
selected: widget.saved,
iconSize: _railIconSize,
onTap: widget.onSave,
),
_RailItem(
_FeedRailItem(
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
label: widget.l10n.comments,
iconSize: _railIconSize,
onTap: widget.onComments,
),
_RailItem(
_FeedRailItem(
key: const Key('rail-like'),
icon: widget.liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
label: widget.l10n.like,
selected: widget.liked,
iconSize: _railIconSize,
onTap: widget.onLike,
),
_FeedRailItem(
key: const Key('rail-share'),
icon: Icons.ios_share_rounded,
label: l10n.share,
onTap: onShare,
),
_RailItem(
key: const Key('rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: l10n.favorite,
selected: saved,
onTap: onSave,
label: widget.l10n.share,
iconSize: _railIconSize,
onTap: widget.onShare,
),
],
);
}
Widget _infoCard() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
return GlassPanel(
key: const Key('home-feed-info-card'),
radius: 14,
blur: 6,
padding: const EdgeInsets.all(12),
borderColor: AppColors.border,
Widget _bottomInfo() {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
Row(
children: [
if (genre.isNotEmpty)
_tag(
genre,
background: AppColors.blue,
foreground: AppColors.onPrimary,
Flexible(
child: _DanmakuPill(
l10n: widget.l10n,
height: _pillHeight,
background: _overlayPill,
onTap: _showComingSoon,
),
if (drama.category.isNotEmpty && drama.category != genre)
_tag(
drama.category,
background: AppColors.raised,
foreground: AppColors.onSurface,
),
const SizedBox(width: 12),
Flexible(
child: _FullscreenPill(
l10n: widget.l10n,
height: _pillHeight,
background: _overlayPill,
onTap: widget.onFullscreen,
),
Text(
l10n.episodeOfTotal(episode.number, drama.episodeCount),
key: const Key('home-feed-episode'),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
],
),
const SizedBox(height: 8),
Text(
episode.title.trim().isEmpty
? localizedDramaTitle(l10n, drama)
: episode.title,
key: const Key('home-feed-episode-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
_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 SizedBox(height: 10),
Row(
children: [
Expanded(
child: SizedBox(
height: 34,
child: FilledButton.icon(
key: const Key('home-feed-follow-series'),
onPressed: onSave,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 16),
label: Text(
saved ? l10n.followingTab : l10n.followSeries,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 34,
child: FilledButton(
key: const Key('home-feed-episodes'),
onPressed: onEpisodes,
style: FilledButton.styleFrom(
backgroundColor: AppColors.raised,
foregroundColor: AppColors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
padding: EdgeInsets.zero,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
child: Text(
l10n.episodes,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
const Icon(
Icons.chevron_right_rounded,
color: Colors.white70,
size: 16,
),
],
),
);
}
Widget _tag(
String label, {
required Color background,
required Color foreground,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(6),
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,
),
],
),
child: Text(
label,
maxLines: 1,
style: TextStyle(
color: foreground,
fontSize: 8,
fontWeight: FontWeight.w800,
);
}
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.onEpisodes,
child: Container(
height: _fullBarHeight,
color: _fullBarColor,
child: Stack(
children: [
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: _FeedProgressTrack(
controller: widget.controller,
height: _progressHeight,
),
),
],
),
),
);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('home-feed-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
onDownload();
},
),
if (onPip != null)
ListTile(
key: const Key('home-feed-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
onPip!();
},
),
],
),
),
);
}
}
class _RoundIcon extends StatelessWidget {
const _RoundIcon({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: AppColors.card.withValues(alpha: 0.94),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}
class _RailItem extends StatelessWidget {
const _RailItem({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
this.countKey,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
final Key? countKey;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Semantics(
button: true,
label: label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(22),
child: SizedBox(
width: 44,
child: Column(
children: [
Icon(
icon,
size: 22,
color: selected ? AppColors.blue : AppColors.onSurface,
),
const SizedBox(height: 2),
Text(
label,
key: countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 9,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
),
),
),
builder: (sheetContext) => _FeedMoreSheet(
l10n: widget.l10n,
onDownload: widget.onDownload,
onPip: widget.onPip,
onClear: widget.onClear,
),
);
}
@@ -0,0 +1,393 @@
// Author: fruit.com.cn
part of 'home_feed_overlay.dart';
/// 顶部频道 tab:关注/漫剧点击提示即将上线。
enum _FeedTab {
following,
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.following || this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.following => 'follow',
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.following => l10n.feedTabFollowing,
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
};
}
class _TopIconButton extends StatelessWidget {
const _TopIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.dimension,
required this.glyphSize,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final double dimension;
final double glyphSize;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
icon: Icon(
icon,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
);
}
}
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.label,
required this.selected,
required this.fontSize,
required this.underlineWidth,
required this.underlineHeight,
required this.onTap,
});
final _FeedTab tab;
final String label;
final bool selected;
final double fontSize;
final double underlineWidth;
final double underlineHeight;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : Colors.white70,
fontSize: fontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
const SizedBox(height: 4),
Container(
width: underlineWidth,
height: underlineHeight,
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
],
),
);
}
}
class _FeedRailItem extends StatelessWidget {
const _FeedRailItem({
super.key,
required this.icon,
required this.label,
required this.iconSize,
required this.onTap,
this.count,
this.countKey,
this.selected = false,
});
final IconData icon;
final String label;
final double iconSize;
final VoidCallback onTap;
final int? count;
final Key? countKey;
final bool selected;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Semantics(
button: true,
label: label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 48,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: selected ? AppColors.blue : Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
const SizedBox(height: 2),
Text(
count == null ? label : formatInteractionCount(count!),
key: countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
),
),
),
),
);
}
}
class _FeedPill extends StatelessWidget {
const _FeedPill({
required this.height,
required this.background,
required this.child,
});
final double height;
final Color background;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
),
child: Center(child: child),
);
}
}
class _DanmakuPill extends StatelessWidget {
const _DanmakuPill({
required this.l10n,
required this.height,
required this.background,
required this.onTap,
});
final AppLocalizations l10n;
final double height;
final Color background;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-danmaku'),
onTap: onTap,
child: _FeedPill(
height: height,
background: background,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'弹',
style: TextStyle(color: Colors.white, fontSize: 12),
),
const SizedBox(width: 6),
Flexible(
child: Text(
l10n.danmaku,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
),
);
}
}
class _FullscreenPill extends StatelessWidget {
const _FullscreenPill({
required this.l10n,
required this.height,
required this.background,
required this.onTap,
});
final AppLocalizations l10n;
final double height;
final Color background;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-fullscreen'),
onTap: onTap,
child: _FeedPill(
height: height,
background: background,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fullscreen_rounded, color: Colors.white, size: 14),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.watchFullscreen,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
],
),
),
);
}
}
class _FeedProgressTrack extends StatelessWidget {
const _FeedProgressTrack({required this.controller, required this.height});
final ValueListenable<VideoPlayerValue>? controller;
final double height;
@override
Widget build(BuildContext context) {
final source = controller;
if (source == null) return _track(0);
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) {
final total = value.duration.inMilliseconds;
final fraction = total <= 0
? 0.0
: (value.position.inMilliseconds / total).clamp(0.0, 1.0);
return _track(fraction.toDouble());
},
);
}
Widget _track(double fraction) {
return LayoutBuilder(
builder: (context, constraints) => SizedBox(
height: height,
child: Stack(
children: [
Container(
width: constraints.maxWidth,
height: height,
color: Colors.white24,
),
Container(
width: constraints.maxWidth * fraction,
height: height,
color: AppColors.blue,
),
],
),
),
);
}
}
class _FeedMoreSheet extends StatelessWidget {
const _FeedMoreSheet({
required this.l10n,
required this.onDownload,
required this.onClear,
this.onPip,
});
final AppLocalizations l10n;
final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback? onPip;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('home-feed-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(context);
onDownload();
},
),
if (onPip != null)
ListTile(
key: const Key('home-feed-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(context);
onPip!();
},
),
ListTile(
key: const Key('home-feed-clear'),
leading: const Icon(
Icons.fullscreen_rounded,
color: AppColors.secondary,
),
title: Text(l10n.clearScreen),
onTap: () {
Navigator.pop(context);
onClear();
},
),
],
),
);
}
}