258 lines
8.0 KiB
Dart
258 lines
8.0 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
import '../../../core/theme/app_colors.dart';
|
|
|
|
/// 1.5px player scrubber with a buffered segment and white thumb.
|
|
class CardSeekBar extends StatefulWidget {
|
|
const CardSeekBar({
|
|
super.key,
|
|
required this.controller,
|
|
required this.semanticsLabel,
|
|
required this.episodeLabel,
|
|
required this.totalEpisodes,
|
|
this.onScrubStart,
|
|
this.onSeek,
|
|
});
|
|
|
|
final VideoPlayerController? controller;
|
|
final String semanticsLabel;
|
|
final String episodeLabel;
|
|
final int totalEpisodes;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
|
|
@override
|
|
State<CardSeekBar> createState() => _CardSeekBarState();
|
|
}
|
|
|
|
class _CardSeekBarState extends State<CardSeekBar> {
|
|
final ValueNotifier<double?> _preview = ValueNotifier(null);
|
|
|
|
@override
|
|
void dispose() {
|
|
_preview.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = widget.controller;
|
|
if (controller == null) {
|
|
return _track(context, const VideoPlayerValue.uninitialized());
|
|
}
|
|
return ValueListenableBuilder<VideoPlayerValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, _) {
|
|
return ValueListenableBuilder<double?>(
|
|
valueListenable: _preview,
|
|
builder: (context, _, _) => _track(context, value),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _track(BuildContext context, VideoPlayerValue video) {
|
|
final total = video.duration.inMilliseconds;
|
|
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
|
|
final played = total > 0
|
|
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
|
|
: 0.0;
|
|
final fraction = _preview.value ?? played;
|
|
final buffered = _bufferedFraction(video);
|
|
final previewPosition = _preview.value == null
|
|
? video.position
|
|
: Duration(milliseconds: (fraction * total).round());
|
|
|
|
return Column(
|
|
key: const Key('card-player-progress-bar'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 24,
|
|
child: SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
trackHeight: 1.5,
|
|
activeTrackColor: AppColors.blue,
|
|
inactiveTrackColor: Colors.white.withValues(alpha: 0.15),
|
|
thumbColor: Colors.white,
|
|
overlayColor: AppColors.blue.withValues(alpha: 0.16),
|
|
trackShape: _BufferedTrackShape(buffered: buffered),
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
|
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
|
),
|
|
child: Semantics(
|
|
label: widget.semanticsLabel,
|
|
child: Slider(
|
|
key: const Key('card-player-progress'),
|
|
value: fraction,
|
|
onChangeStart: enabled
|
|
? (value) {
|
|
_preview.value = value;
|
|
widget.onScrubStart?.call();
|
|
}
|
|
: null,
|
|
onChanged: enabled ? (value) => _preview.value = value : null,
|
|
onChangeEnd: enabled
|
|
? (value) => unawaited(_finish(value, video.duration))
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
_format(previewPosition),
|
|
key: const Key('card-player-time-current'),
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
'${widget.episodeLabel} / ${widget.totalEpisodes}',
|
|
key: const Key('card-player-episode'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.subtle,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
_format(video.duration),
|
|
key: const Key('card-player-time-total'),
|
|
style: const TextStyle(color: AppColors.subtle, fontSize: 10),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _finish(double fraction, Duration duration) async {
|
|
final source = widget.controller;
|
|
try {
|
|
final total = duration.inMilliseconds;
|
|
final target = total > 250
|
|
? (fraction * total).round().clamp(0, total - 250)
|
|
: 0;
|
|
await widget.onSeek?.call(Duration(milliseconds: target));
|
|
} finally {
|
|
if (mounted && widget.controller == source) _preview.value = null;
|
|
}
|
|
}
|
|
|
|
double _bufferedFraction(VideoPlayerValue video) {
|
|
final total = video.duration.inMilliseconds;
|
|
if (total <= 0 || video.buffered.isEmpty) return 0;
|
|
var end = Duration.zero;
|
|
for (final range in video.buffered) {
|
|
if (range.end > end) end = range.end;
|
|
}
|
|
return (end.inMilliseconds / total).clamp(0.0, 1.0);
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape {
|
|
const _BufferedTrackShape({required this.buffered});
|
|
|
|
final double buffered;
|
|
|
|
@override
|
|
Rect getPreferredRect({
|
|
required RenderBox parentBox,
|
|
Offset offset = Offset.zero,
|
|
required SliderThemeData sliderTheme,
|
|
bool isEnabled = false,
|
|
bool isDiscrete = false,
|
|
}) {
|
|
final thumbWidth =
|
|
sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ??
|
|
0;
|
|
final overlayWidth =
|
|
sliderTheme.overlayShape
|
|
?.getPreferredSize(isEnabled, isDiscrete)
|
|
.width ??
|
|
0;
|
|
final trackHeight = sliderTheme.trackHeight ?? 1.5;
|
|
final trackLeft = offset.dx + overlayWidth / 2;
|
|
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
|
|
final trackWidth =
|
|
parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth;
|
|
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
|
|
}
|
|
|
|
@override
|
|
void paint(
|
|
PaintingContext context,
|
|
Offset offset, {
|
|
required RenderBox parentBox,
|
|
required SliderThemeData sliderTheme,
|
|
required Animation<double> enableAnimation,
|
|
required TextDirection textDirection,
|
|
required Offset thumbCenter,
|
|
Offset? secondaryOffset,
|
|
bool isEnabled = false,
|
|
bool isDiscrete = false,
|
|
bool isSecondaryTrack = false,
|
|
}) {
|
|
final rect = getPreferredRect(
|
|
parentBox: parentBox,
|
|
offset: offset,
|
|
sliderTheme: sliderTheme,
|
|
isEnabled: isEnabled,
|
|
isDiscrete: isDiscrete,
|
|
);
|
|
final radius = Radius.circular(rect.height);
|
|
final canvas = context.canvas;
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(rect, radius),
|
|
Paint()..color = Colors.white.withValues(alpha: 0.15),
|
|
);
|
|
if (buffered > 0) {
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(
|
|
rect.left,
|
|
rect.top,
|
|
rect.width * buffered,
|
|
rect.height,
|
|
),
|
|
radius,
|
|
),
|
|
Paint()..color = Colors.white.withValues(alpha: 0.30),
|
|
);
|
|
}
|
|
final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0);
|
|
if (played > 0) {
|
|
canvas.drawRRect(
|
|
RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height),
|
|
radius,
|
|
),
|
|
Paint()..color = AppColors.blue,
|
|
);
|
|
}
|
|
}
|
|
}
|