Files
ReelShort/lib/pages/player/widgets/seek_progress_bar.dart
T
Vicky 77d5748dbb chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。

只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
2026-10-15 17:53:27 +08:00

157 lines
5.1 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// 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,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
final String semanticsLabel;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
}
class _SeekProgressBarState extends State<SeekProgressBar> {
final _previewFraction = ValueNotifier<double?>(null);
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_previewFraction.value = null;
}
}
@override
void dispose() {
_previewFraction.dispose();
super.dispose();
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
_previewFraction.value = fraction;
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
_previewFraction.value = null;
}
}
}
@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 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 = _previewFraction.value ?? played;
final previewPosition = _previewFraction.value == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction.value != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showTimes && scrubbing)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_format(previewPosition)} / ${_format(video.duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
SizedBox(
height: scrubbing ? 48 : 44,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: scrubbing ? 4 : 3,
activeTrackColor: const Color(0xFF2F7BFF),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: scrubbing ? 8 : 6,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
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,
),
),
),
),
],
);
}
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';
}
}