fix(player): 修复清晰度切换闪屏与还原黑屏
This commit is contained in:
1 parent
43e60e29ec
commit
85dedf20fa
8 files changed
+211
-258
No files matched your search
@@ -11,7 +11,7 @@ part 'playback_neighbors.dart';
|
|||||||
part 'playback_session_controls.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 PlayerFactory = PlayerPort Function(Episode episode);
|
||||||
typedef PlayerPreparation =
|
typedef PlayerPreparation =
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
return _detailCache[dramaId];
|
return _detailCache[dramaId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。
|
||||||
|
WatchRecord? recordForDrama(String dramaId) {
|
||||||
|
for (final record in records) {
|
||||||
|
if (record.dramaId == dramaId) return record;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||||
Future<bool> remove(String dramaId) async {
|
Future<bool> remove(String dramaId) async {
|
||||||
final index = _recordIndex(dramaId);
|
final index = _recordIndex(dramaId);
|
||||||
|
|||||||
@@ -149,33 +149,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
onInteractionEnd: widget.onBoostEnd,
|
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(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 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 '../../home/widgets/home_drama_classification.dart';
|
||||||
import 'interaction_rail.dart';
|
import 'interaction_rail.dart';
|
||||||
|
|
||||||
const double _seriesFont = 21;
|
const double _seriesFont = 17;
|
||||||
const double _tagFont = 14;
|
const double _tagFont = 12;
|
||||||
const double _synopsisFont = 15;
|
const double _synopsisFont = 14;
|
||||||
const double _watchAllHeight = 44;
|
const double _watchAllHeight = 44;
|
||||||
const List<Shadow> _textShadows = [
|
const List<Shadow> _textShadows = [
|
||||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||||
@@ -90,7 +90,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: _seriesFont,
|
fontSize: _seriesFont,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w400,
|
||||||
shadows: _textShadows,
|
shadows: _textShadows,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,7 +98,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.chevron_right_rounded,
|
Icons.chevron_right_rounded,
|
||||||
size: 20,
|
size: 18,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -127,13 +127,13 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
];
|
];
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
key: const Key('home-feed-tags'),
|
key: const Key('home-feed-tags'),
|
||||||
height: 25,
|
height: 22,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: chips.length,
|
itemCount: chips.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||||
itemBuilder: (_, index) => chips[index],
|
itemBuilder: (_, index) => chips[index],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -151,7 +151,15 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: prefix,
|
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),
|
TextSpan(text: synopsis, style: baseStyle),
|
||||||
],
|
],
|
||||||
@@ -164,6 +172,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
final toggle = _synopsisToggle(
|
final toggle = _synopsisToggle(
|
||||||
key: const Key('home-feed-synopsis-more'),
|
key: const Key('home-feed-synopsis-more'),
|
||||||
label: l10n.expandSynopsis,
|
label: l10n.expandSynopsis,
|
||||||
|
padding: const EdgeInsets.only(left: 6),
|
||||||
);
|
);
|
||||||
final painter = TextPainter(
|
final painter = TextPainter(
|
||||||
text: span,
|
text: span,
|
||||||
@@ -180,8 +189,14 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 设计稿:单行省略号 + 行内固定「展开」。
|
// 设计稿:单行省略号 + 行内固定「展开」。
|
||||||
return Row(
|
// 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => setState(() => _synopsisExpanded = true),
|
||||||
|
child: Row(
|
||||||
key: const Key('home-feed-synopsis-collapsed'),
|
key: const Key('home-feed-synopsis-collapsed'),
|
||||||
|
// 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
@@ -195,6 +210,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
toggle,
|
toggle,
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -205,31 +221,42 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
TextStyle style,
|
TextStyle style,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
) {
|
) {
|
||||||
return Column(
|
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
|
||||||
key: const Key('home-feed-synopsis-expanded'),
|
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final expandedSpan = TextSpan(
|
||||||
children: [
|
|
||||||
Text.rich(
|
|
||||||
span,
|
|
||||||
key: const Key('home-feed-synopsis'),
|
|
||||||
style: style,
|
style: style,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 2),
|
span,
|
||||||
Align(
|
WidgetSpan(
|
||||||
alignment: Alignment.centerRight,
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
child: _synopsisToggle(
|
child: _synopsisToggle(
|
||||||
key: const Key('home-feed-synopsis-more'),
|
key: const Key('home-feed-synopsis-more'),
|
||||||
label: l10n.showLess,
|
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(
|
return GestureDetector(
|
||||||
key: key,
|
key: key,
|
||||||
|
// opaque + 留白扩大命中区;留白方向由调用方决定,
|
||||||
|
// 展开态不能加 top padding,否则「收起」距简介正文过远。
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
|
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: padding,
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -239,6 +266,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
shadows: _textShadows,
|
shadows: _textShadows,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +345,7 @@ class _TagChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: filled
|
color: filled
|
||||||
@@ -426,11 +454,11 @@ class HomeFeedActionRail extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return InteractionRail(
|
return InteractionRail(
|
||||||
itemGap: 18,
|
itemGap: 12,
|
||||||
buttonSize: 48,
|
buttonSize: 48,
|
||||||
iconSize: 36,
|
iconSize: 36,
|
||||||
countFontSize: 15,
|
countFontSize: 15,
|
||||||
iconCountGap: 6,
|
iconCountGap: 2,
|
||||||
items: [
|
items: [
|
||||||
InteractionRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('rail-save'),
|
key: const Key('rail-save'),
|
||||||
@@ -462,8 +490,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
InteractionRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('rail-share'),
|
key: const Key('rail-share'),
|
||||||
icon: Icons.reply_rounded,
|
asset: 'assets/icon/rail/rail_share.png',
|
||||||
mirrorIcon: true,
|
|
||||||
label: l10n.share,
|
label: l10n.share,
|
||||||
countKey: const Key('rail-share-label'),
|
countKey: const Key('rail-share-label'),
|
||||||
onTap: onShare,
|
onTap: onShare,
|
||||||
|
|||||||
@@ -158,16 +158,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _rail(BuildContext context) {
|
Widget _rail(BuildContext context) {
|
||||||
final compact = MediaQuery.sizeOf(context).height < 700;
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: AppDimens.spaceMd,
|
right: AppDimens.spaceMd,
|
||||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
|
// 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。
|
||||||
|
bottom: 120,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: scrubbing,
|
ignoring: scrubbing,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: scrubbing ? 0 : 1,
|
opacity: scrubbing ? 0 : 1,
|
||||||
child: InteractionRail(
|
child: InteractionRail(
|
||||||
key: const Key('player-interaction-rail'),
|
key: const Key('player-interaction-rail'),
|
||||||
|
itemGap: 12,
|
||||||
|
buttonSize: 48,
|
||||||
|
iconSize: 36,
|
||||||
|
countFontSize: 15,
|
||||||
items: [
|
items: [
|
||||||
InteractionRailItem(
|
InteractionRailItem(
|
||||||
key: const Key('player-rail-save'),
|
key: const Key('player-rail-save'),
|
||||||
@@ -336,7 +340,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
color: AppColors.onPrimary,
|
color: AppColors.onPrimary,
|
||||||
fontFamily: 'Outfit',
|
fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w400,
|
||||||
shadows: _playerTextShadows,
|
shadows: _playerTextShadows,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -131,9 +131,9 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
int _detailRequest = 0;
|
int _detailRequest = 0;
|
||||||
bool _detailRequested = false;
|
bool _detailRequested = false;
|
||||||
|
|
||||||
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
|
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
|
||||||
static const TextStyle _lineStyle = TextStyle(
|
static const TextStyle _lineStyle = TextStyle(
|
||||||
color: AppColors.playerMuted,
|
color: AppColors.onPrimary,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
@@ -142,13 +142,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
static const TextStyle _linkStyle = TextStyle(
|
static const TextStyle _linkStyle = TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
shadows: _playerTextShadows,
|
shadows: _playerTextShadows,
|
||||||
);
|
);
|
||||||
static const int _collapsedMaxLines = 2;
|
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;
|
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||||
|
|
||||||
@@ -200,47 +197,79 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 与首页推荐 feed 的简介展开/收起保持一致:
|
||||||
|
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
|
||||||
Widget _block(double maxWidth) {
|
Widget _block(double maxWidth) {
|
||||||
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||||
if (_expanded) return _expandedBlock(label, maxWidth);
|
final span = TextSpan(
|
||||||
return _collapsedBlock(label, maxWidth);
|
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) {
|
Widget _expandedBlock(InlineSpan span) {
|
||||||
final link = widget.l10n.expandSynopsis;
|
final expandedSpan = TextSpan(
|
||||||
final body = _collapsedBody(label, maxWidth, link);
|
style: _lineStyle,
|
||||||
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,
|
|
||||||
children: [
|
children: [
|
||||||
text,
|
span,
|
||||||
Positioned(
|
WidgetSpan(
|
||||||
left: caret.dx,
|
alignment: PlaceholderAlignment.baseline,
|
||||||
top: caret.dy,
|
baseline: TextBaseline.alphabetic,
|
||||||
child: Padding(
|
child: _toggle(
|
||||||
key: const Key('player-synopsis-gap'),
|
widget.l10n.showLess,
|
||||||
padding: const EdgeInsets.only(left: _expandGap),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: _toggle(link),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
return Column(
|
||||||
|
key: const Key('player-synopsis-expanded'),
|
||||||
Widget _expandedBlock(String label, double maxWidth) {
|
|
||||||
return SizedBox(
|
|
||||||
width: maxWidth,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_synopsisText(label, _synopsis, link: null, expanded: true),
|
Text.rich(expandedSpan, key: const Key('player-synopsis')),
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: _toggle(widget.l10n.showLess),
|
|
||||||
),
|
|
||||||
if (widget.authorNote.isNotEmpty) ...[
|
if (widget.authorNote.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -256,180 +285,28 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _synopsisText(
|
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||||
String label,
|
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||||
String body, {
|
TextSpan _separatorSpan() => TextSpan(
|
||||||
required String? link,
|
text: ' | ',
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
WidgetSpan(
|
|
||||||
alignment: PlaceholderAlignment.baseline,
|
|
||||||
baseline: TextBaseline.alphabetic,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: _separatorGap),
|
|
||||||
child: Text(
|
|
||||||
'|',
|
|
||||||
style: _lineStyle.copyWith(
|
style: _lineStyle.copyWith(
|
||||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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) {
|
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||||
return [
|
return GestureDetector(
|
||||||
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(
|
|
||||||
key: const Key('player-synopsis-toggle'),
|
key: const Key('player-synopsis-toggle'),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
borderRadius: BorderRadius.circular(4),
|
child: Padding(
|
||||||
|
padding: padding,
|
||||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,12 +53,16 @@ class PlayerRenderStack extends StatefulWidget {
|
|||||||
required this.pageController,
|
required this.pageController,
|
||||||
required this.slots,
|
required this.slots,
|
||||||
this.onCurrentVideoVisible,
|
this.onCurrentVideoVisible,
|
||||||
|
this.resumeSignal = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PageController pageController;
|
final PageController pageController;
|
||||||
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
||||||
final ValueChanged<int?>? onCurrentVideoVisible;
|
final ValueChanged<int?>? onCurrentVideoVisible;
|
||||||
|
|
||||||
|
/// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。
|
||||||
|
final int resumeSignal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
|
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
|
||||||
}
|
}
|
||||||
@@ -200,6 +204,7 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
|
|||||||
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
||||||
? () => widget.onCurrentVideoVisible?.call(slot.index)
|
? () => widget.onCurrentVideoVisible?.call(slot.index)
|
||||||
: null,
|
: null,
|
||||||
|
resumeSignal: widget.resumeSignal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
this.debugIndex,
|
this.debugIndex,
|
||||||
this.showFallbackBackground = true,
|
this.showFallbackBackground = true,
|
||||||
this.onVideoVisible,
|
this.onVideoVisible,
|
||||||
|
this.resumeSignal = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
@@ -60,6 +61,10 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
final bool theater;
|
final bool theater;
|
||||||
final VoidCallback? onVideoVisible;
|
final VoidCallback? onVideoVisible;
|
||||||
|
|
||||||
|
/// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧,
|
||||||
|
/// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。
|
||||||
|
final int resumeSignal;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
|
||||||
}
|
}
|
||||||
@@ -76,6 +81,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
bool _transitioning = false;
|
bool _transitioning = false;
|
||||||
bool _videoVisible = false;
|
bool _videoVisible = false;
|
||||||
bool _videoVisibleNotified = false;
|
bool _videoVisibleNotified = false;
|
||||||
|
/// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。
|
||||||
|
Duration? _confirmBasePosition;
|
||||||
|
/// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准,
|
||||||
|
/// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。
|
||||||
|
DateTime? _confirmNotBefore;
|
||||||
String? _lastLayerState;
|
String? _lastLayerState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -99,6 +109,13 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
||||||
super.didUpdateWidget(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) {
|
if (oldWidget.controller != widget.controller) {
|
||||||
_logSlot('controller-change-before', oldWidget.controller?.value);
|
_logSlot('controller-change-before', oldWidget.controller?.value);
|
||||||
_detachController(oldWidget.controller);
|
_detachController(oldWidget.controller);
|
||||||
@@ -261,6 +278,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true;
|
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;
|
if (value.position > Duration.zero || value.isCompleted) return true;
|
||||||
return _playbackStarted && !value.isPlaying && !value.isBuffering;
|
return _playbackStarted && !value.isPlaying && !value.isBuffering;
|
||||||
}
|
}
|
||||||
@@ -321,6 +348,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
_videoVisible = false;
|
_videoVisible = false;
|
||||||
_videoVisibleNotified = false;
|
_videoVisibleNotified = false;
|
||||||
_lastLayerState = null;
|
_lastLayerState = null;
|
||||||
|
// 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。
|
||||||
|
_confirmBasePosition = widget.controller?.value.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyVideoVisible() {
|
void _notifyVideoVisible() {
|
||||||
|
|||||||
Reference in new issue
Block a user