// 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'); /// 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.onSeek, this.showTimes = true, this.compact = false, this.inactiveTrackColor = AppColors.border, this.locked = false, this.thumbnailService, this.spriteAxisService, this.episodeId, this.posterUrl = '', this.thumbnailsUrl, }); final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; 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 posterUrl; final String? thumbnailsUrl; @override State createState() => _SeekProgressBarState(); } class _SeekProgressBarState extends State { static const _bufferDebounce = Duration(milliseconds: 350); final _previewFraction = ValueNotifier(null); Timer? _bufferTimer; bool _buffering = false; @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); } Future _finish(double fraction, Duration duration) async { if (widget.locked) return; _previewFraction.value = fraction; widget.thumbnailService?.cancelPending(); _previewFraction.value = null; 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 total = video.duration.inMilliseconds; final scrubbing = _previewFraction.value != null; 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, video.duration); final showPreview = scrubbing && widget.showTimes && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); final trackHeight = widget.compact ? (scrubbing ? 3.0 : 1.5) : (scrubbing ? 4.0 : 3.0); final trackAreaHeight = loading ? (widget.compact ? 20.0 : 44.0) : widget.compact ? (scrubbing ? 32.0 : 20.0) : (scrubbing ? 48.0 : 44.0); return LayoutBuilder( builder: (context, constraints) { final track = Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '${_format(previewPosition)} / ${_format(video.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: widget.compact ? (scrubbing ? 6 : 4) : (scrubbing ? 8 : 6), ), overlayShape: RoundSliderOverlayShape( overlayRadius: widget.compact ? 12 : 18, ), ), child: Semantics( label: widget.semanticsLabel, child: Slider( value: fraction, // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 padding: EdgeInsets.zero, onChangeStart: enabled ? (value) { _previewFraction.value = value; widget.onScrubStart?.call(); } : null, onChanged: enabled ? (value) => _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!, posterUrl: widget.posterUrl, position: previewPosition, duration: video.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'; } }