feat(player): 重做播放页信息层级与互动栏点击动效

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-30 22:53:18 +08:00
1 parent aed9ee2b01
commit fd2ba0bf43
15 files changed
+2074 -551

No files matched your search

@@ -0,0 +1,166 @@
// Author: fruit.com.cn
// 职责:推荐流与播放页共用的简介样式、固定切换入口与高度过渡。
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _synopsisFont = 15;
const double _synopsisLineHeight = 1.4;
const int _collapsedSynopsisLines = 2;
const double _synopsisToggleWidthFraction = 0.55;
const double _synopsisToggleGap = 6;
const _synopsisMotion = Duration(milliseconds: 240);
const _synopsisStyle = TextStyle(
color: AppColors.overlaySecondary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w400,
height: _synopsisLineHeight,
);
const _synopsisToggleStyle = TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w400,
height: _synopsisLineHeight,
);
/// 两行折叠的简介,展开与收起入口在右下共用同一占位。
class ExpandableSynopsis extends StatefulWidget {
/// [keyPrefix] 区分页内标识,[expandedFooter] 仅在展开时展示。
const ExpandableSynopsis({
super.key,
required this.synopsis,
this.keyPrefix = 'home-feed',
this.expandedFooter,
});
/// 按当前语言获取的简介正文。
final String synopsis;
/// 当前页面的组件标识前缀。
final String keyPrefix;
/// 展开后附加的声明等内容。
final Widget? expandedFooter;
@override
State<ExpandableSynopsis> createState() => _ExpandableSynopsisState();
}
class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
bool _expanded = false;
@override
void didUpdateWidget(covariant ExpandableSynopsis oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.synopsis != widget.synopsis) _expanded = false;
}
@override
Widget build(BuildContext context) {
if (widget.synopsis.trim().isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
return LayoutBuilder(
builder: (context, constraints) {
if (!_needsToggle(constraints.maxWidth)) return _body();
return Row(
key: _key('layout'),
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: _animatedBody(),
),
),
const SizedBox(width: _synopsisToggleGap),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * _synopsisToggleWidthFraction,
),
child: IntrinsicWidth(child: _toggleButton(l10n)),
),
],
);
},
);
}
bool _needsToggle(double maxWidth) {
final painter = _painter(widget.synopsis, _synopsisStyle);
painter.layout(maxWidth: maxWidth);
final overflows = painter.didExceedMaxLines;
painter.dispose();
return overflows;
}
TextPainter _painter(String text, TextStyle style) => TextPainter(
text: TextSpan(
text: text,
style: DefaultTextStyle.of(context).style.merge(style),
),
maxLines: _collapsedSynopsisLines,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
locale: Localizations.localeOf(context),
textHeightBehavior: DefaultTextStyle.of(context).textHeightBehavior,
);
Widget _animatedBody() {
if (MediaQuery.disableAnimationsOf(context)) return _body();
return AnimatedSize(
key: _key('size'),
duration: _synopsisMotion,
curve: Curves.easeInOutCubic,
alignment: Alignment.topLeft,
child: _body(),
);
}
Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix');
Widget _body() {
final text = Text.rich(
TextSpan(text: widget.synopsis, style: _synopsisStyle),
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
style: _synopsisStyle,
maxLines: _expanded ? null : _collapsedSynopsisLines,
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
);
if (!_expanded || widget.expandedFooter == null) return text;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [text, widget.expandedFooter!],
);
}
Widget _toggleButton(AppLocalizations l10n) => Semantics(
button: true,
expanded: _expanded,
child: GestureDetector(
key: _key('more'),
behavior: HitTestBehavior.opaque,
onTap: _toggle,
child: IndexedStack(
index: _expanded ? 1 : 0,
alignment: Alignment.bottomRight,
children: [
_toggleLabel(l10n.expandSynopsis),
_toggleLabel(l10n.showLess),
],
),
),
);
Widget _toggleLabel(String label) => Text(
label,
maxLines: _collapsedSynopsisLines,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: _synopsisToggleStyle,
);
void _toggle() => setState(() => _expanded = !_expanded);
}
@@ -0,0 +1,110 @@
// Author: fruit.com.cn
// 职责:推荐流纯文字标签与热搜入口,处理横向滚动和长文案。
part of 'home_feed_recommend_panel.dart';
const double _tagGap = 6;
const double _searchFillAlpha = 0.1;
const double _searchInset = 10;
const double _searchVertical = 4;
const double _searchIcon = 14;
const double _searchIconGap = 4;
const double _searchRadius = 6;
class _TagStrip extends StatelessWidget {
const _TagStrip({required this.chips});
final List<Widget> chips;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
key: const Key('home-feed-tags'),
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
for (var index = 0; index < chips.length; index++) ...[
if (index > 0) const SizedBox(width: _tagGap),
chips[index],
],
],
),
);
}
}
/// 热搜入口保留点击搜索,长语言文案省略而不挤占信息区。
class _HotSearchPill extends StatelessWidget {
const _HotSearchPill({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
key: const Key('home-feed-hot-search-pill'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: _searchInset,
vertical: _searchVertical,
),
decoration: BoxDecoration(
color: AppColors.textPrimary.withValues(alpha: _searchFillAlpha),
borderRadius: BorderRadius.circular(_searchRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.search_rounded,
size: _searchIcon,
color: AppColors.textSecondary,
),
const SizedBox(width: _searchIconGap),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: _searchIconGap),
const Icon(
Icons.chevron_right_rounded,
size: _searchIcon,
color: AppColors.textSecondary,
),
],
),
),
);
}
class _TagChip extends StatelessWidget {
const _TagChip({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) => Text(
label,
maxLines: 1,
strutStyle: _metadataStrutStyle,
style: const TextStyle(
color: AppColors.overlaySecondary,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: _metadataLineHeight,
),
);
}
@@ -1,4 +1,5 @@
// Author: fruit.com.cn
// 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。
import 'dart:async';
@@ -27,10 +28,13 @@ const double _progressHitHeight = 16;
const double _pageHorizontal = 20;
const double _railRight = 14;
const double _railGap = 12;
const double _railWidth = 56;
const double _infoWatchAllGap = 2;
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
const double _watchAllBottomInset = 8;
// 信息面板底边直接贴住观看条顶沿,中间不留缝。
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
// 简介与观看条留出明确间距,展开后仍保留相同的底部留白。
const double _bottomInfoInset =
_watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap;
const double _railBottomOffset = 40;
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [
@@ -169,7 +173,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
Positioned(
left: _pageHorizontal,
right: _pageHorizontal,
right: _railRight + _railGap + _railWidth,
bottom: _watchAllBottomInset,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
@@ -224,9 +228,14 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
children: [
Expanded(child: _bottomInfo()),
const SizedBox(width: _railGap),
Padding(
padding: const EdgeInsets.only(bottom: _railBottomOffset),
child: _rail(),
SizedBox(
width: _railWidth,
child: Padding(
padding: const EdgeInsets.only(
bottom: _railBottomOffset,
),
child: _rail(),
),
),
],
),
@@ -1,32 +1,47 @@
// Author: fruit.com.cn
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/locale_text.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
import 'expandable_synopsis.dart';
import 'interaction_rail.dart';
const double _seriesFont = 17;
const double _tagFont = 12;
const double _synopsisFont = 14;
part 'home_feed_info_tags.dart';
const double _seriesFont = 18;
const double _tagFont = 13;
const double _watchAllHeight = 38;
const double _titleMetadataGap = 2;
const double _metadataSynopsisGap = 2;
const double _hotSearchBottomGap = 2;
const double _metadataGap = 8;
const int _maximumTags = 2;
const double _episodeFont = 14;
const double _episodeWidthFraction = 0.55;
const double _metadataLineHeight = 1.4;
const _metadataStrutStyle = StrutStyle(
fontSize: _episodeFont,
height: _metadataLineHeight,
forceStrutHeight: true,
);
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
const double _watchAllInset = 12;
const double _watchAllRadius = 12;
const double _watchAllRadius = 6;
const double _watchAllFont = 16;
const _watchAllGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [AppColors.mineStatShade, AppColors.brandRed, AppColors.vipButtonEnd],
);
/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。
const double _blockGap = 4;
const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
];
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
/// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
@@ -56,15 +71,13 @@ class HomeFeedInfoPanel extends StatefulWidget {
}
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
bool _synopsisExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues(Localizations.localeOf(context).languageCode);
final tags = _tagChips(l10n, genres);
final types = _typeLabels(Localizations.localeOf(context).languageCode);
final tags = _tagChips(types);
return Column(
key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min,
@@ -80,306 +93,108 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
),
onTap: widget.onOpenSearch!,
),
const SizedBox(height: 8),
],
GestureDetector(
key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque,
onTap: widget.onOpenFullPlayer,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
dramaTitle,
key: const Key('home-feed-series-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _seriesFont,
fontWeight: FontWeight.w400,
shadows: _textShadows,
),
),
),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 18,
color: AppColors.textPrimary,
),
],
),
),
// 没有标签就整行跳过,不给标题和简介之间留空档。
if (tags.isNotEmpty) ...[
const SizedBox(height: _blockGap),
_tagRow(tags),
const SizedBox(height: _hotSearchBottomGap),
],
_title(dramaTitle),
const SizedBox(height: _titleMetadataGap),
_metadata(l10n, tags),
if (synopsis.isNotEmpty) ...[
const SizedBox(height: _blockGap),
_synopsis(l10n, synopsis),
const SizedBox(height: _metadataSynopsisGap),
ExpandableSynopsis(
key: ValueKey(widget.drama.id),
synopsis: synopsis,
),
],
],
);
}
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
return [
if (isHotDrama(widget.drama))
_TagChip(
key: const Key('home-feed-hot-badge'),
label: l10n.homeBadgeHot,
filled: true,
Widget _title(String dramaTitle) => GestureDetector(
key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque,
onTap: widget.onOpenFullPlayer,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
dramaTitle,
key: const Key('home-feed-series-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _seriesFont,
fontWeight: FontWeight.w400,
),
),
),
for (final genre in genres)
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 18,
color: AppColors.textPrimary,
),
],
),
);
List<Widget> _tagChips(List<String> types) {
return [
for (final type in types)
_TagChip(key: Key('home-feed-tag-$type'), label: type),
];
}
Widget _tagRow(List<Widget> chips) {
return SizedBox(
key: const Key('home-feed-tags'),
height: 22,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
shrinkWrap: true,
itemCount: chips.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (_, index) => chips[index],
),
);
}
Widget _synopsis(AppLocalizations l10n, String synopsis) {
final prefix = l10n.feedEpisodePrefix(widget.episode.number);
final baseStyle = const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
height: 1.3,
shadows: _textShadows,
);
final span = TextSpan(
children: [
TextSpan(text: prefix, style: baseStyle),
TextSpan(
text: ' | ',
style: baseStyle.copyWith(
color: AppColors.textPrimary.withValues(alpha: 0.45),
fontSize: _synopsisFont - 2,
fontWeight: FontWeight.w400,
),
),
TextSpan(text: synopsis, style: baseStyle),
],
);
if (_synopsisExpanded) {
return _expandedSynopsis(span, baseStyle, l10n);
}
Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
return LayoutBuilder(
builder: (context, constraints) {
final toggle = _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
padding: const EdgeInsets.only(left: 6),
);
final painter = TextPainter(
text: span,
final episode = Text(
widget.drama.episodeCount > 0
? l10n.episodeOfTotal(
widget.episode.number,
widget.drama.episodeCount,
)
: l10n.feedEpisodePrefix(widget.episode.number),
key: const Key('home-feed-episode-meta'),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth);
if (!painter.didExceedMaxLines) {
return Text.rich(
span,
key: const Key('home-feed-synopsis'),
maxLines: 1,
style: baseStyle,
);
}
// 设计稿:单行省略号 + 行内固定「展开」。
// 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _synopsisExpanded = true),
child: Row(
key: const Key('home-feed-synopsis-collapsed'),
// 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text.rich(
span,
key: const Key('home-feed-synopsis'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: baseStyle,
),
),
const SizedBox(width: 2),
toggle,
],
overflow: TextOverflow.ellipsis,
strutStyle: _metadataStrutStyle,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _episodeFont,
height: _metadataLineHeight,
),
);
return Row(
key: const Key('home-feed-metadata'),
children: [
if (tags.isNotEmpty) ...[
Flexible(child: _TagStrip(chips: tags)),
const SizedBox(width: _metadataGap),
],
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: tags.isEmpty
? constraints.maxWidth
: constraints.maxWidth * _episodeWidthFraction,
),
child: episode,
),
],
);
},
);
}
Widget _expandedSynopsis(
InlineSpan span,
TextStyle style,
AppLocalizations l10n,
) {
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
final expandedSpan = TextSpan(
style: style,
children: [
span,
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.showLess,
padding: const EdgeInsets.only(left: 8),
),
),
],
);
return Text.rich(
expandedSpan,
key: const Key('home-feed-synopsis-expanded'),
);
}
Widget _synopsisToggle({
required Key key,
required String label,
required EdgeInsets padding,
}) {
return GestureDetector(
key: key,
// opaque + 留白扩大命中区;留白方向由调用方决定,
// 展开态不能加 top padding,否则「收起」距简介正文过远。
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
child: Padding(
padding: padding,
child: Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w600,
shadows: _textShadows,
),
),
),
);
}
List<String> _genreValues(String languageCode) {
final localized = widget.drama.localizedTypeLabels(languageCode);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result.take(3).toList(growable: false);
}
List<String> _typeLabels(String languageCode) => widget.drama.types
.take(_maximumTags)
.map((type) => localizedText(languageCode, '', type).trim())
.where((label) => label.isNotEmpty)
.toList(growable: false);
}
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
class _HotSearchPill extends StatelessWidget {
const _HotSearchPill({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-hot-search-pill'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.textPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.search_rounded,
size: 15,
color: AppColors.textPrimary,
),
const SizedBox(width: 5),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 16,
color: AppColors.textPrimary,
),
],
),
),
);
}
}
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
class _TagChip extends StatelessWidget {
const _TagChip({super.key, required this.label, this.filled = false});
final String label;
final bool filled;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: 1.2,
shadows: _textShadows,
),
),
);
}
}
/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。
class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({
super.key,
@@ -395,49 +210,43 @@ class HomeFeedWatchAllBar extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
return ClipRRect(
borderRadius: BorderRadius.circular(_watchAllRadius),
child: Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.textPrimary.withValues(alpha: 0.15),
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: SizedBox(
height: _watchAllHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
child: Row(
children: [
const Icon(
Icons.movie_rounded,
size: 22,
color: AppColors.onPrimary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.watchFullSeries('${drama.episodeCount}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textPrimary,
),
],
),
),
child: DecoratedBox(
key: const Key('home-feed-full-series-gradient'),
decoration: const BoxDecoration(gradient: _watchAllGradient),
child: Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: _content(l10n),
),
),
),
);
}
Widget _content(AppLocalizations l10n) => SizedBox(
width: double.infinity,
height: _watchAllHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
child: Center(
child: Text(
l10n.homeWatchAll,
semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _watchAllFont,
fontWeight: FontWeight.w400,
),
),
),
),
);
}
class HomeFeedActionRail extends StatelessWidget {
@@ -468,7 +277,7 @@ class HomeFeedActionRail extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return InteractionRail(
itemGap: 12,
itemGap: interactionRailCompactGap,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
@@ -481,6 +290,7 @@ class HomeFeedActionRail extends StatelessWidget {
count: favoriteCount,
countKey: const Key('rail-save-count'),
selected: saved,
animateTap: true,
selectedColor: AppColors.brandRed,
onTap: onSave,
),
@@ -499,6 +309,7 @@ class HomeFeedActionRail extends StatelessWidget {
count: likeCount,
countKey: const Key('rail-like-count'),
selected: liked,
animateTap: true,
selectedColor: AppColors.brandRed,
onTap: onLike,
),
+155 -37
View File
@@ -1,15 +1,44 @@
// Author: fruit.com.cn
// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import 'interaction_rail_burst.dart';
const double _defaultButtonSize = 44;
const double _defaultIconSize = 32;
const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24;
const _tapDuration = Duration(milliseconds: 360);
const double _popScale = 1.34;
const double _cancelScale = 0.68;
const double _popWeight = 40;
const double _settleWeight = 60;
final _growTween = _tapTween(_popScale);
final _shrinkTween = _tapTween(_cancelScale);
TweenSequence<double> _tapTween(double target) => TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 1.0,
end: target,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: _popWeight,
),
TweenSequenceItem(
tween: Tween(
begin: target,
end: 1.0,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: _settleWeight,
),
]);
/// 首页与完整播放器互动项之间的紧凑间距。
const double interactionRailCompactGap = 6;
/// 互动栏单项:图标、计数与点击。
class InteractionRailItem {
@@ -24,6 +53,7 @@ class InteractionRailItem {
this.selected = false,
this.selectedColor = AppColors.blue,
this.mirrorIcon = false,
this.animateTap = false,
}) : assert((asset == null) != (icon == null));
final Key key;
@@ -38,6 +68,9 @@ class InteractionRailItem {
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
final bool mirrorIcon;
/// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。
final bool animateTap;
}
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
@@ -79,7 +112,7 @@ class InteractionRail extends StatelessWidget {
}
}
class _RailButton extends StatelessWidget {
class _RailButton extends StatefulWidget {
const _RailButton({
required this.item,
required this.buttonSize,
@@ -94,50 +127,135 @@ class _RailButton extends StatelessWidget {
final double countFontSize;
final double iconCountGap;
@override
State<_RailButton> createState() => _RailButtonState();
}
class _RailButtonState extends State<_RailButton>
with SingleTickerProviderStateMixin {
late final AnimationController _motion;
Color _burstColor = AppColors.onPrimary;
bool _contracting = false;
@override
void initState() {
super.initState();
_motion = AnimationController(vsync: this, duration: _tapDuration);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (MediaQuery.disableAnimationsOf(context)) _motion.reset();
}
@override
void dispose() {
_motion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return Column(
key: item.key,
key: widget.item.key,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: buttonSize,
child: IconButton(
onPressed: item.onTap,
padding: EdgeInsets.zero,
icon: item.asset != null
? Image.asset(
item.asset!,
width: iconSize,
height: iconSize,
fit: BoxFit.contain,
color: tint,
colorBlendMode: BlendMode.srcIn,
)
: Transform.flip(
flipX: item.mirrorIcon,
child: Icon(item.icon, size: iconSize, color: tint),
),
),
),
SizedBox(height: iconCountGap),
Text(
item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
height: 1,
fontSize: countFontSize,
fontWeight: FontWeight.w600,
shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
),
dimension: widget.buttonSize,
child: widget.item.animateTap ? _animatedButton() : _button(_icon()),
),
SizedBox(height: widget.iconCountGap),
_count(),
],
);
}
void _onTap() {
if (widget.item.animateTap && !MediaQuery.disableAnimationsOf(context)) {
_contracting = widget.item.selected;
_burstColor = _contracting
? AppColors.onPrimary
: widget.item.selectedColor;
_motion.forward(from: 0);
}
widget.item.onTap();
}
Widget _button(Widget icon) => IconButton(
onPressed: _onTap,
iconSize: widget.iconSize,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
overlayColor: AppColors.transparent,
shadowColor: AppColors.transparent,
elevation: 0,
splashFactory: NoSplash.splashFactory,
),
icon: icon,
);
Widget _animatedButton() => AnimatedBuilder(
animation: _motion,
child: _icon(),
builder: (context, child) => Stack(
fit: StackFit.expand,
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
_button(
Transform.scale(
scale: (_contracting ? _shrinkTween : _growTween).transform(
_motion.value,
),
child: child,
),
),
if (_motion.isAnimating)
Positioned.fill(
child: RailTapBurst(
key: const Key('rail-tap-burst'),
progress: _motion.value,
color: _burstColor,
contracting: _contracting,
),
),
],
),
);
Widget _icon() {
final item = widget.item;
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return item.asset != null
? Image.asset(
item.asset!,
width: widget.iconSize,
height: widget.iconSize,
fit: BoxFit.contain,
color: tint,
colorBlendMode: BlendMode.srcIn,
)
: Transform.flip(
flipX: item.mirrorIcon,
child: Icon(item.icon, size: widget.iconSize, color: tint),
);
}
Widget _count() {
final item = widget.item;
return Text(
item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
height: 1,
fontSize: widget.countFontSize,
fontWeight: FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
);
}
}
@@ -0,0 +1,88 @@
// Author: fruit.com.cn
// 职责:绘制收藏点赞周围的紧凑双向短线,无圆环或阴影,不参与手势。
import 'dart:math' as math;
import 'package:flutter/material.dart';
const int _rayCount = 8;
const double _startProgress = 0.1;
const double _radiusStart = 0.34;
const double _radiusTravel = 0.14;
const double _rayLength = 0.07;
const double _strokeFraction = 0.025;
const double _rayAlpha = 0.65;
const double _fullCircle = math.pi * 2;
const double _rayRotation = math.pi / _rayCount;
/// 收藏/点赞的装饰反馈:选中外扩,取消收束。
class RailTapBurst extends StatelessWidget {
/// [progress] 来自按钮的点击动画,不创建独立计时器。
const RailTapBurst({
super.key,
required this.progress,
required this.color,
required this.contracting,
});
final double progress;
final Color color;
final bool contracting;
@override
Widget build(BuildContext context) => IgnorePointer(
child: CustomPaint(
painter: _RailBurstPainter(
progress: progress,
color: color,
contracting: contracting,
),
),
);
}
class _RailBurstPainter extends CustomPainter {
const _RailBurstPainter({
required this.progress,
required this.color,
required this.contracting,
});
final double progress;
final Color color;
final bool contracting;
@override
void paint(Canvas canvas, Size size) {
final phase = ((progress - _startProgress) / (1 - _startProgress)).clamp(
0.0,
1.0,
);
if (phase == 0 || phase == 1) return;
final eased = Curves.easeOutCubic.transform(phase);
final travel = contracting ? 1 - eased : eased;
final base = size.shortestSide;
final radius = base * (_radiusStart + _radiusTravel * travel);
final alpha = math.sin(math.pi * phase);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = base * _strokeFraction
..color = color.withValues(alpha: alpha * _rayAlpha);
final center = size.center(Offset.zero);
final length = base * _rayLength * (1 - phase);
for (var index = 0; index < _rayCount; index++) {
final angle = _fullCircle * index / _rayCount + _rayRotation;
canvas.drawLine(
center + Offset.fromDirection(angle, radius),
center + Offset.fromDirection(angle, radius + length),
paint,
);
}
}
@override
bool shouldRepaint(covariant _RailBurstPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.contracting != contracting;
}
+5 -3
View File
@@ -11,6 +11,7 @@ import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import 'expandable_synopsis.dart';
import 'player_center_toggle.dart';
import 'interaction_rail.dart';
import 'player_overlay_models.dart';
@@ -178,7 +179,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
opacity: scrubbing ? 0 : 1,
child: InteractionRail(
key: const Key('player-interaction-rail'),
itemGap: 12,
itemGap: interactionRailCompactGap,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
@@ -190,6 +191,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.favoriteCount,
countKey: const Key('player-rail-save-count'),
selected: data.saved,
animateTap: true,
onTap: actions.onSave,
),
InteractionRailItem(
@@ -199,6 +201,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.likeCount,
countKey: const Key('player-rail-like-count'),
selected: data.liked,
animateTap: true,
selectedColor: AppColors.brandRed,
onTap: actions.onLike,
),
@@ -300,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
key: const Key('player-episode-synopsis'),
l10n: data.l10n,
drama: data.drama,
episodeNumber: data.episode.number,
synopsis: synopsis,
authorNote: data.authorNote,
),
@@ -412,7 +414,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 13.5,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w400,
shadows: _playerTextShadows,
),
),
@@ -118,14 +118,12 @@ class _EpisodeSynopsis extends StatefulWidget {
super.key,
required this.l10n,
required this.drama,
required this.episodeNumber,
required this.synopsis,
required this.authorNote,
});
final AppLocalizations l10n;
final Drama drama;
final int episodeNumber;
final String synopsis;
final String authorNote;
@@ -134,26 +132,13 @@ class _EpisodeSynopsis extends StatefulWidget {
}
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
bool _expanded = false;
String? _fullSynopsis;
int _detailRequest = 0;
bool _detailRequested = false;
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
static const TextStyle _lineStyle = TextStyle(
color: AppColors.onPrimary,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.35,
shadows: _playerTextShadows,
);
static const TextStyle _linkStyle = TextStyle(
color: AppColors.textPrimary,
fontSize: 12,
fontWeight: FontWeight.w600,
shadows: _playerTextShadows,
);
static const int _collapsedMaxLines = 2;
static const int _authorNoteLines = 2;
static const double _authorNoteGap = 4;
static const double _authorNoteFont = 10;
String get _synopsis => _fullSynopsis ?? widget.synopsis;
@@ -173,7 +158,6 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
return;
}
_fullSynopsis = null;
_expanded = false;
_detailRequested = !widget.drama.detailLoaded;
if (_detailRequested) unawaited(_loadFullSynopsis());
}
@@ -200,123 +184,26 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => _block(constraints.maxWidth),
return ExpandableSynopsis(
key: ValueKey(widget.drama.id),
keyPrefix: 'player',
synopsis: _synopsis,
expandedFooter: widget.authorNote.isEmpty ? null : _authorNote(),
);
}
/// 与首页推荐 feed 的简介展开/收起保持一致:
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
Widget _block(double maxWidth) {
final label = widget.l10n.episodeLabel(widget.episodeNumber);
final span = TextSpan(
style: _lineStyle,
children: [
TextSpan(text: label, style: _lineStyle),
_separatorSpan(),
TextSpan(text: _synopsis, style: _lineStyle),
],
);
if (_expanded) return _expandedBlock(span);
final painter = TextPainter(
text: span,
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: maxWidth);
if (!painter.didExceedMaxLines) {
return Text.rich(
span,
key: const Key('player-synopsis'),
maxLines: 1,
style: _lineStyle,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = true),
child: Row(
key: const Key('player-synopsis-collapsed'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text.rich(
span,
key: const Key('player-synopsis'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _lineStyle,
),
),
const SizedBox(width: 2),
_toggle(
widget.l10n.expandSynopsis,
padding: const EdgeInsets.only(left: 6),
),
],
Widget _authorNote() => Padding(
padding: const EdgeInsets.only(top: _authorNoteGap),
child: Text(
widget.authorNote,
key: const Key('player-author-note'),
maxLines: _authorNoteLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.playerMuted,
fontSize: _authorNoteFont,
shadows: _playerTextShadows,
),
);
}
Widget _expandedBlock(InlineSpan span) {
final expandedSpan = TextSpan(
style: _lineStyle,
children: [
span,
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _toggle(
widget.l10n.showLess,
padding: const EdgeInsets.only(left: 8),
),
),
],
);
return Column(
key: const Key('player-synopsis-expanded'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(expandedSpan, key: const Key('player-synopsis')),
if (widget.authorNote.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.authorNote,
key: const Key('player-author-note'),
maxLines: _collapsedMaxLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.playerMuted,
fontSize: 10,
shadows: _playerTextShadows,
),
),
],
],
);
}
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
TextSpan _separatorSpan() => TextSpan(
text: ' | ',
style: _lineStyle.copyWith(
color: AppColors.onPrimary.withValues(alpha: 0.45),
fontWeight: FontWeight.w400,
),
);
Widget _toggle(String label, {required EdgeInsets padding}) {
return GestureDetector(
key: const Key('player-synopsis-toggle'),
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: padding,
child: Text(label, maxLines: 1, style: _linkStyle),
),
);
}
}