313 lines
8.5 KiB
Dart
313 lines
8.5 KiB
Dart
// Author: fruit.com.cn
|
||
|
||
part of 'player_overlay.dart';
|
||
|
||
class _TopButton extends StatelessWidget {
|
||
const _TopButton({
|
||
super.key,
|
||
required this.tooltip,
|
||
required this.icon,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final String tooltip;
|
||
final IconData icon;
|
||
final VoidCallback onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox.square(
|
||
dimension: _topButtonSize,
|
||
child: IconButton(
|
||
tooltip: tooltip,
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: AppColors.controlScrim,
|
||
foregroundColor: AppColors.onPrimary,
|
||
),
|
||
onPressed: onPressed,
|
||
icon: Icon(icon, size: 18),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SpeedEntry extends StatelessWidget {
|
||
const _SpeedEntry({super.key, required this.speed, required this.onTap});
|
||
|
||
final double speed;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
color: AppColors.controlScrim,
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.speed_rounded,
|
||
color: AppColors.onPrimary,
|
||
size: 15,
|
||
shadows: _playerTextShadows,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'${speed}x',
|
||
style: const TextStyle(
|
||
color: AppColors.onPrimary,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
shadows: _playerTextShadows,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BottomIconButton extends StatelessWidget {
|
||
const _BottomIconButton({
|
||
super.key,
|
||
required this.tooltip,
|
||
required this.icon,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final String tooltip;
|
||
final IconData icon;
|
||
final VoidCallback onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox.square(
|
||
dimension: 40,
|
||
child: IconButton(
|
||
tooltip: tooltip,
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: AppColors.playerOverlayPill,
|
||
foregroundColor: AppColors.onPrimary,
|
||
),
|
||
onPressed: onPressed,
|
||
icon: Icon(icon, size: 22),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EpisodeSynopsis extends StatefulWidget {
|
||
const _EpisodeSynopsis({
|
||
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;
|
||
|
||
@override
|
||
State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState();
|
||
}
|
||
|
||
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;
|
||
|
||
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
if (_detailRequested || widget.drama.detailLoaded) return;
|
||
_detailRequested = true;
|
||
unawaited(_loadFullSynopsis());
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _EpisodeSynopsis oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.drama.id == widget.drama.id &&
|
||
oldWidget.synopsis == widget.synopsis) {
|
||
return;
|
||
}
|
||
_fullSynopsis = null;
|
||
_expanded = false;
|
||
_detailRequested = !widget.drama.detailLoaded;
|
||
if (_detailRequested) unawaited(_loadFullSynopsis());
|
||
}
|
||
|
||
Future<void> _loadFullSynopsis() async {
|
||
if (widget.drama.detailLoaded) return;
|
||
final request = ++_detailRequest;
|
||
final CatalogController catalog;
|
||
try {
|
||
catalog = context.read<CatalogController>();
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
try {
|
||
final detail = await catalog.requireDetail(widget.drama);
|
||
if (!mounted || request != _detailRequest) return;
|
||
final full = localizedDramaSynopsis(widget.l10n, detail);
|
||
if (full == widget.synopsis) return;
|
||
setState(() => _fullSynopsis = full);
|
||
} catch (_) {
|
||
// 详情失败时继续用列表里的简介。
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) => _block(constraints.maxWidth),
|
||
);
|
||
}
|
||
|
||
/// 与首页推荐 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 _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),
|
||
),
|
||
);
|
||
}
|
||
}
|