beautified some ui

This commit is contained in:
2026-08-17 14:28:37 +08:00
parent 624b834632
commit d27c2e229e
15 changed files with 1023 additions and 200 deletions

View File

@@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity

View File

@@ -1,12 +1,18 @@
package cn.learningpad.oraltrainer.sample
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.View
@@ -14,17 +20,22 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
import androidx.media3.common.MimeTypes
import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.PlayerConfig
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
@@ -33,11 +44,13 @@ import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback
import java.io.File
import java.util.Locale
class MainActivity : Activity() {
private lateinit var sdk: OralTrainerSdk
private lateinit var controller: OralTrainerController
private lateinit var controllerListener: OralTrainerListener
private lateinit var playerView: OralTrainerPlayerView
private lateinit var lessonTitleText: TextView
private lateinit var statusText: TextView
@@ -45,32 +58,78 @@ class MainActivity : Activity() {
private lateinit var sentenceMetaText: TextView
private lateinit var timeText: TextView
private lateinit var speedText: TextView
private lateinit var progressBar: ProgressBar
private lateinit var progressBar: SeekBar
private var progressBarDragging = false
private lateinit var catalogList: LinearLayout
private lateinit var catalogStatusText: TextView
private lateinit var testStatusText: TextView
private lateinit var scoreSummaryText: TextView
private lateinit var scoreDetailText: TextView
private lateinit var recordButton: Button
private var mediaRecorder: MediaRecorder? = null
private var recordingFile: File? = null
private var activeItemId: String = SAMPLE_ID
private var activeItemId: String = ""
private var currentSentenceCount = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
private var catalogStatusTextValue = "正在同步"
private var activeModule = Module.TRAIN
private var continuousPlaybackEnabled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureWindow()
sdk = OralTrainerSdk.init(this)
sdk = OralTrainerSdk.init(
this,
OralTrainerSdkConfig(
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
assessmentApiKey = ASSESSMENT_API_KEY,
allowHttpFallback = true,
)
)
controller = sdk.createController(
playerConfig = PlayerConfig(
sentenceMode = true,
defaultSeekStepMs = 10_000L,
autoPlay = false,
)
continuousPlayback = false,
),
imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
)
controller.setLoopMode(LoopMode.ALL)
controllerListener = createControllerListener()
controller.addListener(controllerListener)
setContentView(createContentView())
bindPlayerEvents()
loadSampleLesson()
loadCatalog()
refreshCurrentUi()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setContentView(createContentView())
renderCatalog()
refreshCurrentUi()
if (activeModule == Module.TEST) {
refreshTestUi()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != RECORD_AUDIO_REQUEST) {
return
}
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
startRecording()
} else if (::testStatusText.isInitialized) {
testStatusText.text = "需要麦克风权限才能进行录音评测"
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -122,6 +181,7 @@ class MainActivity : Activity() {
}
override fun onDestroy() {
discardRecording()
controller.release()
super.onDestroy()
}
@@ -133,13 +193,117 @@ class MainActivity : Activity() {
}
private fun createContentView(): View {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
return if (isLandscape) {
createLandscapeContentView()
} else {
createPortraitContentView()
}
}
private fun createPortraitContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createModuleTabBar())
if (activeModule == Module.TEST) {
addView(createTestContentView())
} else {
addView(createHeader())
addView(createPlayerSection())
addView(createSentenceSection())
addView(createCatalogSection())
}
}
}
private fun createTestContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createHeader())
addView(createPlayerSection())
addView(createSentenceSection())
addView(createCatalogSection())
addView(createTestSection())
}
}
private fun createModuleTabBar(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("训练", Module.TRAIN))
addView(moduleTab("测试", Module.TEST))
}
}
private fun moduleTab(label: String, module: Module): TextView {
val selected = activeModule == module
return TextView(this).apply {
text = label
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
gravity = Gravity.CENTER
isClickable = true
setTextColor(if (selected) Color.WHITE else COLOR_TEXT_MUTED)
background = if (selected) {
rounded(COLOR_BUTTON, 8f)
} else {
rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
}
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
setOnClickListener {
if (activeModule == module) {
return@setOnClickListener
}
if (activeModule == Module.TEST) {
discardRecording()
}
activeModule = module
controller.setContinuousPlayback(
if (activeModule == Module.TRAIN) continuousPlaybackEnabled else true
)
setContentView(createContentView())
renderCatalog()
refreshCurrentUi()
if (activeModule == Module.TEST) {
refreshTestUi()
}
}
}
}
private fun createLandscapeContentView(): View {
return FrameLayout(this).apply {
setBackgroundColor(Color.BLACK)
addView(createPlayerView())
addView(createPlayerOverlay())
addView(
LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
)
addView(createLandscapeSentencePanel().apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
})
addView(createRotationButtonRow())
}
)
}
}
private fun createLandscapeSentencePanel(): View {
return createSentenceContent(translucent = true).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
)
}
}
@@ -149,16 +313,16 @@ class MainActivity : Activity() {
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(this@MainActivity).apply {
text = "口语宝"
text = "跟读虫"
setTextColor(Color.WHITE)
textSize = 26f
textSize = 32f
typeface = Typeface.DEFAULT_BOLD
includeFontPadding = false
})
addView(TextView(this@MainActivity).apply {
text = "中英跟读训练"
text = "外语跟读训练神器"
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
textSize = 20f
setPadding(1.dp, 4.dp, 0, 0)
})
}
@@ -174,14 +338,40 @@ class MainActivity : Activity() {
}
private fun createPlayerSection(): View {
playerView = OralTrainerPlayerView(this).apply {
return FrameLayout(this).apply {
background = rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
clipToOutline = true
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
).withMargins(16.dp, 0, 16.dp, 10.dp)
addView(createPlayerView())
addView(createPlayerOverlay())
addView(createRotationButton().apply {
layoutParams = FrameLayout.LayoutParams(
44.dp,
44.dp,
Gravity.BOTTOM or Gravity.END,
).apply {
marginEnd = 12.dp
bottomMargin = 12.dp
}
})
}
}
private fun createPlayerView(): OralTrainerPlayerView {
return OralTrainerPlayerView(this).apply {
bind(controller)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
}.also { playerView = it }
}
private fun createPlayerOverlay(): View {
val overlay = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
@@ -192,71 +382,123 @@ class MainActivity : Activity() {
Gravity.TOP,
)
}
timeText = overlayPill("--:-- / --:--").apply {
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
speedText = overlayPill("1x")
overlay.addView(timeText)
overlay.addView(speedText)
return overlay
}
return FrameLayout(this).apply {
background = rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
clipToOutline = true
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
).withMargins(16.dp, 0, 16.dp, 10.dp)
addView(playerView)
addView(overlay)
private fun createRotationButtonRow(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
setPadding(0, 0, 12.dp, 12.dp)
addView(createRotationButton())
}
}
private fun createRotationButton(): ImageButton {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
return ImageButton(this).apply {
setImageResource(R.drawable.ic_screen_rotation)
imageTintList = ColorStateList.valueOf(Color.WHITE)
background = rounded(Color.argb(178, 9, 12, 16), 22.dp.toFloat())
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
contentDescription = if (isLandscape) "竖屏" else "横屏"
layoutParams = LinearLayout.LayoutParams(44.dp, 44.dp)
setOnClickListener { toggleOrientation() }
}
}
private fun toggleOrientation() {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
requestedOrientation = if (isLandscape) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
}
private fun createSentenceSection(): View {
lessonTitleText = TextView(this).apply {
setTextColor(COLOR_TEXT_DARK)
textSize = 17f
typeface = Typeface.DEFAULT_BOLD
maxLines = 1
}
sentenceMetaText = TextView(this).apply {
setTextColor(COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 8.dp, 0, 0)
}
sentenceText = TextView(this).apply {
setTextColor(COLOR_TEXT_DARK)
textSize = 22f
typeface = Typeface.DEFAULT_BOLD
setLineSpacing(2.dp.toFloat(), 1.05f)
setPadding(0, 10.dp, 0, 10.dp)
}
progressBar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
max = PROGRESS_MAX
progress = 0
progressTintList = ColorStateList.valueOf(COLOR_ACCENT)
progressBackgroundTintList = ColorStateList.valueOf(COLOR_PROGRESS_TRACK)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
6.dp,
)
}
statusText = TextView(this).apply {
setTextColor(COLOR_TEXT_SUBTLE)
textSize = 13f
setPadding(0, 10.dp, 0, 0)
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
return createSentenceContent(translucent = false).apply {
background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
setPadding(18.dp, 16.dp, 18.dp, 16.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(16.dp, 0, 16.dp, 12.dp)
}
}
private fun createSentenceContent(translucent: Boolean): LinearLayout {
lessonTitleText = TextView(this).apply {
setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
textSize = 17f
typeface = Typeface.DEFAULT_BOLD
maxLines = 1
}
sentenceMetaText = TextView(this).apply {
setTextColor(if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 8.dp, 0, 0)
}
sentenceText = TextView(this).apply {
setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK)
textSize = 22f
typeface = Typeface.DEFAULT_BOLD
setLineSpacing(2.dp.toFloat(), 1.05f)
setPadding(0, 10.dp, 0, 10.dp)
}
progressBar = SeekBar(this).apply {
max = PROGRESS_MAX
progress = 0
progressTintList = ColorStateList.valueOf(COLOR_ACCENT)
progressBackgroundTintList = ColorStateList.valueOf(
if (translucent) COLOR_PROGRESS_TRACK_DARK else COLOR_PROGRESS_TRACK
)
thumbTintList = ColorStateList.valueOf(COLOR_ACCENT)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
28.dp,
)
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
val duration = controller.snapshot().durationMs
timeText.text = "${formatTime(seekPositionForProgress(progress))} / ${formatTime(duration)}"
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
progressBarDragging = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
progressBarDragging = false
controller.seekTo(seekPositionForProgress(seekBar.progress))
}
})
}
statusText = TextView(this).apply {
setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
textSize = 13f
setPadding(0, 10.dp, 0, 0)
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
if (translucent) {
background = rounded(COLOR_OVERLAY, 0f, null)
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
}
addView(lessonTitleText)
if (activeModule == Module.TRAIN) {
addView(createContinuousPlaybackRow(translucent))
}
addView(sentenceMetaText)
addView(sentenceText)
addView(progressBar)
@@ -264,6 +506,36 @@ class MainActivity : Activity() {
}
}
private fun createContinuousPlaybackRow(translucent: Boolean): View {
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 6.dp, 0, 0)
addView(TextView(this@MainActivity).apply {
text = "连续播放"
setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE)
textSize = 13f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Switch(this@MainActivity).apply {
isChecked = continuousPlaybackEnabled
buttonTintList = ColorStateList.valueOf(
if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT
)
setOnCheckedChangeListener { _, checked ->
continuousPlaybackEnabled = checked
controller.setContinuousPlayback(checked)
statusText.text = if (checked) {
"已开启连续播放,将按句子顺序连续播放"
} else {
"已关闭连续播放,将循环播放当前句子"
}
}
})
}
}
private fun createCatalogSection(): View {
catalogStatusText = TextView(this).apply {
setTextColor(COLOR_TEXT_MUTED)
@@ -302,13 +574,243 @@ class MainActivity : Activity() {
}
}
private fun bindPlayerEvents() {
controller.addListener(object : OralTrainerListener {
private fun createTestSection(): View {
testStatusText = TextView(this).apply {
setTextColor(COLOR_ACCENT_DEEP)
textSize = 13f
setPadding(0, 0, 0, 8.dp)
}
scoreSummaryText = TextView(this).apply {
setTextColor(COLOR_TEXT_DARK)
textSize = 22f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 8.dp, 0, 0)
text = "尚未评测"
}
scoreDetailText = TextView(this).apply {
setTextColor(COLOR_TEXT_SUBTLE)
textSize = 13f
setLineSpacing(4.dp.toFloat(), 1f)
setPadding(0, 8.dp, 0, 0)
}
val sentenceButton = { label: String, action: () -> Unit ->
Button(this).apply {
text = label
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 13f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(0, 0, 0, 0)
background = rounded(COLOR_BUTTON, 8f)
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
setOnClickListener { action() }
}
}
recordButton = Button(this).apply {
text = "开始录音"
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
background = rounded(COLOR_ACCENT, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
44.dp,
)
setOnClickListener {
if (mediaRecorder == null) {
startRecording()
} else {
stopRecordingAndAssess()
}
}
}
val controlRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
addView(sentenceButton("上一句") { controller.seekToPreviousSentence() })
addView(sentenceButton("播放原句") {
controller.playCurrentSentenceAndStop()
statusText.text = "正在播放当前句子,播放完自动停止"
})
addView(sentenceButton("下一句") { controller.seekToNextSentence() })
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER)
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(16.dp, 0, 16.dp, 12.dp)
addView(TextView(this@MainActivity).apply {
text = "朗读评测(测试)"
setTextColor(COLOR_TEXT_DARK)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
})
addView(testStatusText)
addView(controlRow)
addView(recordButton)
addView(scoreSummaryText)
addView(scoreDetailText)
}
}
private fun refreshTestUi() {
if (!::testStatusText.isInitialized) {
return
}
recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测"
val item = controller.currentTrainingItem()
val sentence = controller.currentSentence()
testStatusText.text = if (item == null || sentence == null) {
"请先在训练模块选择一个云端课程"
} else {
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)"
}
}
private fun startRecording() {
controller.pause()
if (!::testStatusText.isInitialized) {
return
}
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RECORD_AUDIO_REQUEST)
return
}
val file = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
try {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
recorder.setAudioSamplingRate(16_000)
recorder.setAudioEncodingBitRate(96_000)
recorder.setOutputFile(file.absolutePath)
recorder.prepare()
recorder.start()
mediaRecorder = recorder
recordingFile = file
recordButton.text = "停止并评测"
testStatusText.text = "正在录音…读完当前句子后点击“停止并评测”"
} catch (error: Throwable) {
testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
runCatching { recorder.release() }
}
}
private fun stopRecordingAndAssess() {
val recorder = mediaRecorder ?: return
val file = recordingFile ?: return
mediaRecorder = null
recordButton.text = "开始录音"
try {
recorder.stop()
} catch (error: Throwable) {
testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}"
file.delete()
recordingFile = null
return
} finally {
runCatching { recorder.release() }
}
recordingFile = null
submitAssessment(Uri.fromFile(file))
}
private fun discardRecording() {
val recorder = mediaRecorder ?: return
mediaRecorder = null
runCatching { recorder.stop() }
runCatching { recorder.release() }
recordingFile?.delete()
recordingFile = null
}
private fun submitAssessment(recordingUri: Uri) {
val item = controller.currentTrainingItem()
val sentence = controller.currentSentence()
if (item == null || sentence == null) {
testStatusText.text = "没有正在学习的句子,无法评测"
return
}
if (!SHA256_PATTERN.matches(item.id)) {
testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
return
}
if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
testStatusText.text = "未配置评分密钥:请在 MainActivity 填入服务器 .env 的 CLIENT_API_KEY"
return
}
testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
scoreSummaryText.text = "评测中…"
scoreDetailText.text = ""
controller.assessCurrentSentence(
recordingUri = recordingUri,
locale = sentence.language
?.takeIf { it.isNotBlank() }
?: item.sentences.getOrNull(sentence.index)?.language,
metadata = mapOf("video_hash" to item.id),
callback = object : ImitationAssessmentCallback {
override fun onResult(result: ImitationAssessmentResult) {
if (::testStatusText.isInitialized) {
applyAssessmentResult(result)
}
}
override fun onError(error: Throwable) {
if (::testStatusText.isInitialized) {
testStatusText.text = "评测失败:${error.message.orEmpty()}"
scoreSummaryText.text = "评测失败"
}
}
},
)
}
private fun applyAssessmentResult(result: ImitationAssessmentResult) {
testStatusText.text = if (result.passed == true) "已通过" else "未通过"
scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
scoreDetailText.text = buildString {
result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) }
result.fluencyScore?.let { appendLine(String.format(Locale.US, "流畅度 %.1f", it)) }
result.durationScore?.let { appendLine(String.format(Locale.US, "时长分 %.1f", it)) }
result.pauseScore?.let { appendLine(String.format(Locale.US, "停顿分 %.1f", it)) }
result.speechRateScore?.let { appendLine(String.format(Locale.US, "语速分 %.1f", it)) }
result.referenceText?.let { appendLine("参考:$it") }
result.recognizedText?.let { appendLine("识别:$it") }
if (result.missingTokens.isNotEmpty()) {
appendLine("漏读:${result.missingTokens.joinToString("、")}")
}
if (result.extraTokens.isNotEmpty()) {
appendLine("多读:${result.extraTokens.joinToString("、")}")
}
result.feedback?.let { appendLine(it) }
}.trimEnd()
}
private fun createControllerListener(): OralTrainerListener {
return object : OralTrainerListener {
override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
speedText.text = formatSpeed(snapshot.playbackSpeed)
statusText.text = playbackStatus(snapshot)
progressBar.progress = playbackProgress(snapshot)
applyPlaybackSnapshot(snapshot)
}
override fun onMediaChanged(item: TrainingMediaItem?) {
@@ -317,18 +819,7 @@ class MainActivity : Activity() {
}
override fun onSentenceChanged(sentence: SentenceBoundary?) {
if (sentence == null) {
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = lessonTitleText.text
return
}
val countText = if (currentSentenceCount > 0) {
" / $currentSentenceCount"
} else {
""
}
sentenceMetaText.text = "${sentence.index + 1}$countText${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}"
sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
applySentence(sentence)
}
override fun onGesture(event: GestureEvent) {
@@ -343,33 +834,76 @@ class MainActivity : Activity() {
override fun onPlayerError(error: Throwable) {
statusText.text = "播放失败:${error.message.orEmpty()}"
}
})
}
}
private fun loadSampleLesson() {
val item = sampleOnlineLesson()
activeItemId = item.id
currentSentenceCount = item.sentences.size
controller.loadItem(item)
lessonTitleText.text = item.title
renderCatalog()
private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
if (!progressBarDragging) {
timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}"
progressBar.progress = playbackProgress(snapshot)
}
speedText.text = formatSpeed(snapshot.playbackSpeed)
statusText.text = playbackStatus(snapshot)
}
private fun seekPositionForProgress(progress: Int): Long {
val duration = controller.snapshot().durationMs
if (duration <= 0L) {
return 0L
}
return progress.toLong() * duration / PROGRESS_MAX
}
private fun applySentence(sentence: SentenceBoundary?) {
if (sentence == null) {
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = lessonTitleText.text
return
}
val countText = if (currentSentenceCount > 0) {
" / $currentSentenceCount"
} else {
""
}
sentenceMetaText.text = "${sentence.index + 1}$countText${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}"
sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子"
}
private fun refreshCurrentUi() {
val item = controller.currentTrainingItem()
lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0
applyPlaybackSnapshot(controller.snapshot())
applySentence(controller.currentSentence())
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
}
private fun loadCatalog() {
catalogStatusText.text = "正在同步"
catalogStatusTextValue = "正在同步"
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
catalogVideos = videos
catalogStatusText.text = if (videos.isEmpty()) {
catalogStatusTextValue = if (videos.isEmpty()) {
"暂无云端课程"
} else {
"${videos.size} 个云端课程"
}
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
renderCatalog()
}
override fun onError(error: Throwable) {
catalogStatusText.text = "云端暂不可用"
catalogStatusTextValue = "云端暂不可用${error.message.orEmpty()}"
if (::catalogStatusText.isInitialized) {
catalogStatusText.text = catalogStatusTextValue
}
renderCatalog()
}
})
@@ -380,15 +914,6 @@ class MainActivity : Activity() {
return
}
catalogList.removeAllViews()
catalogList.addView(
videoCard(
id = SAMPLE_ID,
title = "示例课程",
meta = "4 句 · ${formatTime(16_000L)}",
) {
loadSampleLesson()
}
)
catalogVideos.forEach { video ->
catalogList.addView(
videoCard(
@@ -516,22 +1041,6 @@ class MainActivity : Activity() {
)
}
private fun sampleOnlineLesson(): TrainingMediaItem {
return TrainingMediaItem(
id = SAMPLE_ID,
title = "口语宝示例课",
uri = Uri.parse("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
mimeType = MimeTypes.VIDEO_MP4,
customCacheKey = SAMPLE_ID,
sentences = listOf(
SentenceBoundary(0, 0L, 3_000L, "Good morning, everyone."),
SentenceBoundary(1, 3_000L, 7_000L, "Today we will practice listening carefully."),
SentenceBoundary(2, 7_000L, 11_000L, "Please repeat each sentence clearly."),
SentenceBoundary(3, 11_000L, 16_000L, "Small daily practice makes progress visible."),
),
)
}
private fun playbackStatus(snapshot: PlaybackSnapshot): String {
val state = when {
snapshot.isPlaying -> "播放中"
@@ -599,22 +1108,38 @@ class MainActivity : Activity() {
get() = (this * resources.displayMetrics.density).toInt()
private companion object {
// 服务端地址:改成你实际部署的域名或 IP。
// 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80
const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
const val PICK_VIDEO_REQUEST = 1001
const val SAMPLE_ID = "online_sample_01"
const val RECORD_AUDIO_REQUEST = 1002
const val PROGRESS_MAX = 1000
private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
enum class Module {
TRAIN,
TEST,
}
val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18)
val COLOR_SURFACE: Int = Color.rgb(28, 34, 40)
val COLOR_BORDER: Int = Color.rgb(50, 59, 67)
val COLOR_BUTTON: Int = Color.rgb(37, 99, 235)
val COLOR_ACCENT: Int = Color.rgb(21, 184, 132)
val COLOR_ACCENT_DEEP: Int = Color.rgb(7, 118, 86)
val COLOR_ACCENT_LIGHT: Int = Color.rgb(94, 234, 182)
val COLOR_SELECTED: Int = Color.rgb(229, 248, 240)
val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235)
val COLOR_PROGRESS_TRACK_DARK: Int = Color.rgb(55, 62, 70)
val COLOR_LIGHT_BORDER: Int = Color.rgb(218, 226, 232)
val COLOR_OVERLAY: Int = Color.argb(178, 9, 12, 16)
val COLOR_TEXT_DARK: Int = Color.rgb(18, 24, 31)
val COLOR_TEXT_MUTED: Int = Color.rgb(151, 162, 174)
val COLOR_TEXT_SUBTLE: Int = Color.rgb(88, 98, 108)
val COLOR_TEXT_OVERLAY: Int = Color.rgb(163, 172, 182)
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.48,2.52c3,2.02 5.02,5.27 5.02,9.48 0,1.77 -0.38,3.45 -1.06,4.97L10.02,2.94C11.39,2.51 12.86,2.28 14.4,2.28c0.74,0 1.47,0.08 2.08,0.24zM10.02,21.06L1.56,7.43C0.56,8.82 0,10.36 0,12c0,4.97 4.03,9 9,9h0.51C9.73,21.06 9.87,21.06 10.02,21.06zM9,22.06C3.96,22.06 -0.03,18.08 -0.03,13.04c0,-1.6 0.39,-3.11 1.07,-4.44l8.57,13.12C9.63,21.72 9.6,21.88 9.6,22.06 9.6,22.06 9.6,22.06 9,22.06zM20.93,5.44c0.68,1.33 1.07,2.84 1.07,4.44 0,5.04 -3.99,9.02 -9.03,9.02 -0.2,0 -0.4,-0.01 -0.6,-0.02l8.56,-13.44L20.93,5.44z"/>
</vector>

View File

@@ -1,3 +1,3 @@
<resources>
<string name="app_name">口语宝</string>
<string name="app_name">跟读虫</string>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
允许 HTTP 明文访问,便于直连内网/开发环境部署的服务器(如 http://192.168.x.x
同时信任系统证书和用户手动安装的证书(自签/私有 CA 时需先把证书装到手机)。
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>