// 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 _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), ); } /// 与首页推荐 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), ), ); } }