add explain module

This commit is contained in:
2026-09-06 07:38:56 +08:00
parent 7d843e8ff1
commit 701148e00e
20 changed files with 775 additions and 8 deletions

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.3"
versionName = "0.1.4"
}
compileOptions {

View File

@@ -42,6 +42,7 @@ class MainActivity : Activity() {
private lateinit var screenFactory: SampleScreenFactory
private lateinit var catalogController: CourseCatalogController
private lateinit var testSession: TestSessionController
private lateinit var explanationOverlay: SentenceExplanationOverlay
private var progressBarDragging = false
@@ -66,6 +67,12 @@ class MainActivity : Activity() {
sdk = sdkComponents.sdk
controller = sdkComponents.controller
authCoordinator = AuthCoordinator(this, sdk.userApi, ::saveAuthSession)
explanationOverlay = SentenceExplanationOverlay(
this,
SentenceExplanationApi(this, SERVER_BASE_URL) {
authSessionStore.session?.token.orEmpty()
},
)
screenFactory = SampleScreenFactory(
activity = this,
@@ -217,6 +224,7 @@ class MainActivity : Activity() {
authCoordinator.reset()
testSession.release()
controller.release()
explanationOverlay.release()
super.onDestroy()
}
@@ -365,6 +373,7 @@ class MainActivity : Activity() {
if (sentence == null) {
bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = bindings.lessonTitleText.text
bindings.explainSentenceButton?.isEnabled = false
return
}
val countText = if (screenState.currentSentenceCount > 0) {
@@ -375,6 +384,7 @@ class MainActivity : Activity() {
bindings.sentenceMetaText.text =
"第 ${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()
if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi()
}
@@ -417,6 +427,16 @@ class MainActivity : Activity() {
}
}
override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return
controller.pause()
explanationOverlay.show(
sentence,
controller.currentTrainingItem()?.title.orEmpty(),
)
}
override fun onTestSubtitleChanged(visible: Boolean) {
testSession.onTestSubtitleChanged(visible)
}

View File

@@ -1,6 +1,7 @@
package cn.learningpad.oraltrainer.sample
import android.widget.Button
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
@@ -26,6 +27,7 @@ internal class SampleBindings {
lateinit var mergeDubButton: Button
lateinit var shareDubButton: Button
var dubbingStatusText: TextView? = null
var explainSentenceButton: ImageButton? = null
var myContentList: LinearLayout? = null
val hasPlayerChrome: Boolean

View File

@@ -10,7 +10,12 @@ internal interface CourseScreenActions {
fun onSelectCourse(video: TrainingVideoSummary)
}
internal interface TrainScreenActions {
internal interface SentenceExplainActions {
fun onExplainCurrentSentence()
}
internal interface TrainScreenActions :
SentenceExplainActions {
fun onImportLocalVideo()
fun onRefreshCatalog()
fun onSeekProgressPreview(progress: Int)
@@ -20,7 +25,8 @@ internal interface TrainScreenActions {
fun onTrainCatalogVideoClick(video: TrainingVideoSummary)
}
internal interface TestScreenActions {
internal interface TestScreenActions :
SentenceExplainActions {
fun onSeekProgressPreview(progress: Int)
fun onSeekStart()
fun onSeekStop(progress: Int)

View File

@@ -0,0 +1,113 @@
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 SentenceExplanationResult(
val explanation: String,
)
interface SentenceExplanationCallback {
fun onSuccess(result: SentenceExplanationResult)
fun onError(error: Throwable)
}
class CancellableExplanationRequest 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 SentenceExplanationApi(
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 explain(
sentenceText: String,
courseTitle: String,
sentenceIndex: Int?,
language: String?,
callback: SentenceExplanationCallback,
): CancellableExplanationRequest {
val future = executor.submit {
try {
val result = requestBlocking(
sentenceText,
courseTitle,
sentenceIndex,
language,
)
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(result) }
}
} catch (error: Throwable) {
if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onError(error) }
}
}
}
return CancellableExplanationRequest(future)
}
fun release() {
executor.shutdownNow()
}
private fun requestBlocking(
sentenceText: String,
courseTitle: String,
sentenceIndex: Int?,
language: String?,
): SentenceExplanationResult {
val endpoint = baseUrl.trimEnd('/') + "/api/v1/sentences/explain"
val body = JSONObject().apply {
put("sentence_text", sentenceText)
put("course_title", courseTitle)
sentenceIndex?.let { put("sentence_index", it) }
language?.takeIf { it.isNotBlank() }?.let { put("language", it) }
}
val connection = URL(endpoint).openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.doOutput = true
connection.useCaches = false
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("User-Agent", "OralTrainerSample/0.1.4 (Android)")
tokenProvider().takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
connection.outputStream.use { output ->
output.write(body.toString().toByteArray(Charsets.UTF_8))
}
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("AI explain API returned HTTP $statusCode: $response")
}
val explanation = JSONObject(response).getString("explanation")
return SentenceExplanationResult(explanation)
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,150 @@
package cn.learningpad.oraltrainer.sample
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
/** Modal explanation layer shown after the user taps the subtitle AI button. */
internal class SentenceExplanationOverlay(
private val activity: Activity,
private val api: SentenceExplanationApi,
) {
private var currentOverlay: View? = null
private var currentRequest: CancellableExplanationRequest? = null
private var requestGeneration = 0
fun show(sentence: SentenceBoundary, courseTitle: String) {
close()
val generation = ++requestGeneration
val root = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(Color.argb(160, 0, 0, 0))
isClickable = true
isFocusable = true
}
val card = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
background = rounded(Color.WHITE, 16f, SampleTheme.lightBorder)
setPadding(20.dp, 20.dp, 20.dp, 18.dp)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
Gravity.CENTER,
).apply {
setMargins(24.dp, 48.dp, 24.dp, 48.dp)
}
}
val title = TextView(activity).apply {
text = "句子讲解"
setTextColor(SampleTheme.textDark)
textSize = 19f
typeface = Typeface.DEFAULT_BOLD
}
val subtitle = TextView(activity).apply {
text = sentence.text?.takeIf { it.isNotBlank() } ?: courseTitle
setTextColor(SampleTheme.accentDeep)
textSize = 14f
setPadding(0, 8.dp, 0, 12.dp)
maxLines = 3
}
val message = TextView(activity).apply {
text = "AI 正在讲解这句话…"
setTextColor(SampleTheme.textSubtle)
textSize = 15f
setLineSpacing(3.dp.toFloat(), 1.1f)
}
val scroll = ScrollView(activity).apply {
addView(message)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
}
val close = TextView(activity).apply {
text = "关闭"
setTextColor(Color.WHITE)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
gravity = Gravity.CENTER
background = rounded(SampleTheme.button, 10f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
44.dp,
).apply { topMargin = 16.dp }
setOnClickListener { close() }
}
card.addView(title)
card.addView(subtitle)
card.addView(scroll)
card.addView(close)
root.addView(card)
addToWindow(root)
currentOverlay = root
currentRequest = api.explain(
sentence.text.orEmpty(),
courseTitle,
sentence.index,
sentence.language,
object : SentenceExplanationCallback {
override fun onSuccess(result: SentenceExplanationResult) {
if (generation == requestGeneration) message.text = result.explanation
}
override fun onError(error: Throwable) {
if (generation == requestGeneration) {
message.text = "讲解失败:${error.message ?: "请稍后重试"}"
message.setTextColor(SampleTheme.danger)
}
}
},
)
}
fun close() {
currentRequest?.cancel()
currentRequest = null
currentOverlay?.let { overlay ->
(overlay.parent as? ViewGroup)?.removeView(overlay)
}
currentOverlay = null
}
fun release() {
close()
api.release()
}
private fun addToWindow(view: View) {
(activity.window?.decorView as? ViewGroup)?.addView(view)
}
private val Int.dp: Int
get() = (this * activity.resources.displayMetrics.density).toInt()
private val Float.dp: Int
get() = (this * activity.resources.displayMetrics.density).toInt()
private fun rounded(
color: Int,
radiusDp: Float,
strokeColor: Int? = null,
): GradientDrawable = GradientDrawable().apply {
setColor(color)
cornerRadius = radiusDp.dp.toFloat()
strokeColor?.let { setStroke(1.dp, it) }
}
}

View File

@@ -162,7 +162,23 @@ internal class PlayerChrome(
addView(createContinuousPlaybackRow(translucent))
}
addView(bindings.sentenceMetaText)
addView(bindings.sentenceText)
addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(
bindings.sentenceText,
LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f,
),
)
bindings.explainSentenceButton = createExplainButton(translucent)
addView(
bindings.explainSentenceButton,
LinearLayout.LayoutParams(32.dp, 32.dp).apply { marginStart = 10.dp },
)
})
addView(bindings.progressBar)
addView(bindings.statusText)
}
@@ -211,6 +227,23 @@ internal class PlayerChrome(
}
}
private fun createExplainButton(translucent: Boolean): ImageButton =
ImageButton(activity).apply {
setImageResource(R.drawable.ic_ai_explain)
imageTintList = ColorStateList.valueOf(
if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep,
)
background = rounded(
if (translucent) SampleTheme.overlay else SampleTheme.selected,
16.dp.toFloat(),
)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(6.dp, 6.dp, 6.dp, 6.dp)
contentDescription = "AI 讲解这句话"
isEnabled = false
setOnClickListener { seekActions.onExplainCurrentSentence() }
}
private fun createLandscapeSentencePanel(): View =
createSentenceContent(translucent = true).apply {
layoutParams = FrameLayout.LayoutParams(

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M24,4a13,13 0 0 0 -7.2,23.8c0.7,0.5 1.2,1.3 1.2,2.2v1.5h12v-1.5c0,-0.9 0.5,-1.7 1.2,-2.2A13,13 0 0 0 24,4zM18,36h12v3h-12zM20,42h8v2h-8z"/>
<path
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M38,10l1.8,4.2L44,16l-4.2,1.8L38,22l-1.8,-4.2L32,16l4.2,-1.8z"/>
</vector>