Skip to content

Commit 2277c4c

Browse files
Merge pull request #71 from hoangkien1703/codex/fix-caption-highlight-regression
Prevent caption highlights from jumping backward
2 parents fa0f210 + c90d837 commit 2277c4c

11 files changed

Lines changed: 167 additions & 31 deletions

File tree

‎.github/workflows/release-on-main.yml‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,23 +162,21 @@ jobs:
162162
163163
Preview builds install separately as **DualSub Replay Preview**, so installing an official release does not require removing the preview app.
164164
165-
## v1.0.3 highlights
165+
## v1.0.4 highlights
166166
167-
- **Adjustable portrait panel**: Use the Higher–Lower slider in More settings to move the portrait transcript panel from 20% higher to 20% lower (#70).
168-
- **Improved default placement**: The panel now starts 1% lower to leave slightly more of the video visible (#70).
169-
- **Safe resizing**: Raising the panel preserves its usual height, while lowering it reduces the visible subtitle area without moving content behind the navigation bar (#70).
170-
- **Saved position**: The selected position persists across restarts, video changes, panel hide/show, and rotation, with dedicated and global reset support (#70).
171-
- **Reachable subtitles**: The full bilingual timeline remains scrollable at every position, including replay controls on the final row (#70).
167+
- **Steady caption highlights**: Captions no longer jump to a previous sentence when live caption tracking briefly falls back to transcript timing or the WebView clock corrects backward (#71).
168+
- **Intentional rewinds still work**: Playback sessions reset highlight continuity after seeks, including small rewinds (#71).
169+
- **Natural caption gaps**: Highlighting still clears between captions and resets when the transcript is replaced (#71).
172170
173171
## Validation
174172
175-
- PR #70 passed formatting, complexity, unit tests, lint, debug and Android-test builds, optimized release checks, and API 36 managed-device tests.
176-
- Regression coverage includes panel geometry limits, small windows, invalid saved values, individual and global resets, persistence, settings interaction, and final-row reachability.
173+
- PR #71 passed formatting, complexity, unit tests, lint, debug and Android-test builds, optimized release checks, and API 36 managed-device tests.
174+
- Regression coverage includes clock corrections, live-to-timestamp fallback, delayed live captions, regressive words, caption gaps, and small rewinds.
177175
- The official APK is built with production signing and verified by this release workflow.
178176
179177
## Known limitations
180178
181-
- The panel-position control applies to the portrait transcript panel. Landscape layouts and compact/fullscreen captions retain their existing positioning behavior.
179+
- Spoken-word tracking on auto-generated captions depends on YouTube's webpage and may vary by video or device. Transcript timing remains available as a fallback.
182180
183181
The `.sha256` file can be used to verify the APK download.
184182
files: |

‎app/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ plugins {
33
id("org.jetbrains.kotlin.plugin.compose")
44
}
55

6-
val appVersionCode = 34
7-
val appVersionName = "1.0.3"
6+
val appVersionCode = 35
7+
val appVersionName = "1.0.4"
88
val releaseStoreFile = providers.environmentVariable("ANDROID_RELEASE_STORE_FILE").orNull
99
val releaseStorePassword = providers.environmentVariable("ANDROID_RELEASE_STORE_PASSWORD").orNull
1010
val releaseKeyAlias = providers.environmentVariable("ANDROID_RELEASE_KEY_ALIAS").orNull

‎app/src/androidTest/java/com/kienhoang/dualsubreplay/ui/RestoredHighlightPlaybackTest.kt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,16 @@ class RestoredHighlightPlaybackTest {
116116
}
117117
listOf(Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_LANDSCAPE).forEach { value ->
118118
compose.runOnIdle { orientation.value = value }
119-
listOf(0.1f to "One", 1.1f to "two", 2.1f to "three.", 0.1f to "One").forEach { (second, word) ->
120-
compose.runOnIdle { vm.onWebPlaybackSecond("abcdefghijk", second, null) }
119+
listOf(
120+
Triple(0.1f, "One", "steady"),
121+
Triple(1.1f, "two", "steady"),
122+
Triple(2.1f, "three.", "steady"),
123+
Triple(1.9f, "two", "small-rewind"),
124+
Triple(0.1f, "One", "large-rewind"),
125+
).forEach { (second, word, sessionId) ->
126+
compose.runOnIdle {
127+
vm.onWebPlaybackSecond("abcdefghijk", second, null, sessionId)
128+
}
121129
assertSpokenWord(word)
122130
}
123131
}

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/AppViewModel.kt‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@ class AppViewModel internal constructor(
276276
private var playbackKnown = false
277277
private var appVisible = true
278278
private val liveCaptionTracker = LiveCaptionTracker()
279+
private val captionHighlightResolver = CaptionHighlightResolver()
279280
private var liveCaptionProgress: LiveCaptionProgress? = null
281+
private var playbackSessionId: String? = null
280282
private val liveTranslationGate = LiveTranslationGate()
281283
private var liveTranslationJob: Job? = null
282284
private var rejectedLiveRevision: Long? = null
@@ -480,15 +482,23 @@ class AppViewModel internal constructor(
480482
videoId: String,
481483
second: Float,
482484
liveCaption: LiveCaptionSample? = null,
485+
sessionId: String = "",
483486
) {
484487
val current = _state.value
485488
if (current.activeVideoId != videoId || !second.isFinite()) return
486489
val timeMs = (second.coerceAtLeast(0f) * 1_000).toLong()
490+
val normalizedSessionId = sessionId.takeIf(String::isNotBlank)
491+
val sessionChanged =
492+
normalizedSessionId != null &&
493+
playbackSessionId?.let { it != normalizedSessionId } == true
494+
if (normalizedSessionId != null) playbackSessionId = normalizedSessionId
487495
val seek =
488-
timeMs + LIVE_CAPTION_BACKWARD_SEEK_RESET_MS < latestPlaybackSecondMs ||
496+
sessionChanged ||
497+
timeMs + LIVE_CAPTION_BACKWARD_SEEK_RESET_MS < latestPlaybackSecondMs ||
489498
timeMs > latestPlaybackSecondMs + 2_000L
490499
if (seek) {
491500
liveCaptionTracker.reset()
501+
captionHighlightResolver.reset()
492502
liveCaptionProgress = null
493503
}
494504
latestPlaybackSecondMs = timeMs
@@ -542,18 +552,15 @@ class AppViewModel internal constructor(
542552
null
543553
}
544554
val timedWordIndex = activeWordIndex(current.segments, timedIndex, timeMs)
545-
val timedPosition =
546-
timedIndex.takeIf { it >= 0 && timedWordIndex >= 0 }?.let {
547-
KaraokePosition(it, timedWordIndex)
548-
}
549555
val position =
550-
effectiveKaraokePosition(
551-
current.generatedCaptions,
552-
current.wordHighlightEnabled,
553-
timedPosition,
554-
livePosition,
556+
captionHighlightResolver.resolve(
557+
generatedCaptions = current.generatedCaptions,
558+
wordHighlightEnabled = current.wordHighlightEnabled,
559+
timedSegmentIndex = timedIndex,
560+
timedWordIndex = timedWordIndex,
561+
livePosition = livePosition,
555562
)
556-
val index = position?.segmentIndex ?: timedIndex
563+
val index = position?.segmentIndex ?: -1
557564
val wordIndex = position?.wordIndex ?: -1
558565
if (index != current.currentIndex || wordIndex != current.activeWordIndex) {
559566
_state.update { it.copy(currentIndex = index, activeWordIndex = wordIndex) }
@@ -741,6 +748,7 @@ class AppViewModel internal constructor(
741748
fun setWordHighlightEnabled(enabled: Boolean) {
742749
preferences.edit().putBoolean(WORD_HIGHLIGHT_ENABLED_PREFERENCE, enabled).apply()
743750
liveCaptionTracker.reset()
751+
captionHighlightResolver.reset()
744752
liveCaptionProgress = null
745753
_state.update { it.copy(wordHighlightEnabled = enabled, activeWordIndex = -1) }
746754
}
@@ -755,6 +763,7 @@ class AppViewModel internal constructor(
755763
preferences.edit().putString(CAPTION_FORMAT_PREFERENCE, format.storageValue).apply()
756764
_state.update { it.copy(captionFormat = format) }
757765
liveCaptionTracker.reset()
766+
captionHighlightResolver.reset()
758767
liveCaptionProgress = null
759768
}
760769

@@ -873,6 +882,7 @@ class AppViewModel internal constructor(
873882
_state.update { it.copy(autoPronounce = true) }
874883
latestPlaybackSecondMs = 0L
875884
liveCaptionTracker.reset()
885+
captionHighlightResolver.reset()
876886
liveCaptionProgress = null
877887
_state.update { current ->
878888
current.copy(
@@ -924,7 +934,9 @@ class AppViewModel internal constructor(
924934
liveTranslationGate.reset()
925935
latestPlaybackSecondMs = 0L
926936
liveCaptionTracker.reset()
937+
captionHighlightResolver.reset()
927938
liveCaptionProgress = null
939+
playbackSessionId = null
928940
playbackKnown = false
929941
playbackRequests.value = CaptionPlaybackRequest()
930942
_state.update {
@@ -967,6 +979,8 @@ class AppViewModel internal constructor(
967979
if (shouldResetPlaybackClock(_state.value.activeVideoId, videoId)) {
968980
latestPlaybackSecondMs = 0L
969981
playbackKnown = false
982+
playbackSessionId = null
983+
captionHighlightResolver.reset()
970984
}
971985
playbackRequests.value = CaptionPlaybackRequest()
972986
_state.update {
@@ -1077,6 +1091,7 @@ class AppViewModel internal constructor(
10771091
) {
10781092
var displayStore: SubtitleStore? = null
10791093
liveCaptionTracker.reset()
1094+
captionHighlightResolver.reset()
10801095
_state.update {
10811096
it.copy(
10821097
segments = emptyList(),
@@ -1145,6 +1160,7 @@ class AppViewModel internal constructor(
11451160
?.id != rows.firstOrNull()?.id
11461161
) {
11471162
liveCaptionTracker.reset()
1163+
captionHighlightResolver.reset()
11481164
}
11491165
_state.update { current ->
11501166
if (!isCurrentLoad(current, videoId, generation)) return@update current

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/CaptionPlaybackClock.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ internal class CaptionPlaybackClock {
88
private var sampledAt = 0L
99
private var lastWallSample = 0L
1010
private var positionMs = 0L
11+
private var lastEmittedPositionMs: Long? = null
12+
private var lastEmittedSessionId: String? = null
1113
private val retiredSessions = ArrayDeque<String>()
1214

1315
fun accept(
@@ -43,7 +45,14 @@ internal class CaptionPlaybackClock {
4345
// Do not march through words when the renderer stops reporting playback.
4446
if (age !in 0..PLAYBACK_CLOCK_STALE_MS || sample.seeking) return null
4547
val advance = if (sample.paused || sample.buffering) 0 else (age * sample.playbackRate).toLong()
46-
return positionMs + advance
48+
val projectedPositionMs = positionMs + advance
49+
if (lastEmittedSessionId != sample.sessionId) {
50+
lastEmittedSessionId = sample.sessionId
51+
lastEmittedPositionMs = null
52+
}
53+
return maxOf(projectedPositionMs, lastEmittedPositionMs ?: projectedPositionMs).also {
54+
lastEmittedPositionMs = it
55+
}
4756
}
4857

4958
fun sample(): WebPlaybackSnapshot? = snapshot

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/DualSubApp.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ private fun DualSubExperience(
233233
fullscreenLearningOverlay: (@Composable BoxScope.() -> Unit)?,
234234
onNavigationVisibilityChange: (Boolean) -> Unit,
235235
onPageChanged: (String) -> Unit,
236-
onPlaybackSecond: (String, Float, LiveCaptionSample?) -> Unit,
236+
onPlaybackSecond: (String, Float, LiveCaptionSample?, String) -> Unit,
237237
onPlaybackPaused: (String, Boolean) -> Unit,
238238
onShowSubtitles: () -> Unit,
239239
onHideSubtitles: () -> Unit,

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/KaraokeTiming.kt‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,48 @@ internal fun effectiveKaraokePosition(
4242
else -> timedPosition
4343
}
4444

45+
internal data class CaptionHighlightPosition(
46+
val segmentIndex: Int,
47+
val wordIndex: Int,
48+
) : Comparable<CaptionHighlightPosition> {
49+
override fun compareTo(other: CaptionHighlightPosition): Int =
50+
compareValuesBy(this, other, CaptionHighlightPosition::segmentIndex, CaptionHighlightPosition::wordIndex)
51+
}
52+
53+
/** Keeps timestamp and live-caption arbitration monotonic between real playback discontinuities. */
54+
internal class CaptionHighlightResolver {
55+
private var lastPosition: CaptionHighlightPosition? = null
56+
57+
fun reset() {
58+
lastPosition = null
59+
}
60+
61+
fun resolve(
62+
generatedCaptions: Boolean,
63+
wordHighlightEnabled: Boolean,
64+
timedSegmentIndex: Int,
65+
timedWordIndex: Int,
66+
livePosition: KaraokePosition?,
67+
): CaptionHighlightPosition? {
68+
val timedPosition =
69+
timedSegmentIndex.takeIf { it >= 0 }?.let {
70+
CaptionHighlightPosition(
71+
segmentIndex = it,
72+
wordIndex = timedWordIndex.takeIf { wordHighlightEnabled && it >= 0 } ?: -1,
73+
)
74+
}
75+
val selected =
76+
when {
77+
wordHighlightEnabled && generatedCaptions && livePosition != null ->
78+
CaptionHighlightPosition(livePosition.segmentIndex, livePosition.wordIndex)
79+
else -> timedPosition
80+
} ?: return null
81+
val resolved = lastPosition?.takeIf { selected < it } ?: selected
82+
lastPosition = resolved
83+
return resolved
84+
}
85+
}
86+
4587
internal data class LiveCaptionProgress(
4688
val text: String,
4789
val tokens: List<String>,

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/YouTubeBrowserScreen.kt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ internal fun SingleYouTubePage(
261261
navigationRequestId: Long,
262262
controller: YouTubeWebController,
263263
onPageChanged: (String) -> Unit,
264-
onPlaybackSecond: (videoId: String, second: Float, liveCaption: LiveCaptionSample?) -> Unit,
264+
onPlaybackSecond: (videoId: String, second: Float, liveCaption: LiveCaptionSample?, sessionId: String) -> Unit,
265265
onPlaybackPaused: (String, Boolean) -> Unit = { _, _ -> },
266266
liveCaptionCaptureEnabled: Boolean = false,
267267
suppressPageCaptions: Boolean = false,
@@ -527,7 +527,12 @@ internal fun SingleYouTubePage(
527527
val selection = snapshot?.url?.takeIf { it == webView.url }?.let(::browseVideoSelection)
528528
if (selection != null && position != null) {
529529
currentOnPlaybackPaused(selection.videoId, snapshot.paused)
530-
currentOnPlaybackSecond(selection.videoId, position / 1000f, snapshot.liveCaption)
530+
currentOnPlaybackSecond(
531+
selection.videoId,
532+
position / 1000f,
533+
snapshot.liveCaption,
534+
snapshot.sessionId,
535+
)
531536
}
532537
delay(33)
533538
}

‎app/src/test/java/com/kienhoang/dualsubreplay/ui/CaptionPlaybackClockTest.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ class CaptionPlaybackClockTest {
4646
assertEquals(2033L, clock.position(333))
4747
}
4848

49+
@Test fun smallClockCorrectionsCannotMoveHighlightTimeBackwardWithinSession() {
50+
val clock = CaptionPlaybackClock()
51+
assertTrue(clock.accept(sample(10f, 1000, "playing"), 100, 100, 1000))
52+
assertEquals(10099L, clock.position(199))
53+
54+
assertTrue(clock.accept(sample(10.04f, 1100, "playing"), 200, 200, 1100))
55+
assertEquals(10099L, clock.position(200))
56+
assertEquals(10100L, clock.position(260))
57+
58+
assertTrue(clock.accept(sample(2f, 1200, "seek"), 300, 300, 1200))
59+
assertEquals(2000L, clock.position(300))
60+
}
61+
4962
@Test fun controlledSteadyPlaybackAddsLessThan150msAcrossPresentationRestarts() {
5063
repeat(3) {
5164
val clock = CaptionPlaybackClock()

‎app/src/test/java/com/kienhoang/dualsubreplay/ui/KaraokeTimingTest.kt‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,51 @@ class KaraokeTimingTest {
2222
assertNull(effectiveKaraokePosition(true, false, timed, live))
2323
}
2424

25+
@Test
26+
fun liveToTimestampFallbackCannotReturnToPreviousSentence() {
27+
val resolver = CaptionHighlightResolver()
28+
assertEquals(
29+
CaptionHighlightPosition(2, 0),
30+
resolver.resolve(true, true, 1, 3, KaraokePosition(2, 0)),
31+
)
32+
assertEquals(
33+
CaptionHighlightPosition(2, 0),
34+
resolver.resolve(true, true, 1, 3, null),
35+
)
36+
}
37+
38+
@Test
39+
fun delayedLiveCaptionAndRegressiveWordMatchCannotMoveHighlightBackward() {
40+
val resolver = CaptionHighlightResolver()
41+
assertEquals(
42+
CaptionHighlightPosition(3, 2),
43+
resolver.resolve(true, true, 3, 2, null),
44+
)
45+
assertEquals(
46+
CaptionHighlightPosition(3, 2),
47+
resolver.resolve(true, true, 3, 3, KaraokePosition(2, 4)),
48+
)
49+
assertEquals(
50+
CaptionHighlightPosition(3, 2),
51+
resolver.resolve(true, true, 3, 1, KaraokePosition(3, 1)),
52+
)
53+
}
54+
55+
@Test
56+
fun genuineGapClearsHighlightAndResetAllowsIntentionalReplay() {
57+
val resolver = CaptionHighlightResolver()
58+
assertEquals(
59+
CaptionHighlightPosition(4, 1),
60+
resolver.resolve(false, true, 4, 1, null),
61+
)
62+
assertNull(resolver.resolve(false, true, -1, -1, null))
63+
resolver.reset()
64+
assertEquals(
65+
CaptionHighlightPosition(1, 0),
66+
resolver.resolve(false, true, 1, 0, null),
67+
)
68+
}
69+
2570
@Test
2671
fun rollingCaptionProgressNeverFlashesBackToTheFirstWord() {
2772
val first = reconcileLiveCaptionProgress(

0 commit comments

Comments
 (0)