relayout of train view

This commit is contained in:
2026-10-02 11:02:36 +08:00
parent 55fad2c0cd
commit 1a5a756e82
8 changed files with 129 additions and 267 deletions

View File

@@ -13,7 +13,6 @@ internal const val ASSESSMENT_API_KEY =
internal const val SSO_LOGIN_URL = "https://d1kt.cn/login"
internal const val PICK_VIDEO_REQUEST = 1001
internal const val RECORD_AUDIO_REQUEST = 1002
internal const val PROGRESS_MAX = 1000

View File

@@ -167,14 +167,6 @@ internal class CourseCatalogController(
}
}
fun useCourseInPlace(video: TrainingVideoSummary) {
// Keep the current module (e.g. Train) while syncing course selection.
if (screenState.courseEnrollment[video.videoHash] != true) {
enrollCourse(video)
}
loadRemoteVideo(video)
}
fun loadRemoteVideo(video: TrainingVideoSummary) {
screenState.currentCourse = video
screenState.activeItemId = video.videoHash

View File

@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import cn.learningpad.oraltrainer.sdk.AuthSession
import cn.learningpad.oraltrainer.sdk.GestureEvent
@@ -17,8 +16,6 @@ import cn.learningpad.oraltrainer.sdk.OralTrainerListener
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingMediaItem
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
@@ -173,53 +170,6 @@ class MainActivity : Activity() {
super.onBackPressed()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != PICK_VIDEO_REQUEST || resultCode != RESULT_OK) return
val uri = data?.data ?: return
val persistableFlags = data.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION
if (persistableFlags != 0) {
runCatching { contentResolver.takePersistableUriPermission(uri, persistableFlags) }
}
val item = TrainingMediaItem(
id = uri.toString(),
title = uri.lastPathSegment ?: "本地视频",
uri = uri,
)
screenState.activeItemId = item.id
screenState.currentSentenceCount = 0
controller.loadItem(item)
if (bindings.hasPlayerChrome) {
bindings.lessonTitleText.text = item.title
bindings.sentenceMetaText.text = "句子边界分析中"
bindings.sentenceText.text = "正在匹配服务端句子边界"
bindings.statusText.text = "正在获取本地视频的句子边界..."
}
screenFactory.renderCatalog()
sdk.sentenceBoundaryApi.fetchForUri(uri, contentResolver, object : SentenceBoundaryApiCallback {
override fun onSuccess(result: SentenceBoundaryApiResult) {
if (screenState.activeItemId != item.id) return
screenState.currentSentenceCount = result.sentences.size
controller.loadItem(item.copy(sentences = result.sentences))
if (bindings.hasPlayerChrome) {
bindings.statusText.text = "句子边界已加载:${result.sentences.size} 句"
}
}
override fun onError(error: Throwable) {
if (screenState.activeItemId != item.id) return
if (bindings.hasPlayerChrome) {
bindings.statusText.text =
"句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}"
bindings.sentenceMetaText.text = "暂无句子边界"
bindings.sentenceText.text = ""
}
}
})
}
override fun onDestroy() {
authCoordinator.reset()
testSession.release()
@@ -268,19 +218,6 @@ class MainActivity : Activity() {
setRootView()
}
private fun openLocalVideoPicker() {
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "video/*"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
},
PICK_VIDEO_REQUEST,
)
}
private fun switchModule(module: Module) {
if (module != Module.TEST) {
testSession.stopDubbingIfNeeded()
@@ -393,10 +330,6 @@ class MainActivity : Activity() {
private fun screenActions() = object : SampleScreenActions {
override fun onModuleSelected(module: Module) = switchModule(module)
override fun onImportLocalVideo() = openLocalVideoPicker()
override fun onRefreshCatalog() = catalogController.loadCatalog()
override fun onSeekProgressPreview(progress: Int) {
if (!bindings.hasPlayerChrome) return
val duration = controller.snapshot().durationMs
@@ -474,10 +407,6 @@ class MainActivity : Activity() {
override fun onRefreshCourses() = catalogController.refreshCourses()
override fun onTrainCatalogVideoClick(video: TrainingVideoSummary) {
catalogController.useCourseInPlace(video)
}
override fun onLogout() = logout()
override fun nickname(): String =

View File

@@ -17,13 +17,10 @@ internal interface SentenceExplainActions {
internal interface TrainScreenActions :
SentenceExplainActions {
fun onImportLocalVideo()
fun onRefreshCatalog()
fun onSeekProgressPreview(progress: Int)
fun onSeekStart()
fun onSeekStop(progress: Int)
fun onContinuousPlaybackChanged(enabled: Boolean)
fun onTrainCatalogVideoClick(video: TrainingVideoSummary)
}
internal interface TestScreenActions :

View File

@@ -33,11 +33,7 @@ internal class SampleScreenFactory(
private val courseScreen = CourseScreen(activity, bindings, screenState, widgets, actions)
private val trainScreen = TrainScreen(
activity = activity,
bindings = bindings,
screenState = screenState,
widgets = widgets,
playerChrome = playerChrome,
actions = actions,
)
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions)
private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
@@ -53,6 +49,10 @@ internal class SampleScreenFactory(
return LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
)
setBackgroundColor(SampleTheme.background)
addView(widgets.moduleTabBar(actions))
when (screenState.activeModule) {
@@ -65,24 +65,19 @@ internal class SampleScreenFactory(
}
fun renderCatalog() {
when (screenState.activeModule) {
Module.COURSE -> courseScreen.renderCatalog()
Module.TRAIN -> trainScreen.renderCatalogStrip()
else -> Unit
if (screenState.activeModule == Module.COURSE) {
courseScreen.renderCatalog()
}
}
fun showCatalogLoading() {
when (screenState.activeModule) {
Module.COURSE -> courseScreen.showLoading()
Module.TRAIN -> trainScreen.showCatalogLoading()
else -> Unit
if (screenState.activeModule == Module.COURSE) {
courseScreen.showLoading()
}
}
fun setCatalogStatus(text: String) {
courseScreen.setStatus(text)
trainScreen.setCatalogStatus(text)
}
fun refreshMyContent(userApi: UserApi, token: String) {

View File

@@ -4,7 +4,10 @@ import android.app.Activity
import android.content.pm.ActivityInfo
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.InsetDrawable
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
@@ -14,6 +17,7 @@ import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
@@ -40,26 +44,50 @@ internal class PlayerChrome(
override val uiContext get() = activity
fun createPlayerSection(): View =
FrameLayout(activity).apply {
LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
background = rounded(SampleTheme.surface, 8f, SampleTheme.border)
clipToOutline = true
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
ViewGroup.LayoutParams.WRAP_CONTENT,
).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
createPlayerOverlay().apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
},
)
addView(
object : FrameLayout(activity) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = (width * 9) / 16
super.onMeasure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
)
}
}.apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
addView(createPlayerView())
addView(
createRotationButton().apply {
layoutParams = FrameLayout.LayoutParams(
44.dp,
44.dp,
Gravity.BOTTOM or Gravity.END,
).apply {
marginEnd = 12.dp
bottomMargin = 12.dp
}
},
)
},
)
}
@@ -70,7 +98,8 @@ internal class PlayerChrome(
setPadding(18.dp, 16.dp, 18.dp, 16.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
0,
1f,
).withMargins(16.dp, 0, 16.dp, 12.dp)
}
@@ -78,7 +107,15 @@ internal class PlayerChrome(
FrameLayout(activity).apply {
setBackgroundColor(Color.BLACK)
addView(createPlayerView())
addView(createPlayerOverlay())
addView(
createPlayerOverlay().apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
)
},
)
val subtitleContainer = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
@@ -106,6 +143,7 @@ internal class PlayerChrome(
textSize = 17f
typeface = Typeface.DEFAULT_BOLD
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
}
bindings.sentenceMetaText = TextView(activity).apply {
setTextColor(if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep)
@@ -157,28 +195,50 @@ internal class PlayerChrome(
background = rounded(SampleTheme.overlayFaint, 0f, null)
setPadding(18.dp, 14.dp, 18.dp, 16.dp)
}
addView(bindings.lessonTitleText)
if (screenState.activeModule == Module.TRAIN) {
addView(createContinuousPlaybackRow(translucent))
val topContent = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(bindings.lessonTitleText)
if (screenState.activeModule == Module.TRAIN) {
addView(createContinuousPlaybackRow(translucent))
}
addView(bindings.sentenceMetaText)
addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.TOP
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.sentenceMetaText)
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(
ScrollView(activity).apply {
isFillViewport = true
isVerticalScrollBarEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
addView(
topContent,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
},
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
if (translucent) ViewGroup.LayoutParams.WRAP_CONTENT else 0,
if (translucent) 0f else 1f,
),
)
addView(bindings.progressBar)
addView(bindings.statusText)
}
@@ -197,12 +257,7 @@ internal class PlayerChrome(
val overlay = LinearLayout(activity).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,
)
setPadding(12.dp, 10.dp, 12.dp, 8.dp)
}
bindings.timeText = widgets.overlayPill("--:-- / --:--").apply {
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
@@ -217,11 +272,27 @@ internal class PlayerChrome(
ImageButton(activity).apply {
setImageResource(R.drawable.ic_screen_rotation)
imageTintList = ColorStateList.valueOf(Color.WHITE)
background = rounded(Color.argb(178, 9, 12, 16), 22.dp.toFloat())
val backgroundSize = 22.dp
val buttonSize = 44.dp
val inset = (buttonSize - backgroundSize) / 2
background = InsetDrawable(
GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.argb(102, 9, 12, 16))
setSize(backgroundSize, backgroundSize)
},
inset,
)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
contentDescription = "横屏"
layoutParams = LinearLayout.LayoutParams(44.dp, 44.dp)
minimumWidth = 0
minimumHeight = 0
clipToOutline = false
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
foreground = null
}
layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize)
setOnClickListener {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}

View File

@@ -153,40 +153,4 @@ internal class SampleWidgets(
}
}
fun videoCard(
id: String,
title: String,
meta: String,
onClick: () -> Unit,
): View {
val selected = screenState.activeItemId == id
val titleColor = if (selected) SampleTheme.textDark else Color.WHITE
val metaColor = if (selected) SampleTheme.accentDeep else SampleTheme.textMuted
return LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
isClickable = true
background = if (selected) {
rounded(SampleTheme.selected, 8f, SampleTheme.accent)
} else {
rounded(SampleTheme.surface, 8f, SampleTheme.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(activity).apply {
text = title
setTextColor(titleColor)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
maxLines = 2
})
addView(TextView(activity).apply {
text = meta
setTextColor(metaColor)
textSize = 12f
setPadding(0, 6.dp, 0, 0)
})
}
}
}

View File

@@ -5,23 +5,14 @@ import android.graphics.Color
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.TextView
import cn.learningpad.oraltrainer.sample.SampleBindings
import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainScreenActions
import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled
internal class TrainScreen(
private val activity: Activity,
private val bindings: SampleBindings,
private val screenState: TrainingScreenState,
private val widgets: SampleWidgets,
private val playerChrome: PlayerChrome,
private val actions: TrainScreenActions,
) : UiStyled {
override val uiContext get() = activity
@@ -29,47 +20,13 @@ internal class TrainScreen(
root.addView(createHeader())
root.addView(playerChrome.createPlayerSection())
root.addView(playerChrome.createSentenceSection())
root.addView(createCatalogSection())
}
fun renderCatalogStrip() {
if (!bindings.hasCatalogChrome) return
bindings.catalogList.removeAllViews()
bindings.catalogStatusText.text = screenState.catalogStatus
screenState.catalogVideos.forEach { video ->
val enrolled = screenState.courseEnrollment[video.videoHash] == true
bindings.catalogList.addView(
widgets.videoCard(
id = video.videoHash,
title = video.title,
meta = if (enrolled) {
"已选修 · ${video.sentenceCount} 句"
} else {
"未选修 · 点击选修并进入"
},
onClick = { actions.onTrainCatalogVideoClick(video) },
),
)
}
}
fun showCatalogLoading() {
if (!bindings.hasCatalogChrome) return
bindings.catalogList.removeAllViews()
bindings.catalogList.addView(widgets.infoCard("课程加载中..."))
}
fun setCatalogStatus(text: String) {
if (bindings.hasCatalogChrome) {
bindings.catalogStatusText.text = text
}
}
private fun createHeader(): View {
val titleBlock = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
addView(TextView(activity).apply {
text = "八哥口语"
setTextColor(Color.WHITE)
@@ -80,52 +37,10 @@ internal class TrainScreen(
addView(TextView(activity).apply {
text = "外语跟读训练神器"
setTextColor(SampleTheme.textMuted)
textSize = 20f
setPadding(1.dp, 4.dp, 0, 0)
textSize = 18f
includeFontPadding = false
setPadding(12.dp, 6.dp, 0, 0)
})
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(18.dp, 16.dp, 18.dp, 12.dp)
addView(titleBlock)
addView(widgets.headerButton("导入") { actions.onImportLocalVideo() })
addView(widgets.headerButton("刷新") { actions.onRefreshCatalog() })
}
}
private fun createCatalogSection(): View {
bindings.catalogStatusText = TextView(activity).apply {
setTextColor(SampleTheme.textMuted)
textSize = 13f
gravity = Gravity.END
text = "正在同步"
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
bindings.catalogList = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 10.dp, 0, 4.dp)
}
val header = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(TextView(activity).apply {
text = "课程"
setTextColor(Color.WHITE)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
})
addView(bindings.catalogStatusText)
}
val scroller = HorizontalScrollView(activity).apply {
isHorizontalScrollBarEnabled = false
addView(bindings.catalogList)
}
return LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 0, 16.dp, 14.dp)
addView(header)
addView(scroller)
}
}
}