fix(flutter): 精修首页 feed 交互与底色
This commit is contained in:
1 parent
435fbbbbb1
commit
321c62664c
17 files changed
+427
-448
No files matched your search
@@ -29,8 +29,6 @@
|
|||||||
"romance": "Romance",
|
"romance": "Romance",
|
||||||
"actionGenre": "Action",
|
"actionGenre": "Action",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"danmaku": "Danmaku",
|
|
||||||
"watchFullscreen": "Full screen",
|
|
||||||
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
|
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
|
||||||
"@seriesMeta": {
|
"@seriesMeta": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
"romance": "Romantis",
|
"romance": "Romantis",
|
||||||
"actionGenre": "Aksi",
|
"actionGenre": "Aksi",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"danmaku": "Danmaku",
|
|
||||||
"watchFullscreen": "Layar penuh",
|
|
||||||
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
|
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
|
||||||
"watchFullSeries": "Tonton serial lengkap · {episodes} eps",
|
"watchFullSeries": "Tonton serial lengkap · {episodes} eps",
|
||||||
"feedTabAnime": "Anime",
|
"feedTabAnime": "Anime",
|
||||||
|
|||||||
@@ -274,18 +274,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Filter'**
|
/// **'Filter'**
|
||||||
String get 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.
|
/// No description provided for @seriesMeta.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2734,6 +2722,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Clear-screen playback'**
|
/// **'Clear-screen playback'**
|
||||||
String get clearPlayback;
|
String get clearPlayback;
|
||||||
|
|
||||||
|
/// No description provided for @danmaku.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Danmaku'**
|
||||||
|
String get danmaku;
|
||||||
|
|
||||||
/// No description provided for @danmakuUnavailable.
|
/// No description provided for @danmakuUnavailable.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -98,12 +98,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => 'Filter';
|
String get filter => 'Filter';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => 'Danmaku';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => 'Full screen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String seriesMeta(String episodes, String viewers) {
|
String seriesMeta(String episodes, String viewers) {
|
||||||
return 'Series · $episodes eps | $viewers watching';
|
return 'Series · $episodes eps | $viewers watching';
|
||||||
@@ -1424,6 +1418,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => 'Clear-screen playback';
|
String get clearPlayback => 'Clear-screen playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get danmaku => 'Danmaku';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => 'Coming soon';
|
String get danmakuUnavailable => 'Coming soon';
|
||||||
|
|
||||||
|
|||||||
@@ -98,12 +98,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => 'Filter';
|
String get filter => 'Filter';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => 'Komentar layar';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => 'Layar penuh';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String seriesMeta(String episodes, String viewers) {
|
String seriesMeta(String episodes, String viewers) {
|
||||||
return 'Serial · $episodes eps | $viewers menonton';
|
return 'Serial · $episodes eps | $viewers menonton';
|
||||||
@@ -1424,6 +1418,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => 'Putar tanpa kontrol';
|
String get clearPlayback => 'Putar tanpa kontrol';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get danmaku => 'Komentar layar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => 'Belum tersedia';
|
String get danmakuUnavailable => 'Belum tersedia';
|
||||||
|
|
||||||
|
|||||||
@@ -95,12 +95,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => '筛选';
|
String get filter => '筛选';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => '弹幕';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get watchFullscreen => '全屏观看';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String seriesMeta(String episodes, String viewers) {
|
String seriesMeta(String episodes, String viewers) {
|
||||||
return '系列剧 · 共$episodes集 | 共$viewers人在追';
|
return '系列剧 · 共$episodes集 | 共$viewers人在追';
|
||||||
@@ -1400,6 +1394,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => '清屏播放';
|
String get clearPlayback => '清屏播放';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get danmaku => '弹幕';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => '暂未开放';
|
String get danmakuUnavailable => '暂未开放';
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
"romance": "都市爱情",
|
"romance": "都市爱情",
|
||||||
"actionGenre": "动作",
|
"actionGenre": "动作",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"danmaku": "弹幕",
|
|
||||||
"watchFullscreen": "全屏观看",
|
|
||||||
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
||||||
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
||||||
"feedTabAnime": "漫剧",
|
"feedTabAnime": "漫剧",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class HomePage extends StatelessWidget {
|
|||||||
return _HomeTabs(
|
return _HomeTabs(
|
||||||
key: ValueKey(catalog.dramas.first.id),
|
key: ValueKey(catalog.dramas.first.id),
|
||||||
dramas: catalog.dramas,
|
dramas: catalog.dramas,
|
||||||
|
onRefreshFeed: catalog.load,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +98,14 @@ class HomeFailureView extends StatelessWidget {
|
|||||||
|
|
||||||
/// 四个根页面共享同一路由并保持各自状态。
|
/// 四个根页面共享同一路由并保持各自状态。
|
||||||
class _HomeTabs extends StatefulWidget {
|
class _HomeTabs extends StatefulWidget {
|
||||||
const _HomeTabs({super.key, required this.dramas});
|
const _HomeTabs({
|
||||||
|
super.key,
|
||||||
|
required this.dramas,
|
||||||
|
required this.onRefreshFeed,
|
||||||
|
});
|
||||||
|
|
||||||
final List<Drama> dramas;
|
final List<Drama> dramas;
|
||||||
|
final VoidCallback onRefreshFeed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_HomeTabs> createState() => _HomeTabsState();
|
State<_HomeTabs> createState() => _HomeTabsState();
|
||||||
@@ -138,7 +144,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: logic.scaffoldKey,
|
key: logic.scaffoldKey,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent,
|
||||||
body: _body(context),
|
body: _body(context),
|
||||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
||||||
),
|
),
|
||||||
@@ -165,6 +171,9 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
drama: widget.dramas.first,
|
drama: widget.dramas.first,
|
||||||
initialIndex: 0,
|
initialIndex: 0,
|
||||||
feedDramas: widget.dramas,
|
feedDramas: widget.dramas,
|
||||||
|
onOpenFullPlayer: (drama, episode) =>
|
||||||
|
logic.playDrama(context, drama, episodeId: episode.id),
|
||||||
|
onRefreshFeed: widget.onRefreshFeed,
|
||||||
onDramaChanged: logic.onDramaChanged,
|
onDramaChanged: logic.onDramaChanged,
|
||||||
onImmersiveChanged: logic.onImmersiveChanged,
|
onImmersiveChanged: logic.onImmersiveChanged,
|
||||||
onSearch: () => logic.select(1, search: true),
|
onSearch: () => logic.select(1, search: true),
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class PlayerPage extends StatefulWidget {
|
|||||||
this.feedDramas,
|
this.feedDramas,
|
||||||
this.onDramaChanged,
|
this.onDramaChanged,
|
||||||
this.onImmersiveChanged,
|
this.onImmersiveChanged,
|
||||||
|
this.onOpenFullPlayer,
|
||||||
|
this.onRefreshFeed,
|
||||||
this.wallet,
|
this.wallet,
|
||||||
this.downloads,
|
this.downloads,
|
||||||
});
|
});
|
||||||
@@ -73,6 +75,8 @@ class PlayerPage extends StatefulWidget {
|
|||||||
final List<Drama>? feedDramas;
|
final List<Drama>? feedDramas;
|
||||||
final ValueChanged<Drama>? onDramaChanged;
|
final ValueChanged<Drama>? onDramaChanged;
|
||||||
final ValueChanged<bool>? onImmersiveChanged;
|
final ValueChanged<bool>? onImmersiveChanged;
|
||||||
|
final void Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
||||||
|
final VoidCallback? onRefreshFeed;
|
||||||
final WalletController? wallet;
|
final WalletController? wallet;
|
||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
|
|
||||||
@@ -411,12 +415,20 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onEpisodes: () => logic.showEpisodes(context),
|
onEpisodes: () => logic.showEpisodes(context),
|
||||||
onDetail: () => unawaited(logic.openDetail(context)),
|
onDetail: () => unawaited(logic.openDetail(context)),
|
||||||
onFullscreen: () =>
|
onOpenFullPlayer: () =>
|
||||||
unawaited(_openFullscreen(context, drama, episode)),
|
widget.onOpenFullPlayer?.call(drama, episode),
|
||||||
|
onRefreshFeed: () => widget.onRefreshFeed?.call(),
|
||||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onSearch:
|
onSearch:
|
||||||
widget.onSearch ?? () => logic.showDiscover(context),
|
widget.onSearch ?? () => logic.showDiscover(context),
|
||||||
|
onScrubStart: ready
|
||||||
|
? () => logic.beginScrub(videoPort!)
|
||||||
|
: null,
|
||||||
|
onSeek: ready
|
||||||
|
? (position) =>
|
||||||
|
logic.seekTo(context, videoPort!, position)
|
||||||
|
: null,
|
||||||
onPip: logic.pictureInPicture.isPlatformSupported
|
onPip: logic.pictureInPicture.isPlatformSupported
|
||||||
? () => unawaited(logic.enterPip(context))
|
? () => unawaited(logic.enterPip(context))
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -43,25 +43,6 @@ 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(
|
Future<void> _openUnlockSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int index,
|
int index,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../core/formatting/count_format.dart';
|
|||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
import 'interaction_rail.dart';
|
||||||
|
|
||||||
part 'home_feed_overlay_parts.dart';
|
part 'home_feed_overlay_parts.dart';
|
||||||
|
|
||||||
@@ -16,7 +17,6 @@ const Color _overlayPill = Color(0x66000000);
|
|||||||
const Color _fullBarColor = Color(0xCC000000);
|
const Color _fullBarColor = Color(0xCC000000);
|
||||||
const double _topIconSize = 36;
|
const double _topIconSize = 36;
|
||||||
const double _topIconGlyph = 22;
|
const double _topIconGlyph = 22;
|
||||||
const double _pillHeight = 34;
|
|
||||||
const double _tabGap = 22;
|
const double _tabGap = 22;
|
||||||
const double _tabFontSize = 15;
|
const double _tabFontSize = 15;
|
||||||
const double _underlineWidth = 18;
|
const double _underlineWidth = 18;
|
||||||
@@ -24,9 +24,9 @@ const double _underlineHeight = 2;
|
|||||||
const double _titleFontSize = 17;
|
const double _titleFontSize = 17;
|
||||||
const double _metaFontSize = 12;
|
const double _metaFontSize = 12;
|
||||||
const double _tagFontSize = 11;
|
const double _tagFontSize = 11;
|
||||||
const double _railIconSize = 22;
|
|
||||||
const double _fullBarHeight = 48;
|
const double _fullBarHeight = 48;
|
||||||
const double _progressHeight = 2;
|
const double _progressHeight = 2;
|
||||||
|
const double _progressHitHeight = 20;
|
||||||
const int _maxTags = 4;
|
const int _maxTags = 4;
|
||||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||||
|
|
||||||
@@ -46,10 +46,13 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onEpisodes,
|
required this.onEpisodes,
|
||||||
required this.onDetail,
|
required this.onDetail,
|
||||||
required this.onFullscreen,
|
required this.onOpenFullPlayer,
|
||||||
|
required this.onRefreshFeed,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
|
this.onScrubStart,
|
||||||
|
this.onSeek,
|
||||||
this.likeCount,
|
this.likeCount,
|
||||||
this.favoriteCount,
|
this.favoriteCount,
|
||||||
this.commentCount,
|
this.commentCount,
|
||||||
@@ -73,11 +76,14 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onShare;
|
final VoidCallback onShare;
|
||||||
final VoidCallback onEpisodes;
|
final VoidCallback onEpisodes;
|
||||||
final VoidCallback onDetail;
|
final VoidCallback onDetail;
|
||||||
final VoidCallback onFullscreen;
|
final VoidCallback onOpenFullPlayer;
|
||||||
|
final VoidCallback onRefreshFeed;
|
||||||
final VoidCallback onDownload;
|
final VoidCallback onDownload;
|
||||||
final VoidCallback onClear;
|
final VoidCallback onClear;
|
||||||
final VoidCallback onSearch;
|
final VoidCallback onSearch;
|
||||||
final VoidCallback? onPip;
|
final VoidCallback? onPip;
|
||||||
|
final VoidCallback? onScrubStart;
|
||||||
|
final ValueChanged<Duration>? onSeek;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||||
@@ -145,13 +151,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_TopIconButton(
|
_RefreshButton(
|
||||||
key: const Key('home-feed-menu'),
|
tooltip: widget.l10n.retry,
|
||||||
tooltip: widget.l10n.more,
|
|
||||||
icon: Icons.menu_rounded,
|
|
||||||
dimension: _topIconSize,
|
dimension: _topIconSize,
|
||||||
glyphSize: _topIconGlyph,
|
glyphSize: _topIconGlyph,
|
||||||
onPressed: () => _openMore(context),
|
onTap: widget.onRefreshFeed,
|
||||||
|
onLongPress: () => _openMore(context),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -205,45 +210,42 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _rail() {
|
Widget _rail() {
|
||||||
return Column(
|
return InteractionRail(
|
||||||
mainAxisSize: MainAxisSize.min,
|
items: [
|
||||||
children: [
|
InteractionRailItem(
|
||||||
_FeedRailItem(
|
|
||||||
key: const Key('rail-save'),
|
key: const Key('rail-save'),
|
||||||
icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
icon: Icons.star_border_rounded,
|
||||||
|
activeIcon: Icons.star_rounded,
|
||||||
|
label: widget.l10n.favorite,
|
||||||
count: widget.favoriteCount,
|
count: widget.favoriteCount,
|
||||||
countKey: const Key('rail-save-count'),
|
countKey: const Key('rail-save-count'),
|
||||||
label: widget.l10n.favorite,
|
|
||||||
selected: widget.saved,
|
selected: widget.saved,
|
||||||
iconSize: _railIconSize,
|
|
||||||
onTap: widget.onSave,
|
onTap: widget.onSave,
|
||||||
),
|
),
|
||||||
_FeedRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('rail-comments'),
|
key: const Key('rail-comments'),
|
||||||
icon: Icons.chat_bubble_outline_rounded,
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
|
activeIcon: Icons.chat_bubble_rounded,
|
||||||
|
label: widget.l10n.comments,
|
||||||
count: widget.commentCount,
|
count: widget.commentCount,
|
||||||
countKey: const Key('rail-comments-count'),
|
countKey: const Key('rail-comments-count'),
|
||||||
label: widget.l10n.comments,
|
|
||||||
iconSize: _railIconSize,
|
|
||||||
onTap: widget.onComments,
|
onTap: widget.onComments,
|
||||||
),
|
),
|
||||||
_FeedRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('rail-like'),
|
key: const Key('rail-like'),
|
||||||
icon: widget.liked
|
icon: Icons.favorite_border_rounded,
|
||||||
? Icons.favorite_rounded
|
activeIcon: Icons.favorite_rounded,
|
||||||
: Icons.favorite_border_rounded,
|
label: widget.l10n.like,
|
||||||
count: widget.likeCount,
|
count: widget.likeCount,
|
||||||
countKey: const Key('rail-like-count'),
|
countKey: const Key('rail-like-count'),
|
||||||
label: widget.l10n.like,
|
|
||||||
selected: widget.liked,
|
selected: widget.liked,
|
||||||
iconSize: _railIconSize,
|
|
||||||
onTap: widget.onLike,
|
onTap: widget.onLike,
|
||||||
),
|
),
|
||||||
_FeedRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('rail-share'),
|
key: const Key('rail-share'),
|
||||||
icon: Icons.ios_share_rounded,
|
icon: Icons.ios_share_rounded,
|
||||||
|
activeIcon: Icons.ios_share_rounded,
|
||||||
label: widget.l10n.share,
|
label: widget.l10n.share,
|
||||||
iconSize: _railIconSize,
|
|
||||||
onTap: widget.onShare,
|
onTap: widget.onShare,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -257,28 +259,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: _DanmakuPill(
|
|
||||||
l10n: widget.l10n,
|
|
||||||
height: _pillHeight,
|
|
||||||
background: _overlayPill,
|
|
||||||
onTap: _showComingSoon,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Flexible(
|
|
||||||
child: _FullscreenPill(
|
|
||||||
l10n: widget.l10n,
|
|
||||||
height: _pillHeight,
|
|
||||||
background: _overlayPill,
|
|
||||||
onTap: widget.onFullscreen,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_seriesMetaRow(),
|
_seriesMetaRow(),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
_titleRow(),
|
_titleRow(),
|
||||||
@@ -405,7 +385,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
Widget _fullSeriesBar() {
|
Widget _fullSeriesBar() {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: const Key('home-feed-full-series'),
|
key: const Key('home-feed-full-series'),
|
||||||
onTap: widget.onEpisodes,
|
onTap: widget.onOpenFullPlayer,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: _fullBarHeight,
|
height: _fullBarHeight,
|
||||||
color: _fullBarColor,
|
color: _fullBarColor,
|
||||||
@@ -447,9 +427,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: _FeedProgressTrack(
|
child: _FeedProgressBar(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
height: _progressHeight,
|
height: _progressHeight,
|
||||||
|
hitHeight: _progressHitHeight,
|
||||||
|
onScrubStart: widget.onScrubStart,
|
||||||
|
onSeek: widget.onSeek,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -58,6 +58,45 @@ class _TopIconButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。
|
||||||
|
class _RefreshButton extends StatelessWidget {
|
||||||
|
const _RefreshButton({
|
||||||
|
required this.tooltip,
|
||||||
|
required this.dimension,
|
||||||
|
required this.glyphSize,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
final double dimension;
|
||||||
|
final double glyphSize;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const Key('home-feed-refresh'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: dimension,
|
||||||
|
child: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
size: glyphSize,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FeedTabItem extends StatelessWidget {
|
class _FeedTabItem extends StatelessWidget {
|
||||||
const _FeedTabItem({
|
const _FeedTabItem({
|
||||||
required this.tab,
|
required this.tab,
|
||||||
@@ -111,217 +150,100 @@ class _FeedTabItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FeedRailItem extends StatelessWidget {
|
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
||||||
const _FeedRailItem({
|
class _FeedProgressBar extends StatefulWidget {
|
||||||
super.key,
|
const _FeedProgressBar({
|
||||||
required this.icon,
|
required this.controller,
|
||||||
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.height,
|
||||||
required this.background,
|
required this.hitHeight,
|
||||||
required this.child,
|
this.onScrubStart,
|
||||||
|
this.onSeek,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 ValueListenable<VideoPlayerValue>? controller;
|
||||||
final double height;
|
final double height;
|
||||||
|
final double hitHeight;
|
||||||
|
final VoidCallback? onScrubStart;
|
||||||
|
final ValueChanged<Duration>? onSeek;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||||
|
double? _preview;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final source = controller;
|
return LayoutBuilder(
|
||||||
if (source == null) return _track(0);
|
builder: (context, constraints) {
|
||||||
return ValueListenableBuilder<VideoPlayerValue>(
|
final width = constraints.maxWidth;
|
||||||
valueListenable: source,
|
final source = widget.controller;
|
||||||
builder: (context, value, _) {
|
final track = source == null
|
||||||
final total = value.duration.inMilliseconds;
|
? _track(0, width)
|
||||||
final fraction = total <= 0
|
: ValueListenableBuilder<VideoPlayerValue>(
|
||||||
? 0.0
|
valueListenable: source,
|
||||||
: (value.position.inMilliseconds / total).clamp(0.0, 1.0);
|
builder: (context, value, _) {
|
||||||
return _track(fraction.toDouble());
|
final total = value.duration.inMilliseconds;
|
||||||
|
final played = total <= 0
|
||||||
|
? 0.0
|
||||||
|
: (value.position.inMilliseconds / total)
|
||||||
|
.clamp(0.0, 1.0)
|
||||||
|
.toDouble();
|
||||||
|
return _track(_preview ?? played, width);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return GestureDetector(
|
||||||
|
key: const Key('home-feed-progress'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (details) {
|
||||||
|
widget.onScrubStart?.call();
|
||||||
|
_commit(details.localPosition.dx / width);
|
||||||
|
},
|
||||||
|
onHorizontalDragStart: (details) {
|
||||||
|
widget.onScrubStart?.call();
|
||||||
|
_update(details.localPosition.dx / width);
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) =>
|
||||||
|
_update(details.localPosition.dx / width),
|
||||||
|
onHorizontalDragEnd: (_) => _commit(_preview),
|
||||||
|
onHorizontalDragCancel: () => setState(() => _preview = null),
|
||||||
|
child: SizedBox(
|
||||||
|
height: widget.hitHeight,
|
||||||
|
child: Align(alignment: Alignment.bottomCenter, child: track),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _track(double fraction) {
|
void _update(double fraction) {
|
||||||
return LayoutBuilder(
|
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
||||||
builder: (context, constraints) => SizedBox(
|
}
|
||||||
height: height,
|
|
||||||
child: Stack(
|
void _commit(double? fraction) {
|
||||||
children: [
|
if (fraction == null) return;
|
||||||
Container(
|
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
||||||
width: constraints.maxWidth,
|
if (total > 0) {
|
||||||
height: height,
|
widget.onSeek?.call(
|
||||||
color: Colors.white24,
|
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||||
),
|
);
|
||||||
Container(
|
}
|
||||||
width: constraints.maxWidth * fraction,
|
setState(() => _preview = null);
|
||||||
height: height,
|
}
|
||||||
color: AppColors.blue,
|
|
||||||
),
|
Widget _track(double fraction, double width) {
|
||||||
],
|
return SizedBox(
|
||||||
),
|
height: widget.height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(width: width, height: widget.height, color: Colors.white24),
|
||||||
|
Container(
|
||||||
|
width: width * fraction,
|
||||||
|
height: widget.height,
|
||||||
|
color: AppColors.blue,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/formatting/count_format.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
const double _buttonSize = 34;
|
||||||
|
const double _iconSize = 24;
|
||||||
|
const double _itemGap = 18;
|
||||||
|
const Color _buttonBackground = Color(0x80000000);
|
||||||
|
|
||||||
|
/// 互动栏单项:图标、计数与点击。
|
||||||
|
class InteractionRailItem {
|
||||||
|
const InteractionRailItem({
|
||||||
|
required this.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.activeIcon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.count,
|
||||||
|
this.countKey,
|
||||||
|
this.selected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key key;
|
||||||
|
final IconData icon;
|
||||||
|
final IconData activeIcon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final int? count;
|
||||||
|
final Key? countKey;
|
||||||
|
final bool selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
||||||
|
class InteractionRail extends StatelessWidget {
|
||||||
|
const InteractionRail({super.key, required this.items});
|
||||||
|
|
||||||
|
final List<InteractionRailItem> items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < items.length; index++) ...[
|
||||||
|
if (index > 0) const SizedBox(height: _itemGap),
|
||||||
|
_RailButton(item: items[index]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RailButton extends StatelessWidget {
|
||||||
|
const _RailButton({required this.item});
|
||||||
|
|
||||||
|
final InteractionRailItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
key: item.key,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: _buttonSize,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: item.onTap,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: _buttonBackground,
|
||||||
|
foregroundColor: item.selected ? AppColors.blue : Colors.white,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
item.selected ? item.activeIcon : item.icon,
|
||||||
|
size: _iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||||
|
key: item.countKey,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/formatting/count_format.dart';
|
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import 'player_center_toggle.dart';
|
import 'player_center_toggle.dart';
|
||||||
|
import 'interaction_rail.dart';
|
||||||
import 'player_overlay_models.dart';
|
import 'player_overlay_models.dart';
|
||||||
import 'seek_progress_bar.dart';
|
import 'seek_progress_bar.dart';
|
||||||
|
|
||||||
@@ -14,8 +14,6 @@ part 'player_overlay_parts.dart';
|
|||||||
|
|
||||||
const double _topButtonSize = 36;
|
const double _topButtonSize = 36;
|
||||||
const double _topBarHeight = 58;
|
const double _topBarHeight = 58;
|
||||||
const double _railButtonSize = 34;
|
|
||||||
const double _railGap = 18;
|
|
||||||
const double _boostHintIconSize = 16;
|
const double _boostHintIconSize = 16;
|
||||||
const double _boostHintFontSize = 13;
|
const double _boostHintFontSize = 13;
|
||||||
const List<Shadow> _playerTextShadows = [
|
const List<Shadow> _playerTextShadows = [
|
||||||
@@ -144,17 +142,46 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
return Positioned(
|
return Positioned(
|
||||||
right: AppDimens.spaceMd,
|
right: AppDimens.spaceMd,
|
||||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55),
|
top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55),
|
||||||
child: _Rail(
|
child: InteractionRail(
|
||||||
l10n: data.l10n,
|
key: const Key('player-interaction-rail'),
|
||||||
liked: data.liked,
|
items: [
|
||||||
saved: data.saved,
|
InteractionRailItem(
|
||||||
likeCount: data.likeCount,
|
key: const Key('player-rail-save'),
|
||||||
favoriteCount: data.favoriteCount,
|
icon: Icons.star_border_rounded,
|
||||||
commentCount: data.commentCount,
|
activeIcon: Icons.star_rounded,
|
||||||
onLike: actions.onLike,
|
label: data.l10n.favorite,
|
||||||
onComments: actions.onComments,
|
count: data.favoriteCount,
|
||||||
onSave: actions.onSave,
|
countKey: const Key('player-rail-save-count'),
|
||||||
onShare: actions.onShare,
|
selected: data.saved,
|
||||||
|
onTap: actions.onSave,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-comments'),
|
||||||
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
|
activeIcon: Icons.chat_bubble_rounded,
|
||||||
|
label: data.l10n.comments,
|
||||||
|
count: data.commentCount,
|
||||||
|
countKey: const Key('player-rail-comments-count'),
|
||||||
|
onTap: actions.onComments,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-like'),
|
||||||
|
icon: Icons.favorite_border_rounded,
|
||||||
|
activeIcon: Icons.favorite_rounded,
|
||||||
|
label: data.l10n.like,
|
||||||
|
count: data.likeCount,
|
||||||
|
countKey: const Key('player-rail-like-count'),
|
||||||
|
selected: data.liked,
|
||||||
|
onTap: actions.onLike,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-share'),
|
||||||
|
icon: Icons.ios_share_rounded,
|
||||||
|
activeIcon: Icons.ios_share_rounded,
|
||||||
|
label: data.l10n.share,
|
||||||
|
onTap: actions.onShare,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,121 +105,6 @@ class _BottomIconButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Rail extends StatelessWidget {
|
|
||||||
const _Rail({
|
|
||||||
required this.l10n,
|
|
||||||
required this.liked,
|
|
||||||
required this.saved,
|
|
||||||
required this.likeCount,
|
|
||||||
required this.favoriteCount,
|
|
||||||
required this.commentCount,
|
|
||||||
required this.onLike,
|
|
||||||
required this.onComments,
|
|
||||||
required this.onSave,
|
|
||||||
required this.onShare,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
|
||||||
final bool liked;
|
|
||||||
final bool saved;
|
|
||||||
final int? likeCount;
|
|
||||||
final int? favoriteCount;
|
|
||||||
final int? commentCount;
|
|
||||||
final VoidCallback onLike;
|
|
||||||
final VoidCallback onComments;
|
|
||||||
final VoidCallback onSave;
|
|
||||||
final VoidCallback onShare;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
key: const Key('player-interaction-rail'),
|
|
||||||
children: [
|
|
||||||
_RailButton(
|
|
||||||
key: const Key('player-rail-save'),
|
|
||||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
|
||||||
label: favoriteCount == null
|
|
||||||
? l10n.favorite
|
|
||||||
: formatInteractionCount(favoriteCount!),
|
|
||||||
active: saved,
|
|
||||||
onTap: onSave,
|
|
||||||
),
|
|
||||||
const SizedBox(height: _railGap),
|
|
||||||
_RailButton(
|
|
||||||
key: const Key('player-rail-comments'),
|
|
||||||
icon: Icons.chat_bubble_outline_rounded,
|
|
||||||
label: commentCount == null
|
|
||||||
? l10n.comments
|
|
||||||
: formatInteractionCount(commentCount!),
|
|
||||||
onTap: onComments,
|
|
||||||
),
|
|
||||||
const SizedBox(height: _railGap),
|
|
||||||
_RailButton(
|
|
||||||
key: const Key('player-rail-like'),
|
|
||||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
label: likeCount == null
|
|
||||||
? l10n.like
|
|
||||||
: formatInteractionCount(likeCount!),
|
|
||||||
active: liked,
|
|
||||||
onTap: onLike,
|
|
||||||
),
|
|
||||||
const SizedBox(height: _railGap),
|
|
||||||
_RailButton(
|
|
||||||
key: const Key('player-rail-share'),
|
|
||||||
icon: Icons.ios_share_rounded,
|
|
||||||
label: l10n.share,
|
|
||||||
onTap: onShare,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RailButton extends StatelessWidget {
|
|
||||||
const _RailButton({
|
|
||||||
super.key,
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.onTap,
|
|
||||||
this.active = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final bool active;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
SizedBox.square(
|
|
||||||
dimension: _railButtonSize,
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: onTap,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0x80000000),
|
|
||||||
foregroundColor: active ? AppColors.blue : AppColors.onPrimary,
|
|
||||||
),
|
|
||||||
icon: Icon(icon, size: 24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.onPrimary,
|
|
||||||
fontSize: 11,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EpisodeSynopsis extends StatefulWidget {
|
class _EpisodeSynopsis extends StatefulWidget {
|
||||||
const _EpisodeSynopsis({
|
const _EpisodeSynopsis({
|
||||||
super.key,
|
super.key,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart
|
|||||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
import '../support/fake_catalog.dart';
|
||||||
import '../support/fake_category.dart';
|
import '../support/fake_category.dart';
|
||||||
@@ -112,7 +113,8 @@ void main() {
|
|||||||
onShare: () {},
|
onShare: () {},
|
||||||
onEpisodes: () {},
|
onEpisodes: () {},
|
||||||
onDetail: () {},
|
onDetail: () {},
|
||||||
onFullscreen: () {},
|
onOpenFullPlayer: () {},
|
||||||
|
onRefreshFeed: () {},
|
||||||
onDownload: () {},
|
onDownload: () {},
|
||||||
onClear: () {},
|
onClear: () {},
|
||||||
onSearch: () {},
|
onSearch: () {},
|
||||||
@@ -145,4 +147,82 @@ void main() {
|
|||||||
'12',
|
'12',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_refresh_seek_and_open_player_from_feed_actions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var refreshed = 0;
|
||||||
|
var opened = 0;
|
||||||
|
Duration? seeked;
|
||||||
|
final controller = ValueNotifier<VideoPlayerValue>(
|
||||||
|
VideoPlayerValue(
|
||||||
|
duration: const Duration(seconds: 100),
|
||||||
|
position: const Duration(seconds: 10),
|
||||||
|
isInitialized: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: HomeFeedOverlay(
|
||||||
|
l10n: AppLocalizations.of(context)!,
|
||||||
|
drama: const Drama(
|
||||||
|
id: 'action-drama',
|
||||||
|
title: 'Action Drama',
|
||||||
|
synopsis: 'synopsis',
|
||||||
|
posterUrl: '',
|
||||||
|
totalEpisodes: 8,
|
||||||
|
viewCount: 1200,
|
||||||
|
),
|
||||||
|
episode: const Episode(
|
||||||
|
id: 'action-episode',
|
||||||
|
number: 1,
|
||||||
|
title: 'Episode 1',
|
||||||
|
isFree: true,
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
liked: false,
|
||||||
|
saved: false,
|
||||||
|
onLike: () {},
|
||||||
|
onSave: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onEpisodes: () {},
|
||||||
|
onDetail: () {},
|
||||||
|
onOpenFullPlayer: () => opened++,
|
||||||
|
onRefreshFeed: () => refreshed++,
|
||||||
|
onDownload: () {},
|
||||||
|
onClear: () {},
|
||||||
|
onSearch: () {},
|
||||||
|
onScrubStart: () {},
|
||||||
|
onSeek: (value) => seeked = value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-refresh')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(refreshed, 1);
|
||||||
|
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('home-feed-progress')),
|
||||||
|
const Offset(120, 0),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(seeked, isNotNull);
|
||||||
|
expect(seeked!.inSeconds, greaterThan(0));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(opened, 1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -65,27 +65,29 @@ void main() {
|
|||||||
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
|
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
|
||||||
'3.2万',
|
'3.2万',
|
||||||
);
|
);
|
||||||
|
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-danmaku')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.widget<Scaffold>(find.byType(Scaffold).first).backgroundColor,
|
||||||
|
Colors.black,
|
||||||
|
);
|
||||||
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
|
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_open_episode_sheet_from_full_series_bar', (tester) async {
|
testWidgets('should_open_full_player_from_full_series_bar', (tester) async {
|
||||||
await _pumpApp(tester);
|
await _pumpApp(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget);
|
final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
|
||||||
|
expect(player.homeMode, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_coming_soon_for_danmaku_and_anime_tab', (
|
testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await _pumpApp(tester);
|
await _pumpApp(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-danmaku')));
|
|
||||||
await tester.pump();
|
|
||||||
expect(find.text('Coming soon'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
|
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Coming soon'), findsOneWidget);
|
expect(find.text('Coming soon'), findsOneWidget);
|
||||||
|
|||||||
Reference in new issue
Block a user