380 lines
13 KiB
Dart
380 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 {
|
|
if (widget.locked || !_dragging) return;
|
|
setState(() => _dragging = false);
|
|
_pointerMoved = false;
|
|
_pointerStart = null;
|
|
_dragStartFraction = 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';
|
|
}
|
|
}
|