436 lines
12 KiB
Dart
436 lines
12 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.playerMuted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
height: 1.35,
|
|
shadows: _playerTextShadows,
|
|
);
|
|
static const TextStyle _linkStyle = TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
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;
|
|
|
|
@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),
|
|
);
|
|
}
|
|
|
|
Widget _block(double maxWidth) {
|
|
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
|
if (_expanded) return _expandedBlock(label, maxWidth);
|
|
return _collapsedBlock(label, maxWidth);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
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(
|
|
key: const Key('player-synopsis-toggle'),
|
|
onTap: () => setState(() => _expanded = !_expanded),
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Text(label, maxLines: 1, style: _linkStyle),
|
|
);
|
|
}
|
|
}
|