add test module
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
3
android/sample-app/src/main/res/values/strings.xml
Normal file
3
android/sample-app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">口语宝</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user