add chinese subtitles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -21,6 +21,7 @@ internal interface TrainScreenActions :
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onContinuousPlaybackChanged(enabled: Boolean)
|
||||
fun onChineseSubtitlesChanged(enabled: Boolean)
|
||||
}
|
||||
|
||||
internal interface TestScreenActions :
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user