// Author: fruit.com.cn part of 'home_feed_overlay.dart'; const double _thumbSize = 6; const double _thumbActiveSize = 10; const double _trackActiveHeight = 4; const double _lineCenterFromBottom = 6; /// 顶部频道 tab:漫剧点击提示即将上线。 enum _FeedTab { anime, real, recommend; bool get comingSoon => this == _FeedTab.anime; String get keyName => switch (this) { _FeedTab.anime => 'anime', _FeedTab.real => 'real', _FeedTab.recommend => 'recommend', }; String label(AppLocalizations l10n) => switch (this) { _FeedTab.anime => l10n.feedTabAnime, _FeedTab.real => l10n.feedTabReal, _FeedTab.recommend => l10n.feedTabForYou, }; } class _TopIconButton extends StatelessWidget { const _TopIconButton({ super.key, required this.tooltip, required this.icon, required this.dimension, required this.glyphSize, required this.onPressed, }); final String tooltip; final IconData icon; final double dimension; final double glyphSize; final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox.square( dimension: dimension, child: IconButton( tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, icon: Icon( icon, size: glyphSize, color: Colors.white, shadows: const [Shadow(blurRadius: 6, color: Colors.black)], ), ), ); } } /// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。 class _RefreshButton extends StatelessWidget { const _RefreshButton({ required this.tooltip, required this.dimension, required this.glyphSize, required this.onTap, required this.onLongPress, }); final String tooltip; final double dimension; final double glyphSize; final VoidCallback onTap; final VoidCallback onLongPress; @override Widget build(BuildContext context) { return Tooltip( message: tooltip, child: GestureDetector( key: const Key('home-feed-refresh'), behavior: HitTestBehavior.opaque, onTap: onTap, onLongPress: onLongPress, child: SizedBox.square( dimension: dimension, child: Icon( Icons.refresh_rounded, size: glyphSize, color: Colors.white, shadows: const [Shadow(blurRadius: 6, color: Colors.black)], ), ), ), ); } } class _FeedTabItem extends StatelessWidget { const _FeedTabItem({ required this.tab, required this.label, required this.selected, required this.fontSize, required this.underlineWidth, required this.underlineHeight, required this.onTap, }); final _FeedTab tab; final String label; final bool selected; final double fontSize; final double underlineWidth; final double underlineHeight; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( key: Key('home-feed-tab-${tab.keyName}'), behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, maxLines: 1, style: TextStyle( color: selected ? Colors.white : Colors.white70, fontSize: fontSize, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, shadows: const [Shadow(blurRadius: 6, color: Colors.black)], ), ), const SizedBox(height: 4), Container( width: underlineWidth, height: underlineHeight, decoration: BoxDecoration( color: selected ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(1), ), ), ], ), ); } } /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 class _FeedProgressBar extends StatefulWidget { const _FeedProgressBar({ required this.controller, required this.height, required this.hitHeight, this.onScrubStart, this.onSeek, }); final ValueListenable? controller; final double height; final double hitHeight; final VoidCallback? onScrubStart; final ValueChanged? onSeek; @override State<_FeedProgressBar> createState() => _FeedProgressBarState(); } class _FeedProgressBarState extends State<_FeedProgressBar> { double? _preview; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final source = widget.controller; final track = source == null ? _track(0, width) : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { final total = value.duration.inMilliseconds; final played = total <= 0 ? 0.0 : (value.position.inMilliseconds / total) .clamp(0.0, 1.0) .toDouble(); return _track(_preview ?? played, width); }, ); return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, onHorizontalDragStart: (details) { widget.onScrubStart?.call(); _update(details.localPosition.dx / width); }, onHorizontalDragUpdate: (details) => _update(details.localPosition.dx / width), onHorizontalDragEnd: (_) => _commit(_preview), onHorizontalDragCancel: () => setState(() => _preview = null), child: track, ); }, ); } void _update(double fraction) { setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } void _commit(double? fraction) { if (fraction == null) return; final total = widget.controller?.value.duration.inMilliseconds ?? 0; if (total > 0) { widget.onSeek?.call( Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), ); } setState(() => _preview = null); } Widget _track(double fraction, double width) { final dragging = _preview != null; final trackHeight = dragging ? _trackActiveHeight : widget.height; final size = dragging ? _thumbActiveSize : _thumbSize; final left = (width * fraction - size / 2) .clamp(0.0, width - size) .toDouble(); final lineBottom = _lineCenterFromBottom - trackHeight / 2; final thumbBottom = _lineCenterFromBottom - size / 2; return SizedBox( height: widget.hitHeight, child: Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, right: 0, bottom: lineBottom, child: Container( key: const Key('home-feed-progress-track'), height: trackHeight, color: Colors.white24, ), ), Positioned( left: 0, bottom: lineBottom, child: Container( width: width * fraction, height: trackHeight, color: AppColors.blue, ), ), Positioned( left: left, bottom: thumbBottom, child: Container( key: const Key('home-feed-progress-thumb'), width: size, height: size, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), ), ), ], ), ); } } class _FeedMoreSheet extends StatelessWidget { const _FeedMoreSheet({ required this.l10n, required this.onDownload, required this.onClear, this.onPip, }); final AppLocalizations l10n; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback? onPip; @override Widget build(BuildContext context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( key: const Key('home-feed-download'), leading: const Icon( Icons.download_outlined, color: AppColors.secondary, ), title: Text(l10n.downloads), onTap: () { Navigator.pop(context); onDownload(); }, ), if (onPip != null) ListTile( key: const Key('home-feed-pip'), leading: const Icon( Icons.picture_in_picture_alt_outlined, color: AppColors.secondary, ), title: Text(l10n.pictureInPicture), onTap: () { Navigator.pop(context); onPip!(); }, ), ListTile( key: const Key('home-feed-clear'), leading: const Icon( Icons.fullscreen_rounded, color: AppColors.secondary, ), title: Text(l10n.clearScreen), onTap: () { Navigator.pop(context); onClear(); }, ), ], ), ); } }