// 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 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.onScrubStart, this.onSeek, this.thumbnailService, this.spriteAxisService, this.thumbnailsUrl, required this.episodeId, required this.posterUrl, }); final ValueListenable? controller; final bool locked; final double height; final double hitHeight; final VoidCallback? onScrubStart; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final String? thumbnailsUrl; final String episodeId; final String posterUrl; @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; widget.thumbnailService?.cancelPending(); _attachSource(); } if (oldWidget.locked != widget.locked) { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; if (widget.locked) { widget.thumbnailService?.cancelPending(); _preview = null; } else { _syncBufferState(notify: false); } } } @override void dispose() { _detachSource(widget.controller); _bufferTimer?.cancel(); if (_preview != null) widget.thumbnailService?.cancelPending(); 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 && widget.thumbnailService != null && widget.episodeId.isNotEmpty; 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 ? (_) => _commit(_preview) : null, onHorizontalDragCancel: interactive ? _cancelPreview : null, child: Stack( clipBehavior: Clip.none, children: [ track, if (showPreview) Positioned( left: scrubPreviewLeft( trackWidth: width, fraction: _preview!, ), bottom: widget.hitHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId, posterUrl: widget.posterUrl, 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()), ); } widget.thumbnailService?.cancelPending(); setState(() => _preview = null); } void _cancelPreview() { widget.thumbnailService?.cancelPending(); 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: AppColors.border, ), ), 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: AppColors.onPrimary, shape: BoxShape.circle, ), ), ), ], ), ); } }