diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 74523281..8420e1ad 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -104,6 +104,8 @@ class PlayerNative extends PlayerBase { }) async { if (disposed) return; await _ensureInitialized(); + final startPosition = media.start ?? Duration.zero; + resetPlaybackProgress(startPosition); setSeekable(false); await setVisible(true); @@ -114,8 +116,8 @@ class PlayerNative extends PlayerBase { } // 'start' must be set before loadfile. - if (media.start != null && media.start!.inSeconds > 0) { - await setProperty('start', media.start!.inSeconds.toString()); + if (startPosition.inSeconds > 0) { + await setProperty('start', (startPosition.inMilliseconds / 1000.0).toString()); } else { await setProperty('start', 'none'); } diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index f7a0d6c9..99af8c7b 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -139,6 +139,11 @@ class _TimelineSliderState extends State { // Calculate the actual track width by subtracting the thumb padding on each side final trackWidth = sliderWidth - 2 * _sliderPadding; final durationMs = widget.duration.inMilliseconds; + final max = durationMs > 0 ? durationMs.toDouble() : 0.0; + final displayValue = max > 0 + ? (_dragValue ?? widget.position.inMilliseconds.toDouble()).clamp(0.0, max).toDouble() + : 0.0; + final displayPosition = Duration(milliseconds: displayValue.toInt()); // Resolve tooltip position (drag takes priority over hover) Widget? tooltip; @@ -146,9 +151,9 @@ class _TimelineSliderState extends State { if (_dragValue != null) { // Convert drag value (ms) to a 0..1 fraction, then map to pixel // position on the track (offset by padding to align with the slider) - final fraction = (_dragValue! / durationMs).clamp(0.0, 1.0); + final fraction = (displayValue / durationMs).clamp(0.0, 1.0); final px = _sliderPadding + fraction * trackWidth; - tooltip = _buildTooltip(sliderWidth, px, Duration(milliseconds: _dragValue!.toInt())); + tooltip = _buildTooltip(sliderWidth, px, displayPosition); } else if (_mousePosition != null) { // Convert mouse pixel position to a 0..1 fraction of the track // (subtract padding to get position relative to track start), @@ -160,9 +165,9 @@ class _TimelineSliderState extends State { // Preview thumbnail at the current playback position while the // user holds a dpad/keyboard direction. The decoder lags behind // rapid seeks, so the BIF thumbnail is the only live feedback. - final fraction = (widget.position.inMilliseconds / durationMs).clamp(0.0, 1.0); + final fraction = (displayValue / durationMs).clamp(0.0, 1.0); final px = _sliderPadding + fraction * trackWidth; - tooltip = _buildTooltip(sliderWidth, px, widget.position); + tooltip = _buildTooltip(sliderWidth, px, displayPosition); } } @@ -205,9 +210,9 @@ class _TimelineSliderState extends State { label: t.videoControls.timelineSlider, slider: true, child: Slider( - value: widget.duration.inMilliseconds > 0 ? widget.position.inMilliseconds.toDouble() : 0.0, + value: displayValue, min: 0.0, - max: widget.duration.inMilliseconds.toDouble(), + max: max, onChanged: (value) { setState(() => _dragValue = value); widget.onSeek(Duration(milliseconds: value.toInt())); diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 29f96f09..d2243bdd 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -57,7 +57,7 @@ class MpvPlayerCoreBase: NSObject { /// Properties that must still flow to Dart while backgrounded (state-critical). private static let criticalProperties: Set = [ - "pause", "eof-reached", "paused-for-cache", + "pause", "eof-reached", "paused-for-cache", "time-pos", "duration", "seekable", ] private static let internalSigPeakObserverId: UInt64 = UInt64.max - 1 diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index b0676ca5..e61077f9 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -139,5 +139,55 @@ void main() { expect((customPaint.painter! as BufferRangePainter).chapters, isEmpty); }); + + testWidgets('clamps stale position beyond duration before building slider', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: TimelineSlider( + position: const Duration(minutes: 12), + duration: const Duration(minutes: 10), + chapters: const [], + chaptersLoaded: true, + onSeek: (_) {}, + onSeekEnd: (_) {}, + ), + ), + ), + ), + ); + + final slider = tester.widget(find.byType(Slider)); + + expect(slider.value, const Duration(minutes: 10).inMilliseconds.toDouble()); + expect(slider.max, const Duration(minutes: 10).inMilliseconds.toDouble()); + }); + + testWidgets('clamps stale position when duration is unknown', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: TimelineSlider( + position: const Duration(minutes: 12), + duration: Duration.zero, + chapters: const [], + chaptersLoaded: true, + onSeek: (_) {}, + onSeekEnd: (_) {}, + ), + ), + ), + ), + ); + + final slider = tester.widget(find.byType(Slider)); + + expect(slider.value, 0.0); + expect(slider.max, 0.0); + }); }); }