diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d7fe40a..f828177 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -427,11 +427,13 @@ class _VideoPlayerScreenState extends ConsumerState { } // Resume from the saved spot unless it's been fully watched (then start - // over). A rewind gives the viewer a moment of context. + // over). A rewind gives the viewer a moment of context. The native duration + // can still be unknown for progressive streams even after initialization, + // so an unknown duration must not collapse a valid resume target to zero. if (resumeSeconds > 0 && !fullyWatched) { - final resumePos = (resumeSeconds - AppConstants.skipBackwardSeconds) - .clamp(0, player.duration.inSeconds); - await player.seekTo(Duration(seconds: resumePos)); + await player.seekTo( + resumePlaybackPosition(resumeSeconds, player.duration), + ); _resumeFromSeconds = resumeSeconds; } @@ -495,17 +497,16 @@ class _VideoPlayerScreenState extends ConsumerState { // Resume from where the viewer left off (rewound a little so they can // re-orient). A fresh or fully-watched video starts from the top. Clamp to - // the player's ACTUAL duration, not the server's durationSeconds (which can - // be stale/wrong and would otherwise drag the resume back toward the - // start while the banner still shows the saved position). Mirrors the - // offline path. + // the player's ACTUAL duration when it is known, not the server's + // durationSeconds (which can be stale/wrong and would otherwise drag the + // resume back toward the start while the banner still shows the saved + // position). Progressive streams can temporarily report zero duration; in + // that case leave the upper clamp to the native player. Mirrors the offline + // path. if (video.canResume) { - final resumePos = - (video.watchPositionSeconds - AppConstants.skipBackwardSeconds).clamp( - 0, - player.duration.inSeconds, - ); - await player.seekTo(Duration(seconds: resumePos)); + await player.seekTo( + resumePlaybackPosition(video.watchPositionSeconds, player.duration), + ); _resumeFromSeconds = video.watchPositionSeconds; } @@ -1456,6 +1457,26 @@ double holdSeekRateAt(double heldSeconds) { : rate; } +/// Playback target for a saved watch position. +/// +/// A short rewind provides context. A usable native duration remains the +/// authoritative upper bound, but progressive iOS streams can report zero while +/// AVPlayer already knows how to seek them. Treating that zero as an upper bound +/// would turn every resume into a seek to the beginning. +@visibleForTesting +Duration resumePlaybackPosition( + int watchPositionSeconds, + Duration nativeDuration, +) { + final rewoundSeconds = + watchPositionSeconds - AppConstants.skipBackwardSeconds; + final target = Duration(seconds: rewoundSeconds > 0 ? rewoundSeconds : 0); + if (nativeDuration > Duration.zero && target > nativeDuration) { + return nativeDuration; + } + return target; +} + /// Duration used by the seek timeline. Native media duration is authoritative /// when available; progressive iOS sources can temporarily report zero even /// while position advances, so backend metadata is the fallback for that case. diff --git a/lib/services/playback/nf_playback_controller.dart b/lib/services/playback/nf_playback_controller.dart index a44e21e..0efaeaf 100644 --- a/lib/services/playback/nf_playback_controller.dart +++ b/lib/services/playback/nf_playback_controller.dart @@ -64,9 +64,10 @@ abstract class NfPlaybackController extends ChangeNotifier { /// Leave Picture-in-Picture. No-op where unsupported. Future exitPip(); - /// Load the source and complete once the video is initialized (duration - /// known). Throws if the source fails to load; callers bound this with a - /// timeout and dispose on failure. + /// Load the source and complete once the video is initialized. Some + /// progressive sources still report an unknown/zero duration at this point; + /// callers must not treat that as a real upper bound. Throws if the source + /// fails to load; callers bound this with a timeout and dispose on failure. Future initialize(); Future play(); diff --git a/test/unit/playback_timeline_test.dart b/test/unit/playback_timeline_test.dart index ee8e9d4..a1adc7a 100644 --- a/test/unit/playback_timeline_test.dart +++ b/test/unit/playback_timeline_test.dart @@ -2,6 +2,33 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nullfeed/screens/video_player_screen.dart'; void main() { + group('resume playback target', () { + test('preserves the saved target while native duration is unknown', () { + expect( + resumePlaybackPosition(120, Duration.zero), + const Duration(seconds: 110), + ); + }); + + test('uses a known native duration as the upper bound', () { + expect( + resumePlaybackPosition(720, const Duration(minutes: 10)), + const Duration(minutes: 10), + ); + }); + + test('rewinds for context without going below zero', () { + expect( + resumePlaybackPosition(90, const Duration(minutes: 10)), + const Duration(seconds: 80), + ); + expect( + resumePlaybackPosition(5, const Duration(minutes: 10)), + Duration.zero, + ); + }); + }); + group('playback timeline duration', () { test('uses metadata when the native stream duration is unknown', () { final duration = effectivePlaybackDuration(