// Author: fruit.com.cn part of 'home_feed_overlay.dart'; const double _thumbSize = 6; const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; /// 整集条底部的进度线: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> { static const _bufferDebounce = Duration(milliseconds: 350); double? _preview; Timer? _bufferTimer; bool _buffering = false; @override void initState() { super.initState(); _attachSource(); } @override void didUpdateWidget(covariant _FeedProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { _detachSource(oldWidget.controller); _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; _attachSource(); } } @override void dispose() { _detachSource(widget.controller); _bufferTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final source = widget.controller; final scrubbing = _preview != null; final loading = source == null || (!scrubbing && (!source.value.isInitialized || _buffering)); final track = source == null ? _loadingTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { if (!scrubbing && (!value.isInitialized || _buffering)) { return _loadingTrack(); } 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: loading ? null : (details) { widget.onScrubStart?.call(); _update(details.localPosition.dx / width); }, onHorizontalDragUpdate: loading ? null : (details) => _update(details.localPosition.dx / width), onHorizontalDragEnd: loading ? null : (_) => _commit(_preview), onHorizontalDragCancel: loading ? null : () => setState(() => _preview = null), child: track, ); }, ); } void _attachSource() { widget.controller?.addListener(_handleSourceChanged); _syncBufferState(notify: false); } void _detachSource(ValueListenable? source) { source?.removeListener(_handleSourceChanged); } void _handleSourceChanged() => _syncBufferState(); void _syncBufferState({bool notify = true}) { final source = widget.controller; if (source == null) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(true, notify: notify); return; } if (!value.isBuffering) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); return; } if (_buffering || _bufferTimer != null) return; _bufferTimer = Timer(_bufferDebounce, () { _bufferTimer = null; final current = widget.controller; if (!mounted || current != source || !current!.value.isInitialized || !current.value.isBuffering) { return; } _setBuffering(true); }); } void _setBuffering(bool buffering, {bool notify = true}) { if (_buffering == buffering) return; if (!notify || !mounted) { _buffering = buffering; return; } setState(() => _buffering = buffering); } Widget _loadingTrack() { final bottom = _lineCenterFromBottom - widget.height / 2; return SizedBox( height: widget.hitHeight, child: Stack( children: [ Positioned( left: 0, right: 0, bottom: bottom, child: LoadingProgressTrack(height: widget.height), ), ], ), ); } 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, ), ), ), ], ), ); } }