// 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; const Duration _trackResizeDuration = Duration(milliseconds: 120); const double _timeBubbleWidth = 96; const double _timeBubbleMargin = 12; const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 class _FeedProgressBar extends StatefulWidget { const _FeedProgressBar({ required this.controller, required this.locked, required this.height, required this.hitHeight, this.cancelScrubRequest = 0, this.onScrubStart, this.onScrubEnd, this.onSeek, }); final ValueListenable? controller; final bool locked; final double height; final double hitHeight; final int cancelScrubRequest; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; 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(); } if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { _preview = null; } if (oldWidget.locked != widget.locked) { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; if (widget.locked) { _preview = null; } else { _syncBufferState(notify: false); } } } @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 = !widget.locked && !scrubbing && _buffering; final track = widget.locked ? _inactiveTrack() : source == null ? _inactiveTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { if (!value.isInitialized) return _inactiveTrack(); if (!scrubbing && _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); }, ); final interactive = !widget.locked && !loading && (source?.value.isInitialized ?? false); final video = source?.value; final previewPosition = _preview == null || video == null ? null : seekPositionForFraction(_preview!, video.duration); final showPreview = previewPosition != null; return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, onHorizontalDragStart: interactive ? (details) { widget.onScrubStart?.call(); _update(details.localPosition.dx / width); } : null, onHorizontalDragUpdate: interactive ? (details) => _update(details.localPosition.dx / width) : null, onHorizontalDragEnd: interactive ? (_) { widget.onScrubEnd?.call(); _commit(_preview); } : null, onHorizontalDragCancel: interactive ? () { widget.onScrubEnd?.call(); _cancelPreview(); } : null, child: Stack( clipBehavior: Clip.none, children: [ track, if (showPreview) Positioned( left: _timeBubbleLeft(width, _preview!), bottom: _lineCenterFromBottom + 16, child: _ScrubTimeBubble( key: const Key('scrub-preview-time-bubble'), position: previewPosition, duration: video!.duration, ), ), ], ), ); }, ); } 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 (widget.locked || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, 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), ), ], ), ); } Widget _inactiveTrack() { final bottom = _lineCenterFromBottom - widget.height / 2; return SizedBox( height: widget.hitHeight, child: Stack( children: [ Positioned( left: 0, right: 0, bottom: bottom, child: SizedBox( height: widget.height, child: ColoredBox( key: _inactiveTrackKey, color: AppColors.border, ), ), ), ], ), ); } void _update(double fraction) { setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } void _commit(double? fraction) { if (widget.locked || 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); } void _cancelPreview() { 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 thumbSlotLeft = (width * fraction - _thumbActiveSize / 2) .clamp(0.0, width - _thumbActiveSize) .toDouble(); final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2; final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2; return SizedBox( height: widget.hitHeight, child: Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, right: 0, bottom: trackSlotBottom, child: SizedBox( height: _trackActiveHeight, child: Stack( children: [ Align( alignment: Alignment.centerLeft, child: AnimatedContainer( key: const Key('home-feed-progress-track'), duration: _trackResizeDuration, curve: Curves.easeOut, width: width, height: trackHeight, color: AppColors.border, ), ), Align( alignment: Alignment.centerLeft, child: AnimatedContainer( duration: _trackResizeDuration, curve: Curves.easeOut, width: width * fraction, height: trackHeight, color: AppColors.blue, ), ), ], ), ), ), Positioned( left: thumbSlotLeft, bottom: thumbSlotBottom, child: SizedBox.square( dimension: _thumbActiveSize, child: Center( child: AnimatedContainer( key: const Key('home-feed-progress-thumb'), duration: _trackResizeDuration, curve: Curves.easeOut, width: size, height: size, decoration: const BoxDecoration( color: AppColors.onPrimary, shape: BoxShape.circle, ), ), ), ), ), ], ), ); } double _timeBubbleLeft(double width, double fraction) { final maxLeft = width - _timeBubbleWidth - _timeBubbleMargin; if (maxLeft < _timeBubbleMargin) { return ((width - _timeBubbleWidth) / 2) .clamp(0.0, width - _timeBubbleWidth) .toDouble(); } return (width * fraction - _timeBubbleWidth / 2) .clamp(_timeBubbleMargin, maxLeft) .toDouble(); } } class _ScrubTimeBubble extends StatelessWidget { const _ScrubTimeBubble({ super.key, required this.position, required this.duration, }); final Duration position; final Duration duration; @override Widget build(BuildContext context) { return Container( width: _timeBubbleWidth, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: AppColors.timeBubbleScrim, borderRadius: BorderRadius.circular(6), ), child: Text( '${_formatTime(position)} / ${_formatTime(duration)}', key: const Key('scrub-preview-time'), textAlign: TextAlign.center, maxLines: 1, style: const TextStyle( color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w600, ), ), ); } String _formatTime(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } }