merge: 合并 K33-C 首页 feed 精修
This commit is contained in:
commit
c3f5f43744
17 files changed
+427
-448
No files matched your search
@@ -29,8 +29,6 @@
|
||||
"romance": "Romance",
|
||||
"actionGenre": "Action",
|
||||
"filter": "Filter",
|
||||
"danmaku": "Danmaku",
|
||||
"watchFullscreen": "Full screen",
|
||||
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
|
||||
"@seriesMeta": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
"romance": "Romantis",
|
||||
"actionGenre": "Aksi",
|
||||
"filter": "Filter",
|
||||
"danmaku": "Danmaku",
|
||||
"watchFullscreen": "Layar penuh",
|
||||
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
|
||||
"watchFullSeries": "Tonton serial lengkap · {episodes} eps",
|
||||
"feedTabAnime": "Anime",
|
||||
|
||||
@@ -274,18 +274,6 @@ 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:
|
||||
@@ -2734,6 +2722,12 @@ 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:
|
||||
|
||||
@@ -98,12 +98,6 @@ 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';
|
||||
@@ -1424,6 +1418,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => 'Clear-screen playback';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Danmaku';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => 'Coming soon';
|
||||
|
||||
|
||||
@@ -98,12 +98,6 @@ 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';
|
||||
@@ -1424,6 +1418,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => 'Putar tanpa kontrol';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Komentar layar';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => 'Belum tersedia';
|
||||
|
||||
|
||||
@@ -95,12 +95,6 @@ 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人在追';
|
||||
@@ -1400,6 +1394,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => '清屏播放';
|
||||
|
||||
@override
|
||||
String get danmaku => '弹幕';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => '暂未开放';
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
"romance": "都市爱情",
|
||||
"actionGenre": "动作",
|
||||
"filter": "筛选",
|
||||
"danmaku": "弹幕",
|
||||
"watchFullscreen": "全屏观看",
|
||||
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
||||
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
||||
"feedTabAnime": "漫剧",
|
||||
|
||||
@@ -73,6 +73,7 @@ class HomePage extends StatelessWidget {
|
||||
return _HomeTabs(
|
||||
key: ValueKey(catalog.dramas.first.id),
|
||||
dramas: catalog.dramas,
|
||||
onRefreshFeed: catalog.load,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,9 +98,14 @@ class HomeFailureView extends StatelessWidget {
|
||||
|
||||
/// 四个根页面共享同一路由并保持各自状态。
|
||||
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 VoidCallback onRefreshFeed;
|
||||
|
||||
@override
|
||||
State<_HomeTabs> createState() => _HomeTabsState();
|
||||
@@ -138,7 +144,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
},
|
||||
child: Scaffold(
|
||||
key: logic.scaffoldKey,
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent,
|
||||
body: _body(context),
|
||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
||||
),
|
||||
@@ -165,6 +171,9 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
drama: widget.dramas.first,
|
||||
initialIndex: 0,
|
||||
feedDramas: widget.dramas,
|
||||
onOpenFullPlayer: (drama, episode) =>
|
||||
logic.playDrama(context, drama, episodeId: episode.id),
|
||||
onRefreshFeed: widget.onRefreshFeed,
|
||||
onDramaChanged: logic.onDramaChanged,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
|
||||
@@ -55,6 +55,8 @@ class PlayerPage extends StatefulWidget {
|
||||
this.feedDramas,
|
||||
this.onDramaChanged,
|
||||
this.onImmersiveChanged,
|
||||
this.onOpenFullPlayer,
|
||||
this.onRefreshFeed,
|
||||
this.wallet,
|
||||
this.downloads,
|
||||
});
|
||||
@@ -73,6 +75,8 @@ class PlayerPage extends StatefulWidget {
|
||||
final List<Drama>? feedDramas;
|
||||
final ValueChanged<Drama>? onDramaChanged;
|
||||
final ValueChanged<bool>? onImmersiveChanged;
|
||||
final void Function(Drama drama, Episode episode)? onOpenFullPlayer;
|
||||
final VoidCallback? onRefreshFeed;
|
||||
final WalletController? wallet;
|
||||
final DownloadController? downloads;
|
||||
|
||||
@@ -411,12 +415,20 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDetail: () => unawaited(logic.openDetail(context)),
|
||||
onFullscreen: () =>
|
||||
unawaited(_openFullscreen(context, drama, episode)),
|
||||
onOpenFullPlayer: () =>
|
||||
widget.onOpenFullPlayer?.call(drama, episode),
|
||||
onRefreshFeed: () => widget.onRefreshFeed?.call(),
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => setState(() => _chromeVisible = false),
|
||||
onSearch:
|
||||
widget.onSearch ?? () => logic.showDiscover(context),
|
||||
onScrubStart: ready
|
||||
? () => logic.beginScrub(videoPort!)
|
||||
: null,
|
||||
onSeek: ready
|
||||
? (position) =>
|
||||
logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: 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(
|
||||
BuildContext context,
|
||||
int index,
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'interaction_rail.dart';
|
||||
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
@@ -16,7 +17,6 @@ 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;
|
||||
@@ -24,9 +24,9 @@ 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 double _progressHitHeight = 20;
|
||||
const int _maxTags = 4;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
@@ -46,10 +46,13 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
required this.onShare,
|
||||
required this.onEpisodes,
|
||||
required this.onDetail,
|
||||
required this.onFullscreen,
|
||||
required this.onOpenFullPlayer,
|
||||
required this.onRefreshFeed,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
required this.onSearch,
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.likeCount,
|
||||
this.favoriteCount,
|
||||
this.commentCount,
|
||||
@@ -73,11 +76,14 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onDetail;
|
||||
final VoidCallback onFullscreen;
|
||||
final VoidCallback onOpenFullPlayer;
|
||||
final VoidCallback onRefreshFeed;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback? onPip;
|
||||
final VoidCallback? onScrubStart;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
|
||||
@override
|
||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||
@@ -145,13 +151,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
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,
|
||||
_RefreshButton(
|
||||
tooltip: widget.l10n.retry,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
onPressed: () => _openMore(context),
|
||||
onTap: widget.onRefreshFeed,
|
||||
onLongPress: () => _openMore(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
@@ -205,45 +210,42 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
}
|
||||
|
||||
Widget _rail() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FeedRailItem(
|
||||
return InteractionRail(
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
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,
|
||||
countKey: const Key('rail-save-count'),
|
||||
label: widget.l10n.favorite,
|
||||
selected: widget.saved,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onSave,
|
||||
),
|
||||
_FeedRailItem(
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
activeIcon: Icons.chat_bubble_rounded,
|
||||
label: widget.l10n.comments,
|
||||
count: widget.commentCount,
|
||||
countKey: const Key('rail-comments-count'),
|
||||
label: widget.l10n.comments,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onComments,
|
||||
),
|
||||
_FeedRailItem(
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-like'),
|
||||
icon: widget.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
icon: Icons.favorite_border_rounded,
|
||||
activeIcon: Icons.favorite_rounded,
|
||||
label: widget.l10n.like,
|
||||
count: widget.likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
label: widget.l10n.like,
|
||||
selected: widget.liked,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onLike,
|
||||
),
|
||||
_FeedRailItem(
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.ios_share_rounded,
|
||||
activeIcon: Icons.ios_share_rounded,
|
||||
label: widget.l10n.share,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onShare,
|
||||
),
|
||||
],
|
||||
@@ -257,28 +259,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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(),
|
||||
const SizedBox(height: 2),
|
||||
_titleRow(),
|
||||
@@ -405,7 +385,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
Widget _fullSeriesBar() {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: widget.onEpisodes,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Container(
|
||||
height: _fullBarHeight,
|
||||
color: _fullBarColor,
|
||||
@@ -447,9 +427,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _FeedProgressTrack(
|
||||
child: _FeedProgressBar(
|
||||
controller: widget.controller,
|
||||
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 {
|
||||
const _FeedTabItem({
|
||||
required this.tab,
|
||||
@@ -111,217 +150,100 @@ class _FeedTabItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
||||
class _FeedProgressBar extends StatefulWidget {
|
||||
const _FeedProgressBar({
|
||||
required this.controller,
|
||||
required this.height,
|
||||
required this.background,
|
||||
required this.child,
|
||||
required this.hitHeight,
|
||||
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 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
|
||||
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());
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final source = widget.controller;
|
||||
final track = source == null
|
||||
? _track(0, width)
|
||||
: ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: source,
|
||||
builder: (context, value, _) {
|
||||
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) {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
void _update(double fraction) {
|
||||
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
||||
}
|
||||
|
||||
void _commit(double? fraction) {
|
||||
if (fraction == null) return;
|
||||
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
||||
if (total > 0) {
|
||||
widget.onSeek?.call(
|
||||
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
||||
);
|
||||
}
|
||||
setState(() => _preview = null);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import 'package:flutter/material.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 '../../../l10n/app_localizations.dart';
|
||||
import 'player_center_toggle.dart';
|
||||
import 'interaction_rail.dart';
|
||||
import 'player_overlay_models.dart';
|
||||
import 'seek_progress_bar.dart';
|
||||
|
||||
@@ -14,8 +14,6 @@ part 'player_overlay_parts.dart';
|
||||
|
||||
const double _topButtonSize = 36;
|
||||
const double _topBarHeight = 58;
|
||||
const double _railButtonSize = 34;
|
||||
const double _railGap = 18;
|
||||
const double _boostHintIconSize = 16;
|
||||
const double _boostHintFontSize = 13;
|
||||
const List<Shadow> _playerTextShadows = [
|
||||
@@ -144,17 +142,46 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
return Positioned(
|
||||
right: AppDimens.spaceMd,
|
||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55),
|
||||
child: _Rail(
|
||||
l10n: data.l10n,
|
||||
liked: data.liked,
|
||||
saved: data.saved,
|
||||
likeCount: data.likeCount,
|
||||
favoriteCount: data.favoriteCount,
|
||||
commentCount: data.commentCount,
|
||||
onLike: actions.onLike,
|
||||
onComments: actions.onComments,
|
||||
onSave: actions.onSave,
|
||||
onShare: actions.onShare,
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-save'),
|
||||
icon: Icons.star_border_rounded,
|
||||
activeIcon: Icons.star_rounded,
|
||||
label: data.l10n.favorite,
|
||||
count: data.favoriteCount,
|
||||
countKey: const Key('player-rail-save-count'),
|
||||
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 {
|
||||
const _EpisodeSynopsis({
|
||||
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/l10n/app_localizations.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_category.dart';
|
||||
@@ -112,7 +113,8 @@ void main() {
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onFullscreen: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onRefreshFeed: () {},
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
@@ -145,4 +147,82 @@ void main() {
|
||||
'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,
|
||||
'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);
|
||||
});
|
||||
|
||||
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 tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
|
||||
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.pump();
|
||||
expect(find.text('Coming soon'), findsOneWidget);
|
||||
|
||||
Reference in new issue
Block a user