fix(player): 修复清晰度切换闪屏与还原黑屏
This commit is contained in:
1 parent
43e60e29ec
commit
85dedf20fa
8 files changed
+249
-296
No files matched your search
@@ -11,7 +11,7 @@ part 'playback_neighbors.dart';
|
||||
part 'playback_session_controls.dart';
|
||||
|
||||
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
|
||||
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
|
||||
const Duration _qualityHandoffHold = Duration(milliseconds: 500);
|
||||
|
||||
typedef PlayerFactory = PlayerPort Function(Episode episode);
|
||||
typedef PlayerPreparation =
|
||||
|
||||
@@ -144,6 +144,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
/// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。
|
||||
WatchRecord? recordForDrama(String dramaId) {
|
||||
for (final record in records) {
|
||||
if (record.dramaId == dramaId) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
|
||||
@@ -149,33 +149,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
onInteractionEnd: widget.onBoostEnd,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _railRight,
|
||||
bottom: _bottomInfoInset,
|
||||
child: AnimatedOpacity(
|
||||
key: const Key('home-feed-content-opacity'),
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
key: const Key('home-feed-content-ignore'),
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: _bottomInfo()),
|
||||
const SizedBox(width: _railGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _railBottomOffset),
|
||||
child: _rail(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -227,6 +200,36 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 信息面板放在最后(最上层):进度条 32px 触控盒向上伸进底部避让区,
|
||||
// 若面板在前,简介/展开按钮底部的触控留白会被进度条吞掉点击。
|
||||
// 视觉上避让条带为空,提层不改变观感。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _railRight,
|
||||
bottom: _bottomInfoInset,
|
||||
child: AnimatedOpacity(
|
||||
key: const Key('home-feed-content-opacity'),
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
key: const Key('home-feed-content-ignore'),
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: _bottomInfo()),
|
||||
const SizedBox(width: _railGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _railBottomOffset),
|
||||
child: _rail(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -10,9 +10,9 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../home/widgets/home_drama_classification.dart';
|
||||
import 'interaction_rail.dart';
|
||||
|
||||
const double _seriesFont = 21;
|
||||
const double _tagFont = 14;
|
||||
const double _synopsisFont = 15;
|
||||
const double _seriesFont = 17;
|
||||
const double _tagFont = 12;
|
||||
const double _synopsisFont = 14;
|
||||
const double _watchAllHeight = 44;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
@@ -90,7 +90,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
@@ -98,7 +98,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 20,
|
||||
size: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -127,13 +127,13 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
];
|
||||
return SizedBox(
|
||||
key: const Key('home-feed-tags'),
|
||||
height: 25,
|
||||
height: 22,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: chips.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||
itemBuilder: (_, index) => chips[index],
|
||||
),
|
||||
);
|
||||
@@ -151,7 +151,15 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
children: [
|
||||
TextSpan(
|
||||
text: prefix,
|
||||
style: baseStyle.copyWith(fontWeight: FontWeight.w600),
|
||||
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),
|
||||
],
|
||||
@@ -164,6 +172,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
final toggle = _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
);
|
||||
final painter = TextPainter(
|
||||
text: span,
|
||||
@@ -180,21 +189,28 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
}
|
||||
// 设计稿:单行省略号 + 行内固定「展开」。
|
||||
return Row(
|
||||
key: const Key('home-feed-synopsis-collapsed'),
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
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,
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
toggle,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -205,38 +221,50 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
TextStyle style,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return Column(
|
||||
key: const Key('home-feed-synopsis-expanded'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
|
||||
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
|
||||
final expandedSpan = TextSpan(
|
||||
style: style,
|
||||
children: [
|
||||
Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
style: style,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
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}) {
|
||||
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: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _textShadows,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -317,7 +345,7 @@ class _TagChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
@@ -426,11 +454,11 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return InteractionRail(
|
||||
itemGap: 18,
|
||||
itemGap: 12,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
iconCountGap: 6,
|
||||
iconCountGap: 2,
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-save'),
|
||||
@@ -462,8 +490,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.reply_rounded,
|
||||
mirrorIcon: true,
|
||||
asset: 'assets/icon/rail/rail_share.png',
|
||||
label: l10n.share,
|
||||
countKey: const Key('rail-share-label'),
|
||||
onTap: onShare,
|
||||
|
||||
@@ -158,16 +158,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _rail(BuildContext context) {
|
||||
final compact = MediaQuery.sizeOf(context).height < 700;
|
||||
return Positioned(
|
||||
right: AppDimens.spaceMd,
|
||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
|
||||
// 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。
|
||||
bottom: 120,
|
||||
child: IgnorePointer(
|
||||
ignoring: scrubbing,
|
||||
child: Opacity(
|
||||
opacity: scrubbing ? 0 : 1,
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
itemGap: 12,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-save'),
|
||||
@@ -336,7 +340,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
color: AppColors.onPrimary,
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,16 @@ class PlayerRenderStack extends StatefulWidget {
|
||||
required this.pageController,
|
||||
required this.slots,
|
||||
this.onCurrentVideoVisible,
|
||||
this.resumeSignal = 0,
|
||||
});
|
||||
|
||||
final PageController pageController;
|
||||
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
||||
final ValueChanged<int?>? onCurrentVideoVisible;
|
||||
|
||||
/// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。
|
||||
final int resumeSignal;
|
||||
|
||||
@override
|
||||
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
|
||||
}
|
||||
@@ -200,6 +204,7 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
|
||||
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
||||
? () => widget.onCurrentVideoVisible?.call(slot.index)
|
||||
: null,
|
||||
resumeSignal: widget.resumeSignal,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ class PlayerVideoSurface extends StatefulWidget {
|
||||
this.debugIndex,
|
||||
this.showFallbackBackground = true,
|
||||
this.onVideoVisible,
|
||||
this.resumeSignal = 0,
|
||||
});
|
||||
|
||||
final VideoPlayerController? controller;
|
||||
@@ -60,6 +61,10 @@ class PlayerVideoSurface extends StatefulWidget {
|
||||
final bool theater;
|
||||
final VoidCallback? onVideoVisible;
|
||||
|
||||
/// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧,
|
||||
/// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。
|
||||
final int resumeSignal;
|
||||
|
||||
@override
|
||||
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
||||
}
|
||||
@@ -76,6 +81,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
bool _transitioning = false;
|
||||
bool _videoVisible = false;
|
||||
bool _videoVisibleNotified = false;
|
||||
/// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。
|
||||
Duration? _confirmBasePosition;
|
||||
/// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准,
|
||||
/// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。
|
||||
DateTime? _confirmNotBefore;
|
||||
String? _lastLayerState;
|
||||
|
||||
@override
|
||||
@@ -99,6 +109,13 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
@override
|
||||
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.resumeSignal != widget.resumeSignal) {
|
||||
_logSlot('pip-resume-reset', widget.controller?.value);
|
||||
_resetForControllerChange();
|
||||
_confirmNotBefore = DateTime.now().add(const Duration(milliseconds: 450));
|
||||
setState(() {});
|
||||
if (oldWidget.controller == widget.controller) return;
|
||||
}
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
_logSlot('controller-change-before', oldWidget.controller?.value);
|
||||
_detachController(oldWidget.controller);
|
||||
@@ -261,6 +278,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
return false;
|
||||
}
|
||||
if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true;
|
||||
// 清晰度切换:新源 seek 完成后 position 立即大于 0,但不代表纹理已有
|
||||
// 画面;等 position 越过切换时刻的值(解码循环已在出帧)再确认,避免闪黑。
|
||||
final base = _confirmBasePosition;
|
||||
if (base != null) {
|
||||
final notBefore = _confirmNotBefore;
|
||||
if (notBefore != null && DateTime.now().isBefore(notBefore)) {
|
||||
return false;
|
||||
}
|
||||
return value.position > base || value.isCompleted;
|
||||
}
|
||||
if (value.position > Duration.zero || value.isCompleted) return true;
|
||||
return _playbackStarted && !value.isPlaying && !value.isBuffering;
|
||||
}
|
||||
@@ -321,6 +348,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
_videoVisible = false;
|
||||
_videoVisibleNotified = false;
|
||||
_lastLayerState = null;
|
||||
// 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。
|
||||
_confirmBasePosition = widget.controller?.value.position;
|
||||
}
|
||||
|
||||
void _notifyVideoVisible() {
|
||||
|
||||
Reference in new issue
Block a user