add test module

This commit is contained in:
2026-08-16 15:39:52 +08:00
parent d0310620fc
commit 6e4d93cea6
46 changed files with 3880 additions and 206 deletions

View File

@@ -3,7 +3,7 @@
<application
android:allowBackup="true"
android:label="Oral Trainer Sample"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity

View File

@@ -2,16 +2,24 @@ package cn.learningpad.oraltrainer.sample
import android.app.Activity
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ProgressBar
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.LoopMode
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerListener
@@ -21,21 +29,33 @@ import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.PlayerConfig
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
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.util.Locale
class MainActivity : Activity() {
private companion object {
const val PICK_VIDEO_REQUEST = 1001
}
private lateinit var sdk: OralTrainerSdk
private lateinit var controller: OralTrainerController
private lateinit var playerView: OralTrainerPlayerView
private lateinit var lessonTitleText: TextView
private lateinit var statusText: TextView
private lateinit var sentenceText: TextView
private lateinit var sentenceMetaText: TextView
private lateinit var timeText: TextView
private lateinit var speedText: TextView
private lateinit var progressBar: ProgressBar
private lateinit var catalogList: LinearLayout
private lateinit var catalogStatusText: TextView
private var activeItemId: String = SAMPLE_ID
private var currentSentenceCount = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureWindow()
sdk = OralTrainerSdk.init(this)
controller = sdk.createController(
@@ -47,77 +67,10 @@ class MainActivity : Activity() {
)
controller.setLoopMode(LoopMode.ALL)
val playerView = OralTrainerPlayerView(this).apply {
bind(controller)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
}
statusText = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 14f
setPadding(24, 16, 24, 8)
}
sentenceText = TextView(this).apply {
setTextColor(Color.WHITE)
textSize = 18f
setPadding(24, 4, 24, 16)
}
val controls = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
setPadding(16, 8, 16, 20)
addView(commandButton("快退") { controller.rewind() })
addView(commandButton("播放/暂停") { controller.togglePlayPause() })
addView(commandButton("快进") { controller.fastForward() })
addView(commandButton("上一句") { controller.previousSentenceOrRewind() })
addView(commandButton("下一句") { controller.nextSentenceOrForward() })
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.rgb(18, 18, 18))
addView(playerView)
addView(statusText)
addView(sentenceText)
addView(controls)
addView(Button(this@MainActivity).apply {
text = "选择本地视频"
setOnClickListener { openLocalVideoPicker() }
})
}
setContentView(root)
controller.addListener(object : OralTrainerListener {
override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
statusText.text = buildString {
append(if (snapshot.isPlaying) "播放中" else "已暂停")
append(" ")
append(format(snapshot.positionMs))
append(" / ")
append(format(snapshot.durationMs))
append(" 速度 ")
append(snapshot.playbackSpeed)
append("x")
}
}
override fun onSentenceChanged(sentence: SentenceBoundary?) {
sentenceText.text = sentence?.let {
"${it.index + 1} 句:${it.text.orEmpty()}"
} ?: "当前没有句子边界"
}
override fun onGesture(event: GestureEvent) {
statusText.text = "手势:${event.kind} ${event.action ?: ""}"
}
})
controller.loadItem(sampleOnlineLesson())
setContentView(createContentView())
bindPlayerEvents()
loadSampleLesson()
loadCatalog()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -132,21 +85,38 @@ class MainActivity : Activity() {
contentResolver.takePersistableUriPermission(uri, persistableFlags)
}
}
val item = TrainingMediaItem(
id = uri.toString(),
title = uri.lastPathSegment ?: "本地视频",
uri = uri,
)
activeItemId = item.id
currentSentenceCount = 0
controller.loadItem(item)
statusText.text = "正在获取句子边界..."
lessonTitleText.text = item.title
sentenceMetaText.text = "句子边界分析中"
sentenceText.text = "正在匹配服务端句子边界"
statusText.text = "正在获取本地视频的句子边界..."
renderCatalog()
sdk.sentenceBoundaryApi.fetchForUri(uri, contentResolver, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
if (activeItemId != item.id) {
return
}
currentSentenceCount = result.sentences.size
controller.loadItem(item.copy(sentences = result.sentences))
statusText.text = "句子边界已加载:${result.sentences.size}"
}
override fun onError(error: Throwable) {
statusText.text = "句子边界获取失败,使用 10 秒跳转:${error.message.orEmpty()}"
if (activeItemId != item.id) {
return
}
statusText.text = "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = item.title
}
})
}
@@ -156,18 +126,381 @@ class MainActivity : Activity() {
super.onDestroy()
}
private fun commandButton(label: String, action: () -> Unit): Button {
@Suppress("DEPRECATION")
private fun configureWindow() {
window.statusBarColor = COLOR_BACKGROUND
window.navigationBarColor = COLOR_BACKGROUND
}
private fun createContentView(): View {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND)
addView(createHeader())
addView(createPlayerSection())
addView(createSentenceSection())
addView(createCatalogSection())
}
}
private fun createHeader(): View {
val titleBlock = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(this@MainActivity).apply {
text = "口语宝"
setTextColor(Color.WHITE)
textSize = 26f
typeface = Typeface.DEFAULT_BOLD
includeFontPadding = false
})
addView(TextView(this@MainActivity).apply {
text = "中英跟读训练"
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
setPadding(1.dp, 4.dp, 0, 0)
})
}
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
addView(titleBlock)
addView(headerButton("导入") { openLocalVideoPicker() })
addView(headerButton("刷新") { loadCatalog() })
}
}
private fun createPlayerSection(): View {
playerView = OralTrainerPlayerView(this).apply {
bind(controller)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
val overlay = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(12.dp, 12.dp, 12.dp, 12.dp)
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
)
}
timeText = overlayPill("--:-- / --:--").apply {
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
speedText = overlayPill("1x")
overlay.addView(timeText)
overlay.addView(speedText)
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 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
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)
addView(lessonTitleText)
addView(sentenceMetaText)
addView(sentenceText)
addView(progressBar)
addView(statusText)
}
}
private fun createCatalogSection(): View {
catalogStatusText = TextView(this).apply {
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
gravity = Gravity.END
text = "正在同步"
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
catalogList = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 10.dp, 0, 4.dp)
}
val header = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(TextView(this@MainActivity).apply {
text = "课程"
setTextColor(Color.WHITE)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
})
addView(catalogStatusText)
}
val scroller = HorizontalScrollView(this).apply {
isHorizontalScrollBarEnabled = false
addView(catalogList)
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 0, 16.dp, 14.dp)
addView(header)
addView(scroller)
}
}
private fun bindPlayerEvents() {
controller.addListener(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)
}
override fun onMediaChanged(item: TrainingMediaItem?) {
lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0
}
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() } ?: "当前句子"
}
override fun onGesture(event: GestureEvent) {
statusText.text = when (event.kind) {
GestureKind.SINGLE_TAP -> "播放状态已切换"
GestureKind.SWIPE_LEFT -> "已跳到上一句"
GestureKind.SWIPE_RIGHT -> "已跳到下一句"
GestureKind.LONG_PRESS_SPEED -> "变速播放中"
}
}
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 loadCatalog() {
catalogStatusText.text = "正在同步"
sdk.videoCatalogApi.fetch(object : VideoCatalogCallback {
override fun onSuccess(videos: List<TrainingVideoSummary>) {
catalogVideos = videos
catalogStatusText.text = if (videos.isEmpty()) {
"暂无云端课程"
} else {
"${videos.size} 个云端课程"
}
renderCatalog()
}
override fun onError(error: Throwable) {
catalogStatusText.text = "云端暂不可用"
renderCatalog()
}
})
}
private fun renderCatalog() {
if (!::catalogList.isInitialized) {
return
}
catalogList.removeAllViews()
catalogList.addView(
videoCard(
id = SAMPLE_ID,
title = "示例课程",
meta = "4 句 · ${formatTime(16_000L)}",
) {
loadSampleLesson()
}
)
catalogVideos.forEach { video ->
catalogList.addView(
videoCard(
id = video.videoHash,
title = video.title,
meta = "${video.sentenceCount} 句 · ${formatTime(video.durationMs ?: -1L)}",
) {
loadRemoteVideo(video)
}
)
}
}
private fun loadRemoteVideo(video: TrainingVideoSummary) {
activeItemId = video.videoHash
lessonTitleText.text = video.title
sentenceMetaText.text = "句子边界加载中"
sentenceText.text = video.title
statusText.text = "正在加载云端课程..."
renderCatalog()
val loadingItem = video.toTrainingMediaItem()
controller.loadItem(loadingItem)
currentSentenceCount = video.sentenceCount
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
if (activeItemId != video.videoHash) {
return
}
currentSentenceCount = result.sentences.size
controller.loadItem(video.toTrainingMediaItem(result.sentences))
statusText.text = "课程已就绪:${result.sentences.size}"
}
override fun onError(error: Throwable) {
if (activeItemId != video.videoHash) {
return
}
statusText.text = "句子边界加载失败:${error.message.orEmpty()}"
sentenceMetaText.text = "暂无句子边界"
sentenceText.text = video.title
}
})
}
private fun videoCard(
id: String,
title: String,
meta: String,
onClick: () -> Unit,
): View {
val selected = activeItemId == id
val titleColor = if (selected) COLOR_TEXT_DARK else Color.WHITE
val metaColor = if (selected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
isClickable = true
background = if (selected) {
rounded(COLOR_SELECTED, 8f, COLOR_ACCENT)
} else {
rounded(COLOR_SURFACE, 8f, COLOR_BORDER)
}
setPadding(14.dp, 12.dp, 14.dp, 12.dp)
layoutParams = LinearLayout.LayoutParams(218.dp, 84.dp).withMargins(0, 0, 10.dp, 0)
setOnClickListener { onClick() }
addView(TextView(this@MainActivity).apply {
text = title
setTextColor(titleColor)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
maxLines = 2
})
addView(TextView(this@MainActivity).apply {
text = meta
setTextColor(metaColor)
textSize = 12f
setPadding(0, 6.dp, 0, 0)
})
}
}
private fun headerButton(label: String, action: () -> Unit): Button {
return Button(this).apply {
text = label
setOnClickListener { action() }
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(14.dp, 0, 14.dp, 0)
background = rounded(COLOR_BUTTON, 8f)
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f,
).apply {
marginStart = 4
marginEnd = 4
}
40.dp,
).withMargins(8.dp, 0, 0, 0)
setOnClickListener { action() }
}
}
private fun overlayPill(label: String): TextView {
return TextView(this).apply {
text = label
setTextColor(Color.WHITE)
textSize = 13f
typeface = Typeface.DEFAULT_BOLD
background = rounded(COLOR_OVERLAY, 8f)
setPadding(10.dp, 6.dp, 10.dp, 6.dp)
gravity = Gravity.CENTER
}
}
@@ -185,27 +518,103 @@ class MainActivity : Activity() {
private fun sampleOnlineLesson(): TrainingMediaItem {
return TrainingMediaItem(
id = "online_sample_01",
title = "Online Sample Lesson",
id = SAMPLE_ID,
title = "口语宝示例课",
uri = Uri.parse("https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"),
mimeType = MimeTypes.VIDEO_MP4,
customCacheKey = "online_sample_01",
customCacheKey = SAMPLE_ID,
sentences = listOf(
SentenceBoundary(0, 0L, 3_000L, "Listen once, then imitate."),
SentenceBoundary(1, 3_000L, 7_000L, "Swipe left to go back."),
SentenceBoundary(2, 7_000L, 11_000L, "Swipe right to move forward."),
SentenceBoundary(3, 11_000L, 16_000L, "Tap the video area to pause or play."),
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 format(ms: Long): String {
private fun playbackStatus(snapshot: PlaybackSnapshot): String {
val state = when {
snapshot.isPlaying -> "播放中"
snapshot.playbackState.name == "BUFFERING" -> "缓冲中"
snapshot.playbackState.name == "ENDED" -> "已结束"
else -> "已暂停"
}
val sentence = snapshot.sentenceIndex?.let { " · 第 ${it + 1}" }.orEmpty()
return "$state$sentence"
}
private fun playbackProgress(snapshot: PlaybackSnapshot): Int {
if (snapshot.durationMs <= 0L) {
return 0
}
return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * PROGRESS_MAX) / snapshot.durationMs).toInt()
}
private fun formatTime(ms: Long): String {
if (ms < 0) {
return "--:--"
}
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return "%02d:%02d".format(minutes, seconds)
return if (hours > 0) {
"%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
} else {
"%02d:%02d".format(Locale.US, minutes, seconds)
}
}
private fun formatSpeed(speed: Float): String {
val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
return "${value}x"
}
private fun rounded(
color: Int,
radiusDp: Float,
strokeColor: Int? = null,
): GradientDrawable {
return GradientDrawable().apply {
setColor(color)
cornerRadius = radiusDp.dp.toFloat()
strokeColor?.let { setStroke(1.dp, it) }
}
}
private fun LinearLayout.LayoutParams.withMargins(
left: Int,
top: Int,
right: Int,
bottom: Int,
): LinearLayout.LayoutParams {
setMargins(left, top, right, bottom)
return this
}
private val Int.dp: Int
get() = (this * resources.displayMetrics.density).toInt()
private val Float.dp: Int
get() = (this * resources.displayMetrics.density).toInt()
private companion object {
const val PICK_VIDEO_REQUEST = 1001
const val SAMPLE_ID = "online_sample_01"
const val PROGRESS_MAX = 1000
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_SELECTED: Int = Color.rgb(229, 248, 240)
val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235)
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)
}
}

View File

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