|
| 1 | +# Spoken-word highlight never jumps backward during playback |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Implemented. Local checks pass. Final-head CI and the owner's phone check are pending. The owner |
| 6 | +asked for a separate PR from `main`. That request does not authorize merging or publishing. |
| 7 | + |
| 8 | +## Context / problem |
| 9 | + |
| 10 | +On the phone build of PR #76, the owner saw the spoken-word underline sometimes jump back to the |
| 11 | +first word of the line or to the previous sentence. It felt jerky. The same highlight code is on |
| 12 | +`main` (see [sentence translation and highlight sync](2026-09-24-sentence-translation-and-highlight-sync.md)). |
| 13 | + |
| 14 | +There are two causes in the code: |
| 15 | + |
| 16 | +1. **Every playback-window slide wiped the highlight state.** |
| 17 | + - `SubtitleStore.windowIndices` keeps rows from 30 s before playback, so the window's first row |
| 18 | + changes every few seconds. |
| 19 | + - When it did, `AppViewModel.publishSubtitleWindow` reset `CaptionHighlightResolver` and |
| 20 | + `LiveCaptionTracker`, then set the row and word from timestamps alone. |
| 21 | + - Auto-caption lines overlap in time, so the timestamp row was often the previous sentence. |
| 22 | + - The reset tracker also restarted live progress at word 0 of YouTube's line, which is the jump |
| 23 | + to the first word. |
| 24 | + - The reset only existed because highlight positions are indices into the current window. |
| 25 | +2. **One signal could move the highlight back.** The 1 s backward correction inside a row trusted |
| 26 | + live progress alone. `liveBoundedPosition` clamps the timestamp word down to the live word, so |
| 27 | + live progress at word 0 pulled the underline back to the start of the row even while the |
| 28 | + timestamps were ahead. |
| 29 | + |
| 30 | +## Goals |
| 31 | + |
| 32 | +- During normal playback, the highlight only moves forward. |
| 33 | +- It moves backward only for a real reason: |
| 34 | + - a seek back; |
| 35 | + - a new video; |
| 36 | + - rebuilt rows (caption format, highlight toggle, reload, settings reset); |
| 37 | + - live progress **and** timestamps both agreeing, for 1 s, that it is ahead in the same row. |
| 38 | + |
| 39 | +## Non-goals |
| 40 | + |
| 41 | +- No change to live-caption matching, timestamps, window size or translation scheduling. |
| 42 | + |
| 43 | +## User-visible behavior |
| 44 | + |
| 45 | +- **Before:** every few seconds the underline could jump back to the previous sentence or to the |
| 46 | + first word of the line, then catch up. |
| 47 | +- **After:** the underline stays in place or moves forward. A backward seek still moves it back |
| 48 | + at once. A wrong live match still recovers after 1 s, because the timestamps also point earlier. |
| 49 | + |
| 50 | +## Technical constraints / invariants |
| 51 | + |
| 52 | +- Row ids are store indices (`SubtitleMerger.merge`, `prepareCaptionDisplayStore`). The same id in |
| 53 | + two windows is the same row. |
| 54 | +- Unit tests stay plain JUnit4. |
| 55 | + |
| 56 | +## Proposed approach / plan |
| 57 | + |
| 58 | +1. `windowShift(previous, rows)` in `PlaybackTranslation.kt` returns how many rows the window |
| 59 | + dropped from its start: 0 for the same window, null when the new window does not start inside |
| 60 | + the old one (seek, reload, first window). |
| 61 | +2. `CaptionHighlightResolver.shift` and `LiveCaptionTracker.shift` move their held positions by |
| 62 | + that amount. They reset only when the held row left the window. The tracker keeps its live |
| 63 | + progress, so there is no 2-revision warm-up after a slide. |
| 64 | +3. `publishSubtitleWindow` shifts the held position instead of resetting it, and keeps the |
| 65 | + current row and word. It still resets and recomputes from timestamps when `windowShift` is null. |
| 66 | +4. `CaptionHighlightResolver` accepts a backward correction only when the timestamp word is in the |
| 67 | + held row and before the held word, in addition to the existing 1 s, same-row, live-only rules. |
| 68 | + |
| 69 | +## Acceptance criteria |
| 70 | + |
| 71 | +- [x] Live progress at word 0 while timestamps are ahead, or in the previous row, never moves the |
| 72 | + highlight back, however long it lasts |
| 73 | + (`KaraokeTimingTest.liveProgressAloneCannotPullTheHighlightBackToTheFirstWord`; fails with the |
| 74 | + old rule). |
| 75 | +- [x] After a window slide, timestamps pointing at the overlapping previous row do not move the |
| 76 | + highlight back (`windowShiftKeepsHoldingTheSameRow`). |
| 77 | +- [x] A slide past the held row resets it (`windowShiftPastTheHeldRowResets`). |
| 78 | +- [x] The live tracker continues after a slide without the warm-up |
| 79 | + (`liveTrackerShiftKeepsProgressWithoutWarmUp`). |
| 80 | +- [x] `windowShift` handles the same window, a forward slide, a backward window and empty lists |
| 81 | + (`windowShiftFindsTheNewFirstRowInThePreviousWindow`). |
| 82 | +- [x] A wrong live match still recovers after 1 s when the timestamps agree, and existing highlight |
| 83 | + tests pass unchanged (`wrongLiveWordMatchRecoversInsideTheSentenceAfterSustainedDisagreementOnly`). |
| 84 | +- [ ] Final-head CI `verify-build` and `managed-device-tests` pass. |
| 85 | +- [ ] Owner phone check: two minutes or more of an auto-captioned video with no backward jump. |
| 86 | + A backward seek still moves the highlight back at once. |
| 87 | + |
| 88 | +## Validation plan |
| 89 | + |
| 90 | +| Category | Command/scenario and expected result | Environment / applicability | |
| 91 | +| --- | --- | --- | |
| 92 | +| Unit tests | `testDebugUnitTest` passes, including the new `KaraokeTimingTest` cases | Local Windows + CI | |
| 93 | +| Android lint/build | `formatCheck complexityCheck lintDebug assembleDebug assembleDebugAndroidTest` pass | Local Windows + CI | |
| 94 | +| Managed-device/emulator | Existing `pixel2Api36DebugAndroidTest` suite | CI | |
| 95 | +| Physical-device / live YouTube | Scenario in the last acceptance criterion | Owner's phone | |
| 96 | + |
| 97 | +## Risks / edge cases |
| 98 | + |
| 99 | +- A wrong live match whose timestamps also point ahead is now held until the speech catches up, |
| 100 | + or until a seek. Before, it corrected after 1 s. Staying slightly ahead is less jarring than |
| 101 | + jumping back. |
| 102 | + |
| 103 | +## Release intent |
| 104 | + |
| 105 | +`release:patch`: a user-visible bug fix with no new setting (project default). |
| 106 | + |
| 107 | +## Implementation result |
| 108 | + |
| 109 | +Implemented as planned. |
| 110 | + |
| 111 | +## Validation result |
| 112 | + |
| 113 | +- Local (Windows, JDK 17, Android SDK 36): `formatCheck complexityCheck testDebugUnitTest lintDebug |
| 114 | + assembleDebug assembleDebugAndroidTest` passed, with 281 unit tests and 0 failures. |
| 115 | +- With the old backward-correction rule put back, the new first-word test fails (1 of 20 in |
| 116 | + `KaraokeTimingTest`), so it covers the reported jump. |
| 117 | +- Managed-device tests: not run locally. CI runs them. |
| 118 | +- Physical phone / live YouTube: not run. Pending owner acceptance. |
0 commit comments