Files
ReelShort/lib/pages/player/widgets/player_overlay_parts.dart
T

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),
);
}
}