Skip to content

Commit b77596f

Browse files
Merge PR #63: restore spoken-word highlighting and release v1.0.0
Restore v0.9.5 word highlighting in portrait and landscape
2 parents 8854f9d + 84e240e commit b77596f

26 files changed

Lines changed: 692 additions & 392 deletions

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,24 @@ jobs:
160160
161161
Preview builds install separately as **DualSub Replay Preview**, so installing an official release does not require removing the preview app.
162162
163-
## v0.9.9 highlights
163+
## v1.0.0 highlights
164164
165-
- **Practice backup and transfer**: Import/export versioned JSON backups and Anki TSV using Android's file picker, with previews and duplicate handling (#60).
166-
- **On-device translation**: Retain sentence context, bound the translation cache, and reject stale callbacks (#60).
167-
- **Caption controls**: Independently choose Always, Only when paused, or Never for each caption track, with preferences shared across portrait and fullscreen (#60).
168-
- **Fullscreen highlighting**: Share playback state and fix stale fullscreen media-clock snapshots (#60).
169-
- **Quality checks**: Add formatting and complexity gates, an isolated benchmark module, and a Baseline Profile (#60).
165+
- **Spoken-word highlighting**: Restore v0.9.5's automatic timing and keep live caption tracking active in portrait, landscape, and fullscreen overlays (#63).
166+
- **Bilingual caption formats**: Choose Short paired phrases or Whole sentence, with each displayed unit translated independently while retaining source-word timestamps (#62).
167+
- **Playback timing**: Share a rate-aware clock across subtitle layouts, with stale-sample rejection and recovery after pauses, buffering, and seeks (#62, #63).
168+
- **Existing features preserved**: On-device translation, vocabulary, Word Learning, Practice backup and transfer, replay, caption colors, and visibility controls remain available.
169+
170+
## Validation
171+
172+
- PR checks cover unit tests, formatting, complexity, lint, debug and optimized release builds, and 42 API 36 emulator tests.
173+
- The landscape regression exercises the complete player and live caption capture through landscape/fullscreen transitions with an offline WebView fixture.
174+
- The user confirmed PR #63 build 271 works well on their phone before approving this official release.
170175
171176
## Known limitations
172177
173-
- Live YouTube fullscreen/rotation, extended playback, and perceptual highlighting still need physical-device acceptance testing.
174-
- Controlled startup was 686 ms versus 681 ms; startup memory (PSS) increased by 11.83% (about 3.1 MiB), with the cause still under investigation.
175-
- ML Kit translations can still be literal or imprecise. Issues #56–#59 remain open pending acceptance.
178+
- Live word timing depends on eligible auto-generated captions and YouTube's page behavior; transcript timing is used when live timing is unavailable.
179+
- ML Kit translations can still be literal or imprecise.
180+
- Phone acceptance does not establish synchronization accuracy for every video or device; emulator fixtures do not measure real audio latency.
176181
177182
The `.sha256` file can be used to verify the APK download.
178183
files: |

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Requires **Android 8.0 or newer**. No account or API key is required.
4242

4343
Android may show a standard warning because the app is downloaded directly from GitHub instead of Google Play.
4444

45-
> **Updating from a preview?** Uninstall the preview build once before installing the official app. Official releases use a new production signing key; future official versions will install as normal updates.
45+
> **Installing after testing a preview?** Current preview builds install separately as **DualSub Replay Preview**, so you can keep both apps. Official releases update the existing official app. Preview settings and vocabulary remain in the preview app; use Practice backup and transfer to move saved study data.
4646
4747
Want to test the newest development build? See the [preview release](https://github.com/hoangkien1703/dual-sub-replay/releases/tag/preview). Preview builds may be less stable and use a different signature.
4848

@@ -114,7 +114,7 @@ Offline video downloading and local video playback have been removed to reduce A
114114

115115
Source contributed to this repository is released under the [MIT license](LICENSE). See [third-party notices and build/source information](THIRD_PARTY_NOTICES.md).
116116

117-
Live spoken-word timing reads caption text rendered by YouTube's webpage and is available only for eligible auto-generated caption tracks. Adaptive mode falls back to transcript timing when a reliable live word cannot be mapped; manual captions always use transcript timing. YouTube's page structure and WebView behavior can vary by video and device, so live word timing may be unavailable or less precise on some phones.
117+
Live spoken-word timing reads caption text rendered by YouTube's webpage and is available only for eligible auto-generated caption tracks. Highlighting automatically falls back to transcript timing when a reliable live word cannot be mapped; manual captions always use transcript timing. YouTube's page structure and WebView behavior can vary by video and device, so live word timing may be unavailable or less precise on some phones.
118118

119119
Official APKs use a dedicated production signing key kept outside the repository and restored through encrypted GitHub Actions secrets. Preview APKs use a separate CI debug signature, so Android treats the preview and official release as different update lines.
120120

@@ -160,7 +160,7 @@ Debug APKs are produced at `app/build/outputs/apk/debug/app-debug.apk`. An offic
160160

161161
## Continuous integration
162162

163-
Every push and pull request uses the committed wrapper to run unit tests, lint, debug APK assembly, and Android-test APK assembly. A second job executes the offline fixture suite on the API 36 managed device. Same-repository pull requests also publish a numbered test APK such as `DualSub-Replay-PR16-preview.apk` to the rolling preview release and remove it automatically when the PR is closed. A push to `main` updates the rolling preview release only after both verification jobs pass. When `main` contains a new app version without a matching version tag, the verified production-signed APK is published automatically as the latest official release.
163+
Pull requests use the committed wrapper to run formatting and complexity checks, unit tests, lint, debug and Android-test APK assembly, and an optimized release build. A second job executes the offline fixture suite on the API 36 managed device. Both jobs must pass before merging into `main`. Same-repository pull requests also publish a numbered test APK such as `DualSub-Replay-PR63-build271-preview.apk` to the rolling preview release and remove it automatically when the PR is closed. After merge, the main publishing workflow builds the rolling preview without repeating PR tests. When `main` contains a new app version without a matching version tag, it also builds, verifies, and publishes the production-signed APK as the latest official release.
164164

165165
## Privacy
166166

‎app/build.gradle.kts‎

Lines changed: 3 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 = 30
7-
val appVersionName = "0.9.9"
6+
val appVersionCode = 31
7+
val appVersionName = "1.0.0"
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
@@ -129,6 +129,7 @@ dependencies {
129129

130130
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
131131
implementation("androidx.core:core-ktx:1.17.0")
132+
implementation("androidx.webkit:webkit:1.14.0")
132133
implementation("androidx.activity:activity-compose:1.11.0")
133134
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
134135
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,10 @@ class CaptionRecoveryStateTest {
2626
val vm = withContext(Dispatchers.Main) { AppViewModel(application, provider) }
2727
val oldTarget = vm.state.value.targetLanguage
2828
val oldHighlight = vm.state.value.wordHighlightEnabled
29-
val oldTiming = vm.state.value.karaokeTimingMode
3029
try {
3130
withContext(Dispatchers.Main) {
3231
vm.setTargetLanguage("en") // Exercises ML Kit's no-download same-language path.
3332
vm.setWordHighlightEnabled(false)
34-
vm.setKaraokeTimingMode(KaraokeTimingMode.TRANSCRIPT)
3533
vm.onYouTubePageChanged("https://m.youtube.com/watch?v=abcdefghijk")
3634
}
3735
withTimeout(5000) { vm.state.first { it.liveFallback } }
@@ -52,7 +50,6 @@ class CaptionRecoveryStateTest {
5250
vm.onYouTubePageChanged(YOUTUBE_HOME_URL)
5351
vm.setTargetLanguage(oldTarget)
5452
vm.setWordHighlightEnabled(oldHighlight)
55-
vm.setKaraokeTimingMode(oldTiming)
5653
}
5754
preferences.edit().putBoolean(PRELOAD_MODELS_ENABLED_PREFERENCE, previousPreload).commit()
5855
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package com.kienhoang.dualsubreplay.ui
2+
3+
import android.app.Application
4+
import android.content.res.Configuration
5+
import android.webkit.WebResourceResponse
6+
import android.webkit.WebView
7+
import android.widget.FrameLayout
8+
import androidx.compose.runtime.CompositionLocalProvider
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.ui.platform.LocalConfiguration
11+
import androidx.compose.ui.test.junit4.createComposeRule
12+
import androidx.lifecycle.viewModelScope
13+
import androidx.test.platform.app.InstrumentationRegistry
14+
import com.kienhoang.dualsubreplay.data.CaptionProvider
15+
import com.kienhoang.dualsubreplay.data.CaptionTrackResult
16+
import com.kienhoang.dualsubreplay.data.RawCaptionCue
17+
import com.kienhoang.dualsubreplay.data.SubtitleWord
18+
import kotlinx.coroutines.cancel
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Assert.assertFalse
21+
import org.junit.Assert.assertTrue
22+
import org.junit.Rule
23+
import org.junit.Test
24+
import java.io.ByteArrayInputStream
25+
import java.util.concurrent.CountDownLatch
26+
import java.util.concurrent.TimeUnit
27+
import java.util.concurrent.atomic.AtomicInteger
28+
import java.util.concurrent.atomic.AtomicReference
29+
30+
class LandscapeLiveCaptureTest {
31+
@get:Rule val compose = createComposeRule()
32+
33+
@Test
34+
fun realPlayerKeepsLiveWordsWhenLandscapeAndFullscreenHideThePanel() {
35+
val instrumentation = InstrumentationRegistry.getInstrumentation()
36+
val application = instrumentation.targetContext.applicationContext as Application
37+
val preferences = application.getSharedPreferences("dual_sub_preferences", 0)
38+
val saved = preferences.all
39+
preferences
40+
.edit()
41+
.putBoolean("onboarding_completed", true)
42+
.putBoolean(GUIDE_COMPLETED_PREFERENCE, true)
43+
.putBoolean(PRELOAD_MODELS_ENABLED_PREFERENCE, false)
44+
.putBoolean(WORD_HIGHLIGHT_ENABLED_PREFERENCE, true)
45+
.putBoolean(AUTO_OVERLAY_LANDSCAPE_PREFERENCE, true)
46+
.putBoolean(AUTO_OVERLAY_FULLSCREEN_PREFERENCE, true)
47+
.putString(PLAYER_EXPERIENCE_MODE_PREFERENCE, PlayerExperienceMode.TRANSCRIPT_PANEL.storageValue)
48+
.putString("last_browser_url", "https://m.youtube.com/watch?v=abcdefghijk")
49+
.putString("preferred_caption_language", "en")
50+
.putString("target_language", "en")
51+
.putString(CAPTION_FORMAT_PREFERENCE, CaptionFormat.WHOLE_SENTENCE.storageValue)
52+
.putString(ORIGINAL_VISIBILITY, CaptionVisibility.ALWAYS.name)
53+
.commit()
54+
lateinit var model: AppViewModel
55+
instrumentation.runOnMainSync { model = AppViewModel(application, DelayedTranscript()) }
56+
val configuration = mutableStateOf(Configuration(application.resources.configuration))
57+
configuration.value = Configuration(configuration.value).apply { orientation = Configuration.ORIENTATION_PORTRAIT }
58+
val page = AtomicReference<WebView>()
59+
val loads = AtomicInteger()
60+
try {
61+
compose.setContent {
62+
CompositionLocalProvider(
63+
LocalConfiguration provides configuration.value,
64+
LocalYouTubeRequestInterceptor provides { view, request ->
65+
page.compareAndSet(null, view)
66+
val html = if (request.isForMainFrame) OFFLINE_PAGE.also { loads.incrementAndGet() } else ""
67+
WebResourceResponse("text/html", "UTF-8", ByteArrayInputStream(html.toByteArray()))
68+
},
69+
) {
70+
LearningPlayerRoot(model)
71+
}
72+
}
73+
compose.waitUntil(15_000) { model.state.value.generatedCaptions && model.state.value.activeWordIndex == 0 }
74+
assertEquals("true", javascript(page.get(), "!!window['$LIVE_CAPTION_CAPTURE_STATE_KEY']?.enabled"))
75+
// The transcript starts at 2 seconds. Earlier highlights can only
76+
// come from the actual WebView live-caption path.
77+
advanceCaption(page.get(), model, "One two", 0.2, 1)
78+
compose.runOnIdle {
79+
configuration.value = Configuration(configuration.value).apply { orientation = Configuration.ORIENTATION_LANDSCAPE }
80+
}
81+
compose.waitUntil(5000) { !model.state.value.subtitlePanelVisible }
82+
assertEquals("true", javascript(page.get(), "!!window['$LIVE_CAPTION_CAPTURE_STATE_KEY']?.enabled"))
83+
advanceCaption(page.get(), model, "One two three", 0.3, 2)
84+
compose.runOnIdle {
85+
page.get().webChromeClient!!.onShowCustomView(FrameLayout(page.get().context)) {}
86+
}
87+
compose.waitUntil(5000) { youtubeFullscreenActive.value }
88+
advanceCaption(page.get(), model, "One two three four", 0.4, 3)
89+
compose.runOnIdle { page.get().webChromeClient!!.onHideCustomView() }
90+
compose.waitUntil(5000) { !youtubeFullscreenActive.value }
91+
assertFalse(model.state.value.subtitlePanelVisible)
92+
compose.runOnIdle {
93+
configuration.value = Configuration(configuration.value).apply { orientation = Configuration.ORIENTATION_PORTRAIT }
94+
}
95+
compose.waitUntil(5000) { model.state.value.subtitlePanelVisible }
96+
advanceCaption(page.get(), model, "One two three four five", 0.5, 4)
97+
assertEquals("The same native page must survive every presentation switch", 1, loads.get())
98+
} finally {
99+
instrumentation.runOnMainSync {
100+
page.get()?.webChromeClient?.onHideCustomView()
101+
model.viewModelScope.cancel()
102+
youtubeFullscreenActive.value = false
103+
}
104+
val editor = preferences.edit().clear()
105+
saved.forEach { (key, value) ->
106+
when (value) {
107+
is String -> editor.putString(key, value)
108+
is Boolean -> editor.putBoolean(key, value)
109+
is Float -> editor.putFloat(key, value)
110+
is Int -> editor.putInt(key, value)
111+
is Long -> editor.putLong(key, value)
112+
}
113+
}
114+
editor.commit()
115+
}
116+
}
117+
118+
private fun advanceCaption(
119+
page: WebView,
120+
model: AppViewModel,
121+
text: String,
122+
second: Double,
123+
word: Int,
124+
) {
125+
javascript(page, "video._time = $second; document.querySelector('.ytp-caption-segment').textContent = '$text';")
126+
compose.waitUntil(5000) { model.state.value.activeWordIndex == word }
127+
assertTrue("A late transcript must not explain this update", second < 1.0)
128+
}
129+
130+
private fun javascript(
131+
page: WebView,
132+
script: String,
133+
): String {
134+
val done = CountDownLatch(1)
135+
val result = AtomicReference<String>()
136+
InstrumentationRegistry.getInstrumentation().runOnMainSync {
137+
page.evaluateJavascript(script) {
138+
result.set(it)
139+
done.countDown()
140+
}
141+
}
142+
assertTrue("WebView script did not complete", done.await(5, TimeUnit.SECONDS))
143+
return result.get()
144+
}
145+
146+
private class DelayedTranscript : CaptionProvider {
147+
override suspend fun fetch(
148+
videoId: String,
149+
preferredLanguages: List<String>,
150+
): CaptionTrackResult =
151+
CaptionTrackResult(
152+
"en",
153+
true,
154+
listOf(
155+
RawCaptionCue(
156+
0,
157+
12_000,
158+
"One two three four five",
159+
listOf("One", "two", "three", "four", "five").mapIndexed { index, word ->
160+
SubtitleWord(word, (index + 1) * 2000L, (index + 2) * 2000L)
161+
},
162+
),
163+
),
164+
)
165+
}
166+
167+
companion object {
168+
private val OFFLINE_PAGE =
169+
"""
170+
<html><body>
171+
<div id="movie_player" class="html5-video-player ytp-autohide">
172+
<video></video><span class="ytp-caption-segment">One</span>
173+
</div>
174+
<script>
175+
const video = document.querySelector('video');
176+
video._time = 0.1;
177+
Object.defineProperties(video, {
178+
currentTime: {get: () => video._time}, readyState: {get: () => 4},
179+
paused: {get: () => false}, seeking: {get: () => false}
180+
});
181+
video.requestVideoFrameCallback = function() {};
182+
const caption = document.querySelector('.ytp-caption-segment');
183+
const bootstrap = setInterval(function() {
184+
if (caption.textContent === 'One') caption.textContent = 'One.';
185+
else if (caption.textContent === 'One.') caption.textContent = 'One';
186+
else clearInterval(bootstrap);
187+
}, 250);
188+
const player = document.getElementById('movie_player');
189+
player.getOption = function() { return {languageCode: 'en'}; };
190+
player.setOption = function() {};
191+
player.getPlayerResponse = function() { return {videoDetails: {videoId: 'abcdefghijk'}}; };
192+
</script></body></html>
193+
""".trimIndent()
194+
}
195+
}

0 commit comments

Comments
 (0)