add chinese subtitles

This commit is contained in:
2026-10-03 12:14:17 +08:00
parent 138d446d73
commit 6bd64ee4a9
22 changed files with 922 additions and 77 deletions

View File

@@ -159,6 +159,11 @@ class SentenceBoundaryApi internal constructor(
} else {
sentence.getLong("reference_speech_duration_ms")
},
zhText = if (sentence.isNull("zh_text")) {
null
} else {
sentence.getString("zh_text").takeIf { it.isNotBlank() }
},
)
)
}

View File

@@ -10,6 +10,7 @@ data class SentenceBoundary @JvmOverloads constructor(
val text: String? = null,
val language: String? = null,
val referenceSpeechDurationMs: Long? = null,
val zhText: String? = null,
) {
init {
require(index >= 0) { "Sentence index must be non-negative." }

View File

@@ -40,6 +40,8 @@ class MainActivity : Activity() {
private lateinit var catalogController: CourseCatalogController
private lateinit var testSession: TestSessionController
private lateinit var explanationOverlay: SentenceExplanationOverlay
private lateinit var translationApi: SubtitleTranslationApi
private var translationRequest: CancellableTranslationRequest? = null
private var progressBarDragging = false
@@ -70,6 +72,9 @@ class MainActivity : Activity() {
authSessionStore.session?.token.orEmpty()
},
)
translationApi = SubtitleTranslationApi(this, SERVER_BASE_URL) {
authSessionStore.session?.token.orEmpty()
}
screenFactory = SampleScreenFactory(
activity = this,
@@ -175,6 +180,8 @@ class MainActivity : Activity() {
testSession.release()
controller.release()
explanationOverlay.release()
translationRequest?.cancel()
translationApi.release()
super.onDestroy()
}
@@ -255,6 +262,7 @@ class MainActivity : Activity() {
screenState.currentSentenceCount = item?.sentences?.size ?: 0
applyPlaybackSnapshot(controller.snapshot())
applySentence(controller.currentSentence())
ensureChineseTranslations()
screenFactory.setCatalogStatus(screenState.catalogStatus)
}
@@ -268,6 +276,7 @@ class MainActivity : Activity() {
bindings.lessonTitleText.text = item?.title ?: "未选择课程"
screenState.currentSentenceCount = item?.sentences?.size ?: 0
}
ensureChineseTranslations()
testSession.ensureHistoryForCurrentCourse()
if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi()
@@ -311,6 +320,7 @@ class MainActivity : Activity() {
bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = ""
bindings.explainSentenceButton?.isEnabled = false
applyChineseSubtitle(null)
return
}
val countText = if (screenState.currentSentenceCount > 0) {
@@ -322,11 +332,83 @@ class MainActivity : Activity() {
"第 ${sentence.index + 1}$countText 句 ${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}"
bindings.sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
bindings.explainSentenceButton?.isEnabled = !sentence.text.isNullOrBlank()
applyChineseSubtitle(sentence)
if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi()
}
}
private fun applyChineseSubtitle(sentence: SentenceBoundary?) {
if (!bindings.hasPlayerChrome) return
val showChinese = screenState.activeModule == Module.TRAIN &&
screenState.chineseSubtitlesEnabled &&
sentence != null &&
!sentence.text.isNullOrBlank()
if (!showChinese || sentence == null) {
bindings.sentenceZhText.visibility = android.view.View.GONE
return
}
bindings.sentenceZhText.visibility = android.view.View.VISIBLE
bindings.sentenceZhText.text = when {
!sentence.zhText.isNullOrBlank() -> sentence.zhText
screenState.chineseTranslationLoading -> "正在翻译中文字幕…"
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
else -> "暂无中文字幕"
}
}
private fun ensureChineseTranslations() {
if (screenState.activeModule != Module.TRAIN || !screenState.chineseSubtitlesEnabled) {
return
}
val item = controller.currentTrainingItem() ?: return
val sentences = item.sentences
val needsTranslation = sentences.any { !it.text.isNullOrBlank() && it.zhText.isNullOrBlank() }
if (!needsTranslation) {
screenState.chineseTranslationLoading = false
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
return
}
val generation = screenState.beginTranslationRequest()
screenState.chineseTranslationLoading = true
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
translationRequest?.cancel()
translationRequest = translationApi.translate(
item.id,
object : SubtitleTranslationCallback {
override fun onSuccess(result: SubtitleTranslationResult) {
if (!screenState.isCurrentTranslationRequest(generation, item.id)) return
screenState.chineseTranslationLoading = false
val current = controller.currentTrainingItem() ?: return
if (current.id != item.id) return
val updated = current.sentences.map { sentence ->
sentence.copy(zhText = result.translations[sentence.index] ?: sentence.zhText)
}
controller.updateSentences(current.id, updated)
screenState.courseBoundaries[current.id]?.let { cached ->
screenState.courseBoundaries[current.id] = cached.copy(sentences = updated)
}
applyChineseSubtitle(controller.currentSentence())
if (bindings.hasPlayerChrome) {
bindings.statusText.text = "已加载中文字幕"
}
}
override fun onError(error: Throwable) {
if (!screenState.isCurrentTranslationRequest(generation, item.id)) return
screenState.chineseTranslationLoading = false
screenState.chineseTranslationError = "中文字幕翻译失败"
applyChineseSubtitle(controller.currentSentence())
if (bindings.hasPlayerChrome) {
bindings.statusText.text = "中文字幕翻译失败:${error.message.orEmpty()}"
}
}
},
)
}
private fun screenActions() = object : SampleScreenActions {
override fun onModuleSelected(module: Module) = switchModule(module)
@@ -360,6 +442,24 @@ class MainActivity : Activity() {
}
}
override fun onChineseSubtitlesChanged(enabled: Boolean) {
screenState.chineseSubtitlesEnabled = enabled
if (bindings.hasPlayerChrome) {
bindings.statusText.text = if (enabled) {
"已开启中文字幕"
} else {
"已关闭中文字幕"
}
}
if (enabled) {
ensureChineseTranslations()
} else {
translationRequest?.cancel()
screenState.chineseTranslationLoading = false
applyChineseSubtitle(controller.currentSentence())
}
}
override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return

View File

@@ -13,6 +13,7 @@ internal class SampleBindings {
lateinit var lessonTitleText: TextView
lateinit var statusText: TextView
lateinit var sentenceText: TextView
lateinit var sentenceZhText: TextView
lateinit var sentenceMetaText: TextView
lateinit var timeText: TextView
lateinit var speedText: TextView
@@ -35,6 +36,7 @@ internal class SampleBindings {
get() = ::lessonTitleText.isInitialized &&
::statusText.isInitialized &&
::sentenceText.isInitialized &&
::sentenceZhText.isInitialized &&
::sentenceMetaText.isInitialized &&
::timeText.isInitialized &&
::speedText.isInitialized &&

View File

@@ -21,6 +21,7 @@ internal interface TrainScreenActions :
fun onSeekStart()
fun onSeekStop(progress: Int)
fun onContinuousPlaybackChanged(enabled: Boolean)
fun onChineseSubtitlesChanged(enabled: Boolean)
}
internal interface TestScreenActions :

View File

@@ -0,0 +1,104 @@
package cn.learningpad.oraltrainer.sample
import android.content.Context
import android.os.Handler
import android.os.Looper
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
data class SubtitleTranslationResult(
val videoHash: String,
val translations: Map<Int, String>,
)
interface SubtitleTranslationCallback {
fun onSuccess(result: SubtitleTranslationResult)
fun onError(error: Throwable)
}
class CancellableTranslationRequest internal constructor(
private val future: Future<*>,
) {
fun cancel() {
future.cancel(true)
}
}
/** Calls the app server; AI credentials never live in the mobile app. */
internal class SubtitleTranslationApi(
context: Context,
private val baseUrl: String,
private val tokenProvider: () -> String,
) {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newCachedThreadPool()
fun translate(
videoHash: String,
callback: SubtitleTranslationCallback,
): CancellableTranslationRequest {
val future = executor.submit {
try {
val result = requestBlocking(videoHash)
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(result) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableTranslationRequest(future)
}
fun release() {
executor.shutdownNow()
}
private fun requestBlocking(videoHash: String): SubtitleTranslationResult {
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
val connection = URL(endpoint).openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.doOutput = false
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.5 (Android)")
tokenProvider().takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
val response = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
throw IOException("Subtitle translation API returned HTTP $statusCode: $response")
}
val root = JSONObject(response)
val items = root.getJSONArray("translations")
val translations = buildMap {
for (index in 0 until items.length()) {
val item = items.getJSONObject(index)
val zh = item.optString("zh_text")
if (zh.isNotBlank()) {
put(item.getInt("index"), zh)
}
}
}
return SubtitleTranslationResult(
videoHash = root.getString("video_hash"),
translations = translations,
)
} finally {
connection.disconnect()
}
}
}

View File

@@ -14,11 +14,15 @@ internal class TrainingScreenState {
var activeModule = Module.COURSE
var currentCourse: TrainingVideoSummary? = null
var continuousPlaybackEnabled = false
var chineseSubtitlesEnabled = false
var chineseTranslationLoading = false
var chineseTranslationError = ""
var testSubtitleVisible = false
var landscapeSubtitleVisible = true
private var boundaryRequestGeneration = 0
private var catalogRequestGeneration = 0
private var translationRequestGeneration = 0
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
@@ -30,6 +34,11 @@ internal class TrainingScreenState {
fun isCurrentCatalogRequest(generation: Int): Boolean =
catalogRequestGeneration == generation
fun beginTranslationRequest(): Int = ++translationRequestGeneration
fun isCurrentTranslationRequest(generation: Int, itemId: String): Boolean =
translationRequestGeneration == generation && activeItemId == itemId
fun clearCourse() {
currentCourse = null
activeItemId = ""

View File

@@ -155,7 +155,14 @@ internal class PlayerChrome(
textSize = 22f
typeface = Typeface.DEFAULT_BOLD
setLineSpacing(2.dp.toFloat(), 1.05f)
setPadding(0, 10.dp, 0, 10.dp)
setPadding(0, 10.dp, 0, 4.dp)
}
bindings.sentenceZhText = TextView(activity).apply {
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
textSize = 16f
setLineSpacing(2.dp.toFloat(), 1.05f)
setPadding(0, 0, 0, 8.dp)
visibility = View.GONE
}
bindings.progressBar = SeekBar(activity).apply {
max = PROGRESS_MAX
@@ -202,13 +209,30 @@ internal class PlayerChrome(
}
if (screenState.activeModule == Module.TRAIN) {
addView(createContinuousPlaybackRow(translucent))
addView(createChineseSubtitleRow(translucent))
}
addView(bindings.sentenceMetaText)
addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.TOP
addView(
bindings.sentenceText,
LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(
bindings.sentenceText,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
addView(
bindings.sentenceZhText,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
},
LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
@@ -349,6 +373,29 @@ internal class PlayerChrome(
})
}
private fun createChineseSubtitleRow(translucent: Boolean): View =
LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 6.dp, 0, 0)
addView(TextView(activity).apply {
text = "中文字幕"
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
textSize = 13f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Switch(activity).apply {
isChecked = screenState.chineseSubtitlesEnabled
buttonTintList = ColorStateList.valueOf(
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
)
setOnCheckedChangeListener { _, checked ->
seekActions.onChineseSubtitlesChanged(checked)
}
})
}
private fun installLandscapeSwipeGesture(subtitleContainer: View) {
bindings.playerView.setVerticalSwipeListener { isSwipeUp ->
if (isSwipeUp == screenState.landscapeSubtitleVisible) return@setVerticalSwipeListener