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

+20
View File
@@ -29,6 +29,26 @@
"romance": "Romance",
"actionGenre": "Action",
"filter": "Filter",
"danmaku": "Danmaku",
"watchFullscreen": "Full screen",
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
"@seriesMeta": {
"placeholders": {
"episodes": {"type": "String"},
"viewers": {"type": "String"}
}
},
"watchFullSeries": "Watch full series · {episodes} eps",
"@watchFullSeries": {
"placeholders": {
"episodes": {"type": "String"}
}
},
"feedTabFollowing": "Following",
"feedTabAnime": "Anime",
"feedTabReal": "Live-action",
"feedTabForYou": "For you",
"authorNoteLabel": "Author note",
"reserve": "Reserve",
"heatLabel": "{count} heat",
"@heatLabel": {
+9
View File
@@ -29,6 +29,15 @@
"romance": "Romantis",
"actionGenre": "Aksi",
"filter": "Filter",
"danmaku": "Danmaku",
"watchFullscreen": "Layar penuh",
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
"watchFullSeries": "Tonton serial lengkap · {episodes} eps",
"feedTabFollowing": "Mengikuti",
"feedTabAnime": "Anime",
"feedTabReal": "Live-action",
"feedTabForYou": "Untukmu",
"authorNoteLabel": "Catatan penulis",
"reserve": "Reservasi",
"heatLabel": "{count} panas",
"topCharts": "Peringkat Teratas",
+54 -6
View File
@@ -274,6 +274,60 @@ abstract class AppLocalizations {
/// **'Filter'**
String get filter;
/// No description provided for @danmaku.
///
/// In en, this message translates to:
/// **'Danmaku'**
String get danmaku;
/// No description provided for @watchFullscreen.
///
/// In en, this message translates to:
/// **'Full screen'**
String get watchFullscreen;
/// No description provided for @seriesMeta.
///
/// In en, this message translates to:
/// **'Series · {episodes} eps | {viewers} watching'**
String seriesMeta(String episodes, String viewers);
/// No description provided for @watchFullSeries.
///
/// In en, this message translates to:
/// **'Watch full series · {episodes} eps'**
String watchFullSeries(String episodes);
/// No description provided for @feedTabFollowing.
///
/// In en, this message translates to:
/// **'Following'**
String get feedTabFollowing;
/// No description provided for @feedTabAnime.
///
/// In en, this message translates to:
/// **'Anime'**
String get feedTabAnime;
/// No description provided for @feedTabReal.
///
/// In en, this message translates to:
/// **'Live-action'**
String get feedTabReal;
/// No description provided for @feedTabForYou.
///
/// In en, this message translates to:
/// **'For you'**
String get feedTabForYou;
/// No description provided for @authorNoteLabel.
///
/// In en, this message translates to:
/// **'Author note'**
String get authorNoteLabel;
/// No description provided for @reserve.
///
/// In en, this message translates to:
@@ -2686,12 +2740,6 @@ abstract class AppLocalizations {
/// **'Clear-screen playback'**
String get clearPlayback;
/// No description provided for @danmaku.
///
/// In en, this message translates to:
/// **'Danmaku'**
String get danmaku;
/// No description provided for @danmakuUnavailable.
///
/// In en, this message translates to:
+31 -3
View File
@@ -98,6 +98,37 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get filter => 'Filter';
@override
String get danmaku => 'Danmaku';
@override
String get watchFullscreen => 'Full screen';
@override
String seriesMeta(String episodes, String viewers) {
return 'Series · $episodes eps | $viewers watching';
}
@override
String watchFullSeries(String episodes) {
return 'Watch full series · $episodes eps';
}
@override
String get feedTabFollowing => 'Following';
@override
String get feedTabAnime => 'Anime';
@override
String get feedTabReal => 'Live-action';
@override
String get feedTabForYou => 'For you';
@override
String get authorNoteLabel => 'Author note';
@override
String get reserve => 'Reserve';
@@ -1396,9 +1427,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get clearPlayback => 'Clear-screen playback';
@override
String get danmaku => 'Danmaku';
@override
String get danmakuUnavailable => 'Coming soon';
+31 -3
View File
@@ -98,6 +98,37 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get filter => 'Filter';
@override
String get danmaku => 'Komentar layar';
@override
String get watchFullscreen => 'Layar penuh';
@override
String seriesMeta(String episodes, String viewers) {
return 'Serial · $episodes eps | $viewers menonton';
}
@override
String watchFullSeries(String episodes) {
return 'Tonton serial lengkap · $episodes eps';
}
@override
String get feedTabFollowing => 'Mengikuti';
@override
String get feedTabAnime => 'Anime';
@override
String get feedTabReal => 'Live-action';
@override
String get feedTabForYou => 'Untukmu';
@override
String get authorNoteLabel => 'Catatan penulis';
@override
String get reserve => 'Reservasi';
@@ -1396,9 +1427,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get clearPlayback => 'Putar tanpa kontrol';
@override
String get danmaku => 'Komentar layar';
@override
String get danmakuUnavailable => 'Belum tersedia';
+31 -3
View File
@@ -95,6 +95,37 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get filter => '筛选';
@override
String get danmaku => '弹幕';
@override
String get watchFullscreen => '全屏观看';
@override
String seriesMeta(String episodes, String viewers) {
return '系列剧 · 共$episodes集 | 共$viewers人在追';
}
@override
String watchFullSeries(String episodes) {
return '观看完整剧集 · 全$episodes集';
}
@override
String get feedTabFollowing => '关注';
@override
String get feedTabAnime => '漫剧';
@override
String get feedTabReal => '真人剧';
@override
String get feedTabForYou => '推荐';
@override
String get authorNoteLabel => '作者声明';
@override
String get reserve => '预约';
@@ -1372,9 +1403,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get clearPlayback => '清屏播放';
@override
String get danmaku => '弹幕';
@override
String get danmakuUnavailable => '暂未开放';
+9
View File
@@ -29,6 +29,15 @@
"romance": "都市爱情",
"actionGenre": "动作",
"filter": "筛选",
"danmaku": "弹幕",
"watchFullscreen": "全屏观看",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
"feedTabFollowing": "关注",
"feedTabAnime": "漫剧",
"feedTabReal": "真人剧",
"feedTabForYou": "推荐",
"authorNoteLabel": "作者声明",
"reserve": "预约",
"heatLabel": "{count}热度",
"topCharts": "排行榜",
+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();
},
),
],
),
);
}
}