// 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 _loadFullSynopsis() async { if (widget.drama.detailLoaded) return; final request = ++_detailRequest; final CatalogController catalog; try { catalog = context.read(); } 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 _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 _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), ); } }