Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions lib/screens/video_player_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,13 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> {
}

// 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;
}

Expand Down Expand Up @@ -495,17 +497,16 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> {

// 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;
}

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions lib/services/playback/nf_playback_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ abstract class NfPlaybackController extends ChangeNotifier {
/// Leave Picture-in-Picture. No-op where unsupported.
Future<void> 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<void> initialize();

Future<void> play();
Expand Down
27 changes: 27 additions & 0 deletions test/unit/playback_timeline_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading