fix(flutter): 精修首页 feed 交互与底色

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:56:51 +08:00
1 parent 435fbbbbb1
commit 321c62664c
17 files changed
+427 -448

No files matched your search

-2
View File
@@ -29,8 +29,6 @@
"romance": "Romance",
"actionGenre": "Action",
"filter": "Filter",
"danmaku": "Danmaku",
"watchFullscreen": "Full screen",
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
"@seriesMeta": {
"placeholders": {
-2
View File
@@ -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",
+6 -12
View File
@@ -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:
+3 -6
View File
@@ -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';
+3 -6
View File
@@ -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';
+3 -6
View File
@@ -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 => '暂未开放';
-2
View File
@@ -29,8 +29,6 @@
"romance": "都市爱情",
"actionGenre": "动作",
"filter": "筛选",
"danmaku": "弹幕",
"watchFullscreen": "全屏观看",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
"feedTabAnime": "漫剧",
+11 -2
View File
@@ -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),
+14 -2
View File
@@ -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,
-19
View File
@@ -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,
+34 -51
View File
@@ -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)],
),
),
],
);
}
}
+41 -14
View File
@@ -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,