add explain module
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
14
android/sample-app/src/main/res/drawable/ic_ai_explain.xml
Normal file
14
android/sample-app/src/main/res/drawable/ic_ai_explain.xml
Normal 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>
|
||||
Reference in New Issue
Block a user