fix(player): 修复清晰度切换闪屏与还原黑屏
This commit is contained in:
1 parent
43e60e29ec
commit
85dedf20fa
8 files changed
+249
-296
No files matched your search
@@ -131,9 +131,9 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
int _detailRequest = 0;
|
||||
bool _detailRequested = false;
|
||||
|
||||
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
|
||||
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
|
||||
static const TextStyle _lineStyle = TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
@@ -142,13 +142,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
static const TextStyle _linkStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const int _collapsedMaxLines = 2;
|
||||
static const double _expandGap = 6;
|
||||
static const double _expandTrailing = 28;
|
||||
static const double _separatorGap = 6;
|
||||
|
||||
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||
|
||||
@@ -200,236 +197,116 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 与首页推荐 feed 的简介展开/收起保持一致:
|
||||
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
|
||||
Widget _block(double maxWidth) {
|
||||
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||
if (_expanded) return _expandedBlock(label, maxWidth);
|
||||
return _collapsedBlock(label, maxWidth);
|
||||
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 _collapsedBlock(String label, double maxWidth) {
|
||||
final link = widget.l10n.expandSynopsis;
|
||||
final body = _collapsedBody(label, maxWidth, link);
|
||||
final text = _synopsisText(label, body, link: null, expanded: false);
|
||||
if (body == _synopsis) return text;
|
||||
final caret = _caret(label, body, maxWidth);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
Widget _expandedBlock(InlineSpan span) {
|
||||
final expandedSpan = TextSpan(
|
||||
style: _lineStyle,
|
||||
children: [
|
||||
text,
|
||||
Positioned(
|
||||
left: caret.dx,
|
||||
top: caret.dy,
|
||||
child: Padding(
|
||||
key: const Key('player-synopsis-gap'),
|
||||
padding: const EdgeInsets.only(left: _expandGap),
|
||||
child: _toggle(link),
|
||||
span,
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _toggle(
|
||||
widget.l10n.showLess,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedBlock(String label, double maxWidth) {
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_synopsisText(label, _synopsis, link: null, expanded: true),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _toggle(widget.l10n.showLess),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsisText(
|
||||
String label,
|
||||
String body, {
|
||||
required String? link,
|
||||
required bool expanded,
|
||||
}) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
style: _lineStyle,
|
||||
children: _spans(label, body, link),
|
||||
),
|
||||
key: const Key('player-synopsis'),
|
||||
style: _lineStyle,
|
||||
maxLines: expanded ? null : _collapsedMaxLines,
|
||||
overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
List<InlineSpan> _spans(String label, String body, String? link) {
|
||||
return [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Text(
|
||||
label,
|
||||
key: const Key('player-episode-label'),
|
||||
style: _lineStyle,
|
||||
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||
TextSpan _separatorSpan() => TextSpan(
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _separatorGap),
|
||||
child: Text(
|
||||
'|',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: body, style: _lineStyle),
|
||||
if (link != null)
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Padding(
|
||||
key: const Key('player-synopsis-gap'),
|
||||
padding: const EdgeInsets.only(left: _expandGap),
|
||||
child: _toggle(link),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
String _collapsedBody(String label, double maxWidth, String link) {
|
||||
if (_fits(label, _synopsis, maxWidth, reserve: 0)) {
|
||||
return _synopsis;
|
||||
}
|
||||
final reserve =
|
||||
_textWidth(link, _linkStyle) + _expandGap + _expandTrailing;
|
||||
var low = 0;
|
||||
var high = _synopsis.length;
|
||||
var best = '…';
|
||||
while (low <= high) {
|
||||
final mid = (low + high) >> 1;
|
||||
final slice = '${_synopsis.substring(0, mid)}…';
|
||||
if (_fits(label, slice, maxWidth, reserve: reserve)) {
|
||||
best = slice;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool _fits(
|
||||
String label,
|
||||
String body,
|
||||
double maxWidth, {
|
||||
required double reserve,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label, style: _lineStyle),
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: _separatorGap),
|
||||
),
|
||||
TextSpan(
|
||||
text: '|',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: _separatorGap),
|
||||
),
|
||||
TextSpan(text: body, style: _lineStyle),
|
||||
if (reserve > 0)
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: reserve),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: _collapsedMaxLines,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.setPlaceholderDimensions(_slots(reserve));
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
return !painter.didExceedMaxLines;
|
||||
}
|
||||
|
||||
List<PlaceholderDimensions> _slots(double reserve) {
|
||||
return [
|
||||
const PlaceholderDimensions(
|
||||
size: Size(_separatorGap, 1),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
const PlaceholderDimensions(
|
||||
size: Size(_separatorGap, 1),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
if (reserve > 0)
|
||||
PlaceholderDimensions(
|
||||
size: Size(reserve, _lineStyle.fontSize ?? 12),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Offset _caret(String label, String body, double maxWidth) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(children: _spans(label, body, null)),
|
||||
maxLines: _collapsedMaxLines,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.setPlaceholderDimensions(_slots(0));
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
return painter.getOffsetForCaret(
|
||||
TextPosition(offset: painter.plainText.length),
|
||||
Rect.zero,
|
||||
);
|
||||
}
|
||||
double _textWidth(String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return painter.width;
|
||||
}
|
||||
|
||||
Widget _toggle(String label) {
|
||||
return InkWell(
|
||||
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||
return GestureDetector(
|
||||
key: const Key('player-synopsis-toggle'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user