Files
ReelShort/lib/pages/player/widgets/seek_progress_bar.dart
T

381 lines
13 KiB
Dart

// 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.onScrubEnd,
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<VideoPlayerValue>? valueListenable;
final String semanticsLabel;
final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd;
final Future<void> 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<SeekProgressBar> createState() => _SeekProgressBarState();
}
class _SeekProgressBarState extends State<SeekProgressBar> {
static const _bufferDebounce = Duration(milliseconds: 350);
final _previewFraction = ValueNotifier<double?>(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<VideoPlayerValue>? 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<void> _finish(double fraction, Duration duration) async {
final startedAt = _dragStartFraction;
_dragStartFraction = null;
if (widget.locked || !_dragging || startedAt == null) return;
setState(() => _dragging = false);
_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<double?>(
valueListenable: _previewFraction,
builder: (context, _, _) =>
_buildTrack(context, const VideoPlayerValue.uninitialized()),
);
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => ValueListenableBuilder<double?>(
valueListenable: _previewFraction,
builder: (context, _, _) => _buildTrack(context, value),
),
);
}
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.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, video.duration);
final showPreview =
scrubbing &&
widget.showTimes &&
widget.thumbnailService != null &&
(widget.episodeId?.isNotEmpty ?? false);
final trackHeight = scrubbing ? 6.0 : 2.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: 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: (_) {
if (!_dragging) {
_pointerMoved = false;
_pointerStart = null;
_dragStartFraction = null;
}
},
onPointerCancel: (_) {
if (!_dragging) {
_pointerMoved = false;
_pointerStart = null;
_dragStartFraction = null;
}
},
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!,
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';
}
}