// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; const Key _inactiveTrackKey = Key('progress-inactive'); const double _compactTrackSlot = 20; const double _trackSlot = 44; /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. Duration seekPositionForFraction(double fraction, Duration duration) { final total = duration.inMilliseconds; if (total <= 0) return Duration.zero; final lastPlayable = total > 250 ? total - 250 : total - 1; final requested = (fraction.clamp(0.0, 1.0) * total).round(); return Duration(milliseconds: requested.clamp(0, lastPlayable)); } class SeekProgressBar extends StatefulWidget { const SeekProgressBar({ super.key, required this.valueListenable, required this.semanticsLabel, this.onScrubStart, this.onScrubEnd, this.onSeek, this.showTimes = true, this.compact = false, this.inactiveTrackColor = AppColors.border, this.locked = false, this.thumbnailService, this.spriteAxisService, this.episodeId, this.thumbnailsUrl, this.fallbackDuration = Duration.zero, }); final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final Future Function(Duration)? onSeek; final bool showTimes; final bool compact; final Color inactiveTrackColor; final bool locked; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; final String? thumbnailsUrl; final Duration fallbackDuration; @override State createState() => _SeekProgressBarState(); } class _SeekProgressBarState extends State { static const _bufferDebounce = Duration(milliseconds: 350); final _previewFraction = ValueNotifier(null); Timer? _bufferTimer; bool _buffering = false; bool _dragging = false; bool _pointerMoved = false; Offset? _pointerStart; double? _dragStartFraction; @override void initState() { super.initState(); _attachSource(); } @override void didUpdateWidget(covariant SeekProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { _detachSource(oldWidget.valueListenable); _previewFraction.value = null; _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(); _previewFraction.value = null; } else { _syncBufferState(notify: false); } } } @override void dispose() { _detachSource(widget.valueListenable); _bufferTimer?.cancel(); if (_previewFraction.value != null) { widget.thumbnailService?.cancelPending(); } _previewFraction.dispose(); super.dispose(); } void _attachSource() { widget.valueListenable?.addListener(_handleSourceChanged); _syncBufferState(notify: false); } void _detachSource(ValueListenable? source) { source?.removeListener(_handleSourceChanged); } void _handleSourceChanged() => _syncBufferState(); void _syncBufferState({bool notify = true}) { final source = widget.valueListenable; 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.valueListenable; 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); } void _endPointerDrag() { if (!_dragging) { _pointerMoved = false; _pointerStart = null; _dragStartFraction = null; return; } final duration = widget.valueListenable?.value.duration ?? Duration.zero; final fraction = _previewFraction.value ?? _dragStartFraction ?? 0; unawaited(_finish(fraction, duration)); } Future _finish(double fraction, Duration duration) async { final startedAt = _dragStartFraction; _dragStartFraction = null; if (widget.locked || !_dragging || startedAt == null) return; _dragging = false; if (!mounted) { widget.onScrubEnd?.call(); return; } setState(() {}); _pointerMoved = false; _pointerStart = null; widget.thumbnailService?.cancelPending(); _previewFraction.value = null; widget.onScrubEnd?.call(); await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } @override Widget build(BuildContext context) { final source = widget.valueListenable; if (source == null) { return ValueListenableBuilder( valueListenable: _previewFraction, builder: (context, _, _) => _buildTrack(context, const VideoPlayerValue.uninitialized()), ); } return ValueListenableBuilder( valueListenable: source, builder: (context, value, _) => ValueListenableBuilder( valueListenable: _previewFraction, builder: (context, _, _) => _buildTrack(context, value), ), ); } Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final duration = video.duration > Duration.zero ? video.duration : widget.fallbackDuration; final total = duration.inMilliseconds; final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final enabled = !widget.locked && video.isInitialized && total > 0 && widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) : 0.0; final fraction = _previewFraction.value ?? played; final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, duration); final showPreview = scrubbing && widget.showTimes && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); final trackHeight = scrubbing ? 6.0 : 2.0; final trackAreaHeight = widget.compact ? _compactTrackSlot : _trackSlot; return LayoutBuilder( builder: (context, constraints) { final track = Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.showTimes && !video.isInitialized) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '00:00 / ${_format(duration)}', key: const Key('player-progress-uninitialized-time'), textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w600, height: 1.2, ), ), ), if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '${_format(previewPosition)} / ${_format(duration)}', textAlign: TextAlign.center, style: const TextStyle( color: AppColors.onPrimary, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2, ), ), ), SizedBox( height: trackAreaHeight, child: widget.locked || !video.isInitialized ? _inactiveTrack(trackHeight) : loading ? Center( child: LoadingProgressTrack( height: trackHeight, trackColor: widget.inactiveTrackColor, ), ) : SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: trackHeight, activeTrackColor: AppColors.blue, inactiveTrackColor: widget.inactiveTrackColor, thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( enabledThumbRadius: scrubbing ? 8 : 6, ), overlayShape: RoundSliderOverlayShape( overlayRadius: widget.compact ? 12 : 18, ), ), child: Semantics( label: widget.semanticsLabel, child: Listener( onPointerDown: (event) { _pointerStart = event.position; _pointerMoved = false; }, onPointerMove: (event) { final start = _pointerStart; if (start != null && (event.position - start).distance > 0) { _pointerMoved = true; } }, onPointerUp: (_) => _endPointerDrag(), onPointerCancel: (_) => _endPointerDrag(), child: Slider( value: fraction, // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 padding: EdgeInsets.zero, onChangeStart: enabled ? (value) { _dragStartFraction = value; _pointerMoved = false; } : null, onChanged: enabled ? (value) { if (!_pointerMoved) return; if (!_dragging) { setState(() => _dragging = true); widget.onScrubStart?.call(); } _previewFraction.value = value; } : null, onChangeEnd: enabled ? (value) => unawaited(_finish(value, video.duration)) : null, ), ), ), ), ), ], ); return Stack( clipBehavior: Clip.none, children: [ track, if (showPreview) Positioned( left: scrubPreviewLeft( trackWidth: constraints.maxWidth, fraction: fraction, ), bottom: trackAreaHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, position: previewPosition, duration: duration, ), ), ], ); }, ); } Widget _inactiveTrack(double height) { return Center( child: SizedBox( width: double.infinity, height: height, child: ColoredBox( key: _inactiveTrackKey, color: widget.inactiveTrackColor, ), ), ); } String _format(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'; } }