From b29c02f597fba6027eb758f5799aabcf6514c4c7 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Mon, 27 Jul 2026 20:11:18 +0530 Subject: [PATCH 1/2] fix(courses): prevent autoplay loop on completed video lessons - Avoid triggering completion on initial load when seek position is near the end. - Reset previously completed videos to play from the beginning. --- .../.openspec.yaml | 2 ++ .../fix-autoplay-next-lesson-loop/design.md | 36 +++++++++++++++++++ .../fix-autoplay-next-lesson-loop/proposal.md | 20 +++++++++++ .../specs/unified-lesson-shell/spec.md | 16 +++++++++ .../fix-autoplay-next-lesson-loop/tasks.md | 11 ++++++ openspec/specs/unified-lesson-shell/spec.md | 8 +++-- .../screens/video_lesson_detail_screen.dart | 7 ++-- .../lesson_detail/custom_video_player.dart | 30 ++++++++++++---- .../lesson_detail/video_lesson_viewer.dart | 7 ++-- 9 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 openspec/changes/fix-autoplay-next-lesson-loop/.openspec.yaml create mode 100644 openspec/changes/fix-autoplay-next-lesson-loop/design.md create mode 100644 openspec/changes/fix-autoplay-next-lesson-loop/proposal.md create mode 100644 openspec/changes/fix-autoplay-next-lesson-loop/specs/unified-lesson-shell/spec.md create mode 100644 openspec/changes/fix-autoplay-next-lesson-loop/tasks.md diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/.openspec.yaml b/openspec/changes/fix-autoplay-next-lesson-loop/.openspec.yaml new file mode 100644 index 000000000..8e7013b8b --- /dev/null +++ b/openspec/changes/fix-autoplay-next-lesson-loop/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/design.md b/openspec/changes/fix-autoplay-next-lesson-loop/design.md new file mode 100644 index 000000000..6ec50c533 --- /dev/null +++ b/openspec/changes/fix-autoplay-next-lesson-loop/design.md @@ -0,0 +1,36 @@ +## Context + +When the "Auto-Play Next Lesson" setting is enabled, completing a video lesson automatically triggers navigation to the next lesson. However, because the app saves the playback progress, navigating back to a completed video lesson causes the player to initialize and seek to the end of the video. Once the seek completes, the player's position changes to the end, which is immediately interpreted as a new completion event. Since the orchestrator's state (including completion flags) is re-initialized on navigation, it immediately fires the `onNext` callback, throwing the user forward again in an inescapable navigation loop. + +## Goals / Non-Goals + +**Goals:** +- Prevent automatic navigation to the next lesson when navigating back to or reloading a completed video lesson. +- Ensure that completed video lessons start playback from the beginning (`0.0`) when opened, so the user can actually replay them. +- Keep the `onComplete` trigger functional if the user seeks backwards or chooses to replay the video from the beginning. + +**Non-Goals:** +- Changing the global autoplay logic for non-video content types. +- Modifying how playback tracking (attempts) are synchronized to the backend. + +## Decisions + +### 1. Reset initial playback position for completed video lessons +In `VideoLessonViewer` and `VideoLessonDetailScreen`, check if the lesson has been completed (`lesson.progressStatus == LessonProgressStatus.completed`). If it has, set `initialPosition` to `0.0`. + +- *Rationale:* If the user has finished a video, returning to it should let them watch it again from the beginning, rather than staring at a black ended screen. This naturally avoids seeking to the end on initialization. + +### 2. Introduce an initial completion guard in `CustomVideoPlayer` +Add a `_shouldIgnoreInitialCompletion` boolean flag inside `CustomVideoPlayerState`. During the first build/seek (where `_hasSeekedToInitial` is processed), if the target seek position is within 2 seconds of the video duration (and the video is sufficiently long, e.g. > 5 seconds), set this flag to `true`. In the player's listener, check this flag before firing `widget.onComplete`. + +- *Rationale:* This serves as a safety guard. If the initial position is at/near the end of the video, we do not want to trigger `onComplete` immediately on load. + +### 3. Reset the guard on user interaction or playback progress +If `_shouldIgnoreInitialCompletion` is `true`, reset it to `false` as soon as the playback position is detected to be before the last 2 seconds of the video (e.g., `currentPos < duration - 2.0`). + +- *Rationale:* If the user replays the video or seeks backwards to watch it, we want the natural completion event to trigger once they reach the end of the video again. + +## Risks / Trade-offs + +- **Risk**: Video playback state might not update the duration immediately when `_hasSeekedToInitial` is evaluated. + - *Mitigation*: We only perform the initial seek and set the ignore flag when `controller.value.duration != Duration.zero` is true. diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/proposal.md b/openspec/changes/fix-autoplay-next-lesson-loop/proposal.md new file mode 100644 index 000000000..5a27f709f --- /dev/null +++ b/openspec/changes/fix-autoplay-next-lesson-loop/proposal.md @@ -0,0 +1,20 @@ +## Why + +When the Auto-Play Next Lesson feature is enabled, navigating back to a previously completed video lesson (using the Previous button or other navigation) causes the app to immediately auto-play the next lesson again. This is because the video player seeks to the end (its last watched position), triggering a video completion event which immediately auto-navigates the user forward, creating an inescapable loop. + +## What Changes + +- Modify completed video lessons to initialize their playback position at `0.0` rather than the end of the video, so that users can replay them from the beginning. +- Introduce an initialization completion guard in `CustomVideoPlayer` that ignores completion events if the video was initialized at or near the end. +- Allow completion events to fire normally if the user actively seeks backward or replays the video from a non-completed position. + +## Capabilities + +### Modified Capabilities +- `unified-lesson-shell`: Refine "AutoPlay Next Video Evaluation" to specify that initialization or seek-on-load to near-end positions of a video lesson must not trigger automatic navigation. + +## Impact + +- `packages/courses/lib/widgets/lesson_detail/custom_video_player.dart` +- `packages/courses/lib/widgets/lesson_detail/video_lesson_viewer.dart` +- `packages/courses/lib/screens/video_lesson_detail_screen.dart` diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/specs/unified-lesson-shell/spec.md b/openspec/changes/fix-autoplay-next-lesson-loop/specs/unified-lesson-shell/spec.md new file mode 100644 index 000000000..7ecf8ec29 --- /dev/null +++ b/openspec/changes/fix-autoplay-next-lesson-loop/specs/unified-lesson-shell/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: AutoPlay Next Video Evaluation +The lesson shell SHALL evaluate whether to automatically navigate to the next lesson when a video completes. The completion event SHALL NOT trigger automatic navigation if the video was initialized or seeked to a near-end position on initial load. + +#### Scenario: AutoPlay enabled +- **WHEN** a video lesson completes naturally, and the `autoPlayNext` user setting is enabled +- **THEN** the system SHALL automatically navigate to the next lesson, regardless of its content type. + +#### Scenario: AutoPlay disabled +- **WHEN** a video lesson completes and the `autoPlayNext` user setting is disabled +- **THEN** the system SHALL NOT automatically navigate to the next lesson. + +#### Scenario: Video loaded at the end with AutoPlay enabled +- **WHEN** a video lesson is opened, and the initial position is at or near the end of the video +- **THEN** the system SHALL NOT trigger a completion event or automatically navigate to the next lesson. diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md b/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md new file mode 100644 index 000000000..082d22c23 --- /dev/null +++ b/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md @@ -0,0 +1,11 @@ +## 1. Lesson Viewers initial position reset + +- [x] 1.1 In `video_lesson_viewer.dart`, reset `initialPos` to `0.0` if `widget.lesson.progressStatus == LessonProgressStatus.completed`. +- [x] 1.2 In `video_lesson_detail_screen.dart`, reset `initialPos` to `0.0` if `widget.lesson.progressStatus == LessonProgressStatus.completed`. + +## 2. CustomVideoPlayer initial completion guard + +- [x] 2.1 In `custom_video_player.dart`, define the `_shouldIgnoreInitialCompletion` flag in `CustomVideoPlayerState`. +- [x] 2.2 In `custom_video_player.dart`, refactor the `_hasSeekedToInitial` logic to safely set `_hasSeekedToInitial = true` when duration is loaded, and set `_shouldIgnoreInitialCompletion = true` if `targetSeek` is near the end. +- [x] 2.3 In `custom_video_player.dart` controller listener, reset `_shouldIgnoreInitialCompletion` to `false` when `currentPos < duration - 2.0`. +- [x] 2.4 In `custom_video_player.dart` controller listener, guard `widget.onComplete?.call()` to only run when `!_shouldIgnoreInitialCompletion`. diff --git a/openspec/specs/unified-lesson-shell/spec.md b/openspec/specs/unified-lesson-shell/spec.md index 37fd0ff43..0aaf39197 100644 --- a/openspec/specs/unified-lesson-shell/spec.md +++ b/openspec/specs/unified-lesson-shell/spec.md @@ -18,13 +18,17 @@ The bookmark icon in the lesson details header SHALL render in an active (filled - **THEN** the header bookmark icon SHALL be displayed as a filled icon. ### Requirement: AutoPlay Next Video Evaluation -The lesson shell SHALL evaluate whether to automatically navigate to the next lesson when a video completes. +The lesson shell SHALL evaluate whether to automatically navigate to the next lesson when a video completes. The completion event SHALL NOT trigger automatic navigation if the video was initialized or seeked to a near-end position on initial load. #### Scenario: AutoPlay enabled -- **WHEN** a video lesson completes and the `autoPlayNext` user setting is enabled +- **WHEN** a video lesson completes naturally, and the `autoPlayNext` user setting is enabled - **THEN** the system SHALL automatically navigate to the next lesson, regardless of its content type. #### Scenario: AutoPlay disabled - **WHEN** a video lesson completes and the `autoPlayNext` user setting is disabled - **THEN** the system SHALL NOT automatically navigate to the next lesson. +#### Scenario: Video loaded at the end with AutoPlay enabled +- **WHEN** a video lesson is opened, and the initial position is at or near the end of the video +- **THEN** the system SHALL NOT trigger a completion event or automatically navigate to the next lesson. + diff --git a/packages/courses/lib/screens/video_lesson_detail_screen.dart b/packages/courses/lib/screens/video_lesson_detail_screen.dart index 298dc8073..13ca7181e 100644 --- a/packages/courses/lib/screens/video_lesson_detail_screen.dart +++ b/packages/courses/lib/screens/video_lesson_detail_screen.dart @@ -147,8 +147,11 @@ class _VideoLessonDetailScreenState @override Widget build(BuildContext context) { final design = Design.of(context); - final initialPos = - double.tryParse(widget.lesson.lastWatchedDuration ?? '0') ?? 0.0; + final isCompleted = + widget.lesson.progressStatus == LessonProgressStatus.completed; + final initialPos = isCompleted + ? 0.0 + : (double.tryParse(widget.lesson.lastWatchedDuration ?? '0') ?? 0.0); final padding = MediaQuery.of(context).padding; final l10n = L10n.of(context); diff --git a/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart b/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart index 1e6802e44..de0193583 100644 --- a/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart +++ b/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart @@ -192,6 +192,7 @@ class CustomVideoPlayerState extends ConsumerState { bool _hasSeekedToInitial = false; double? _pendingSeekPosition; + bool _shouldIgnoreInitialCompletion = false; void _onPlayerCreated(TestpressPlayerController controller) { _controller = controller; @@ -199,21 +200,35 @@ class CustomVideoPlayerState extends ConsumerState { controller.addListener(() { final isPlaying = controller.value.isPlaying; final currentPos = controller.value.position.inMilliseconds / 1000.0; + final duration = controller.value.duration.inMilliseconds / 1000.0; // Ensure we only seek once the video is loaded (duration > 0) - final targetSeek = _pendingSeekPosition ?? widget.initialPosition; - final needsInitialSeek = targetSeek > 0 && !_hasSeekedToInitial; - if (needsInitialSeek) { + if (!_hasSeekedToInitial) { if (controller.value.duration != Duration.zero) { - controller.seek(Duration(milliseconds: (targetSeek * 1000).toInt())); - _lastPosition = targetSeek; - _currentIntervalStart = targetSeek; + final targetSeek = _pendingSeekPosition ?? widget.initialPosition; + if (targetSeek > 0) { + controller + .seek(Duration(milliseconds: (targetSeek * 1000).toInt())); + _lastPosition = targetSeek; + _currentIntervalStart = targetSeek; + // Guard: If the initial position is close to the end, ignore the completion trigger + if (duration > 5.0 && targetSeek >= duration - 2.0) { + _shouldIgnoreInitialCompletion = true; + } + } _hasSeekedToInitial = true; _pendingSeekPosition = null; } return; } + // Reset the ignore flag if the user seeks backwards or plays from before the end + if (_shouldIgnoreInitialCompletion && + duration > 0.0 && + currentPos < duration - 2.0) { + _shouldIgnoreInitialCompletion = false; + } + // Detect seek (position jumped by more than 1.5s or went backwards) final isSeeking = (currentPos - _lastPosition).abs() > 1.5; if (isSeeking) { @@ -235,7 +250,8 @@ class CustomVideoPlayerState extends ConsumerState { } // Check for completion - if (controller.value.position >= controller.value.duration && + if (!_shouldIgnoreInitialCompletion && + controller.value.position >= controller.value.duration && controller.value.duration != Duration.zero) { widget.onComplete?.call(); } diff --git a/packages/courses/lib/widgets/lesson_detail/video_lesson_viewer.dart b/packages/courses/lib/widgets/lesson_detail/video_lesson_viewer.dart index a0bf2a3f2..86efe7d41 100644 --- a/packages/courses/lib/widgets/lesson_detail/video_lesson_viewer.dart +++ b/packages/courses/lib/widgets/lesson_detail/video_lesson_viewer.dart @@ -176,8 +176,11 @@ class _VideoLessonViewerState extends State } Widget _buildVideoSection(DesignConfig design) { - final initialPos = - double.tryParse(widget.lesson.lastWatchedDuration ?? '0') ?? 0.0; + final isCompleted = + widget.lesson.progressStatus == LessonProgressStatus.completed; + final initialPos = isCompleted + ? 0.0 + : (double.tryParse(widget.lesson.lastWatchedDuration ?? '0') ?? 0.0); return CustomVideoPlayer( key: _videoPlayerKey, From 3bed99f7d38d1a2ff13216cc2a8a5cb09a4cd168 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Mon, 27 Jul 2026 20:23:13 +0530 Subject: [PATCH 2/2] fix(courses): prevent autoplay loop on completed video lessons --- .../fix-autoplay-next-lesson-loop/design.md | 12 +++++++----- .../fix-autoplay-next-lesson-loop/tasks.md | 4 ++-- .../lesson_detail/custom_video_player.dart | 19 +++++++++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/design.md b/openspec/changes/fix-autoplay-next-lesson-loop/design.md index 6ec50c533..1fa112267 100644 --- a/openspec/changes/fix-autoplay-next-lesson-loop/design.md +++ b/openspec/changes/fix-autoplay-next-lesson-loop/design.md @@ -21,14 +21,16 @@ In `VideoLessonViewer` and `VideoLessonDetailScreen`, check if the lesson has be - *Rationale:* If the user has finished a video, returning to it should let them watch it again from the beginning, rather than staring at a black ended screen. This naturally avoids seeking to the end on initialization. ### 2. Introduce an initial completion guard in `CustomVideoPlayer` -Add a `_shouldIgnoreInitialCompletion` boolean flag inside `CustomVideoPlayerState`. During the first build/seek (where `_hasSeekedToInitial` is processed), if the target seek position is within 2 seconds of the video duration (and the video is sufficiently long, e.g. > 5 seconds), set this flag to `true`. In the player's listener, check this flag before firing `widget.onComplete`. +Add a `_shouldIgnoreInitialCompletion` boolean flag inside `CustomVideoPlayerState`. During the first build/seek (where `_hasSeekedToInitial` is processed), if the target seek position is within a near-end threshold (defined dynamically as `duration > 2.0 ? 2.0 : duration * 0.5`) of the video duration, set this flag to `true`. In the player's listener, check this flag before firing `widget.onComplete`. -- *Rationale:* This serves as a safety guard. If the initial position is at/near the end of the video, we do not want to trigger `onComplete` immediately on load. +- *Rationale:* This serves as a safety guard. If the initial position is at/near the end of the video, we do not want to trigger `onComplete` immediately on load. The dynamic threshold ensures short clips (≤ 5s) are also guarded without activating too early on their initial playback. -### 3. Reset the guard on user interaction or playback progress -If `_shouldIgnoreInitialCompletion` is `true`, reset it to `false` as soon as the playback position is detected to be before the last 2 seconds of the video (e.g., `currentPos < duration - 2.0`). +### 3. Reset the guard on user interaction, backward seek, or forward playback progress +If `_shouldIgnoreInitialCompletion` is `true`, reset it to `false` when: +- The current position has advanced forward from the initial seek position by a small margin (e.g., `currentPos > _initialSeekPos + 0.1`). +- The user seeks backwards from the initial seek position (e.g., `currentPos < _initialSeekPos - 0.5`). -- *Rationale:* If the user replays the video or seeks backwards to watch it, we want the natural completion event to trigger once they reach the end of the video again. +- *Rationale:* This ensures that if a user resumes a video near the end and actually starts playing it, the video can still complete naturally and trigger auto-play. It also allows completion if they seek backwards to watch from an earlier point. It only suppresses completion if the video loads and is immediately evaluated at the end without any actual forward progress or user interaction. ## Risks / Trade-offs diff --git a/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md b/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md index 082d22c23..908dc3703 100644 --- a/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md +++ b/openspec/changes/fix-autoplay-next-lesson-loop/tasks.md @@ -6,6 +6,6 @@ ## 2. CustomVideoPlayer initial completion guard - [x] 2.1 In `custom_video_player.dart`, define the `_shouldIgnoreInitialCompletion` flag in `CustomVideoPlayerState`. -- [x] 2.2 In `custom_video_player.dart`, refactor the `_hasSeekedToInitial` logic to safely set `_hasSeekedToInitial = true` when duration is loaded, and set `_shouldIgnoreInitialCompletion = true` if `targetSeek` is near the end. -- [x] 2.3 In `custom_video_player.dart` controller listener, reset `_shouldIgnoreInitialCompletion` to `false` when `currentPos < duration - 2.0`. +- [x] 2.2 In `custom_video_player.dart`, store `_initialSeekPos` and set `_shouldIgnoreInitialCompletion = true` if `targetSeek` is within the dynamic threshold `duration > 2.0 ? 2.0 : duration * 0.5` of the end. +- [x] 2.3 In `custom_video_player.dart` controller listener, reset `_shouldIgnoreInitialCompletion` to `false` when `currentPos > _initialSeekPos + 0.1` or `currentPos < _initialSeekPos - 0.5`. - [x] 2.4 In `custom_video_player.dart` controller listener, guard `widget.onComplete?.call()` to only run when `!_shouldIgnoreInitialCompletion`. diff --git a/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart b/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart index de0193583..616114909 100644 --- a/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart +++ b/packages/courses/lib/widgets/lesson_detail/custom_video_player.dart @@ -193,6 +193,7 @@ class CustomVideoPlayerState extends ConsumerState { bool _hasSeekedToInitial = false; double? _pendingSeekPosition; bool _shouldIgnoreInitialCompletion = false; + double _initialSeekPos = 0.0; void _onPlayerCreated(TestpressPlayerController controller) { _controller = controller; @@ -211,10 +212,14 @@ class CustomVideoPlayerState extends ConsumerState { .seek(Duration(milliseconds: (targetSeek * 1000).toInt())); _lastPosition = targetSeek; _currentIntervalStart = targetSeek; + _initialSeekPos = targetSeek; // Guard: If the initial position is close to the end, ignore the completion trigger - if (duration > 5.0 && targetSeek >= duration - 2.0) { + final nearEndThreshold = duration > 2.0 ? 2.0 : (duration * 0.5); + if (targetSeek >= duration - nearEndThreshold) { _shouldIgnoreInitialCompletion = true; } + } else { + _initialSeekPos = 0.0; } _hasSeekedToInitial = true; _pendingSeekPosition = null; @@ -222,11 +227,13 @@ class CustomVideoPlayerState extends ConsumerState { return; } - // Reset the ignore flag if the user seeks backwards or plays from before the end - if (_shouldIgnoreInitialCompletion && - duration > 0.0 && - currentPos < duration - 2.0) { - _shouldIgnoreInitialCompletion = false; + // Reset the ignore flag if the user seeks backwards or plays forward past the initial seek position + if (_shouldIgnoreInitialCompletion) { + final isProgressingForward = currentPos > _initialSeekPos + 0.1; + final isSeekingBackward = currentPos < _initialSeekPos - 0.5; + if (isProgressingForward || isSeekingBackward) { + _shouldIgnoreInitialCompletion = false; + } } // Detect seek (position jumped by more than 1.5s or went backwards)