444 lines
14 KiB
Dart
444 lines
14 KiB
Dart
// 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 Duration _trackResizeDuration = Duration(milliseconds: 120);
|
|
const double _timeBubbleWidth = 96;
|
|
const double _timeBubbleMargin = 12;
|
|
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.cancelScrubRequest = 0,
|
|
this.thumbnailService,
|
|
this.spriteAxisService,
|
|
this.thumbnailsUrl,
|
|
this.episodeId,
|
|
this.posterUrl = '',
|
|
this.onScrubStart,
|
|
this.onScrubEnd,
|
|
this.onSeek,
|
|
});
|
|
|
|
final ValueListenable<VideoPlayerValue>? controller;
|
|
final bool locked;
|
|
final double height;
|
|
final double hitHeight;
|
|
final int cancelScrubRequest;
|
|
final PlaybackThumbnailService? thumbnailService;
|
|
final SpriteThumbnailAxisSource? spriteAxisService;
|
|
final String? thumbnailsUrl;
|
|
final String? episodeId;
|
|
final String posterUrl;
|
|
final VoidCallback? onScrubStart;
|
|
final VoidCallback? onScrubEnd;
|
|
final ValueChanged<Duration>? onSeek;
|
|
|
|
@override
|
|
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
|
}
|
|
|
|
class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|
static const _bufferDebounce = Duration(milliseconds: 350);
|
|
|
|
double? _preview;
|
|
Timer? _bufferTimer;
|
|
bool _buffering = false;
|
|
bool _dragging = 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;
|
|
_attachSource();
|
|
}
|
|
if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) {
|
|
_dragging = false;
|
|
_preview = null;
|
|
}
|
|
if (oldWidget.locked != widget.locked) {
|
|
_bufferTimer?.cancel();
|
|
_bufferTimer = null;
|
|
_buffering = false;
|
|
if (widget.locked) {
|
|
_dragging = false;
|
|
_preview = null;
|
|
} else {
|
|
_syncBufferState(notify: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_detachSource(widget.controller);
|
|
_bufferTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final source = widget.controller;
|
|
final scrubbing = _dragging;
|
|
final loading = !widget.locked && !scrubbing && _buffering;
|
|
final track = widget.locked
|
|
? _inactiveTrack()
|
|
: source == null
|
|
? _inactiveTrack()
|
|
: ValueListenableBuilder<VideoPlayerValue>(
|
|
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 = !_dragging || _preview == null || video == null
|
|
? null
|
|
: seekPositionForFraction(_preview!, video.duration);
|
|
final showPreview = previewPosition != null;
|
|
return Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerUp: (_) => _finishScrub(),
|
|
onPointerCancel: (_) => _finishScrub(),
|
|
child: GestureDetector(
|
|
key: const Key('home-feed-progress'),
|
|
behavior: HitTestBehavior.opaque,
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
onHorizontalDragStart: interactive
|
|
? (details) => _startScrub(details.localPosition.dx / width)
|
|
: null,
|
|
onHorizontalDragUpdate: interactive
|
|
? (details) => _update(details.localPosition.dx / width)
|
|
: null,
|
|
onHorizontalDragEnd: interactive ? (_) => _finishScrub() : null,
|
|
onHorizontalDragCancel: interactive ? _finishScrub : null,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
track,
|
|
if (showPreview &&
|
|
widget.thumbnailService != null &&
|
|
widget.episodeId != null)
|
|
Positioned(
|
|
left: scrubPreviewLeft(
|
|
trackWidth: width,
|
|
fraction: _preview!,
|
|
),
|
|
bottom: _lineCenterFromBottom + 16,
|
|
child: ScrubPreviewCard(
|
|
service: widget.thumbnailService!,
|
|
spriteAxisService: widget.spriteAxisService,
|
|
thumbnailsUrl: widget.thumbnailsUrl,
|
|
episodeId: widget.episodeId!,
|
|
posterUrl: widget.posterUrl,
|
|
position: previewPosition,
|
|
duration: video!.duration,
|
|
),
|
|
)
|
|
else if (showPreview)
|
|
Positioned(
|
|
left: _timeBubbleLeft(width, _preview!),
|
|
bottom: _lineCenterFromBottom + 16,
|
|
child: _ScrubTimeBubble(
|
|
key: const Key('scrub-preview-time-bubble'),
|
|
position: previewPosition,
|
|
duration: video!.duration,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _attachSource() {
|
|
widget.controller?.addListener(_handleSourceChanged);
|
|
_syncBufferState(notify: false);
|
|
}
|
|
|
|
void _detachSource(ValueListenable<VideoPlayerValue>? 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 _startScrub(double fraction) {
|
|
if (!mounted || _dragging) return;
|
|
setState(() {
|
|
_dragging = true;
|
|
_preview = fraction.clamp(0.0, 1.0).toDouble();
|
|
});
|
|
widget.onScrubStart?.call();
|
|
}
|
|
|
|
void _update(double fraction) {
|
|
if (!mounted || !_dragging) return;
|
|
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
|
}
|
|
|
|
void _finishScrub() {
|
|
if (!mounted || !_dragging) return;
|
|
final fraction = _preview;
|
|
setState(() {
|
|
_dragging = false;
|
|
_preview = null;
|
|
});
|
|
widget.onScrubEnd?.call();
|
|
if (widget.locked || fraction == null) return;
|
|
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
|
if (total <= 0) return;
|
|
widget.onSeek?.call(Duration(milliseconds: (total * fraction).round()));
|
|
}
|
|
|
|
Widget _track(double fraction, double width) {
|
|
final dragging = _preview != null;
|
|
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
|
final size = dragging ? _thumbActiveSize : _thumbSize;
|
|
final finger = (width * fraction).clamp(0.0, width).toDouble();
|
|
final thumbSlotLeft = dragging
|
|
? finger - _thumbActiveSize / 2
|
|
: (finger - _thumbActiveSize / 2)
|
|
.clamp(0.0, width - _thumbActiveSize)
|
|
.toDouble();
|
|
final playedWidth = dragging ? finger : width * fraction;
|
|
final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
|
|
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
|
|
return SizedBox(
|
|
height: widget.hitHeight,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: trackSlotBottom,
|
|
child: SizedBox(
|
|
height: _trackActiveHeight,
|
|
child: Stack(
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: AnimatedContainer(
|
|
key: const Key('home-feed-progress-track'),
|
|
duration: _trackResizeDuration,
|
|
curve: Curves.easeOut,
|
|
width: width,
|
|
height: trackHeight,
|
|
color: AppColors.border,
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: AnimatedContainer(
|
|
key: const Key('home-feed-progress-played'),
|
|
duration: dragging ? Duration.zero : _trackResizeDuration,
|
|
curve: Curves.easeOut,
|
|
width: playedWidth,
|
|
height: trackHeight,
|
|
color: AppColors.blue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: thumbSlotLeft,
|
|
bottom: thumbSlotBottom,
|
|
child: SizedBox.square(
|
|
dimension: _thumbActiveSize,
|
|
child: Center(
|
|
child: AnimatedContainer(
|
|
key: const Key('home-feed-progress-thumb'),
|
|
duration: _trackResizeDuration,
|
|
curve: Curves.easeOut,
|
|
width: size,
|
|
height: size,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.onPrimary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
double _timeBubbleLeft(double width, double fraction) {
|
|
final maxLeft = width - _timeBubbleWidth - _timeBubbleMargin;
|
|
if (maxLeft < _timeBubbleMargin) {
|
|
return ((width - _timeBubbleWidth) / 2)
|
|
.clamp(0.0, width - _timeBubbleWidth)
|
|
.toDouble();
|
|
}
|
|
return (width * fraction - _timeBubbleWidth / 2)
|
|
.clamp(_timeBubbleMargin, maxLeft)
|
|
.toDouble();
|
|
}
|
|
}
|
|
|
|
class _ScrubTimeBubble extends StatelessWidget {
|
|
const _ScrubTimeBubble({
|
|
super.key,
|
|
required this.position,
|
|
required this.duration,
|
|
});
|
|
|
|
final Duration position;
|
|
final Duration duration;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: _timeBubbleWidth,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.timeBubbleScrim,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'${_formatTime(position)} / ${_formatTime(duration)}',
|
|
key: const Key('scrub-preview-time'),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatTime(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';
|
|
}
|
|
}
|