add readming mode

This commit is contained in:
2026-10-26 17:31:08 +08:00
parent 9b7c7d726b
commit ed8df744f9
15 changed files with 565 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ package cn.learningpad.oraltrainer.sample
enum class Module {
COURSE,
TRAIN,
READING,
TEST,
MINE,
}

View File

@@ -41,6 +41,7 @@ class MainActivity : Activity() {
private lateinit var testSession: TestSessionController
private lateinit var explanationOverlay: SentenceExplanationOverlay
private lateinit var translationApi: SubtitleTranslationApi
private lateinit var sentenceFavoriteStore: SentenceFavoriteStore
private var translationRequest: CancellableTranslationRequest? = null
private var progressBarDragging = false
@@ -80,6 +81,7 @@ class MainActivity : Activity() {
translationApi = SubtitleTranslationApi(this, SERVER_BASE_URL) {
authSessionStore.session?.token.orEmpty()
}
sentenceFavoriteStore = SentenceFavoriteStore(this)
screenFactory = SampleScreenFactory(
activity = this,
@@ -264,7 +266,17 @@ class MainActivity : Activity() {
}
private fun refreshCurrentUi() {
if (screenState.activeModule != Module.TRAIN && screenState.activeModule != Module.TEST) {
if (screenState.activeModule != Module.TRAIN &&
screenState.activeModule != Module.TEST &&
screenState.activeModule != Module.READING
) {
screenFactory.setCatalogStatus(screenState.catalogStatus)
return
}
if (screenState.activeModule == Module.READING) {
screenState.currentSentenceCount = controller.currentTrainingItem()?.sentences?.size ?: 0
screenFactory.refreshReadingSentences()
ensureChineseTranslations()
screenFactory.setCatalogStatus(screenState.catalogStatus)
return
}
@@ -284,11 +296,14 @@ class MainActivity : Activity() {
}
override fun onMediaChanged(item: TrainingMediaItem?) {
screenState.currentSentenceCount = item?.sentences?.size ?: 0
if (bindings.hasPlayerChrome) {
bindings.lessonTitleText.text = item?.title ?: "未选择课程"
screenState.currentSentenceCount = item?.sentences?.size ?: 0
}
ensureChineseTranslations()
if (screenState.activeModule == Module.READING) {
screenFactory.refreshReadingSentences()
}
testSession.ensureHistoryForCurrentCourse()
if (screenState.activeModule == Module.TEST) {
testSession.refreshTestUi()
@@ -398,10 +413,19 @@ class MainActivity : Activity() {
screenState.courseBoundaries[current.id] = cached.copy(sentences = updated)
}
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
}
private fun favoriteUserKey(): String =
SentenceFavoriteStore.userKey(authSessionStore.session?.token.orEmpty())
private fun ensureChineseTranslations() {
if (screenState.activeModule != Module.TRAIN || !screenState.chineseSubtitlesEnabled) {
val translationEnabled = when (screenState.activeModule) {
Module.TRAIN -> screenState.chineseSubtitlesEnabled
Module.READING -> screenState.readingTranslationVisible
else -> false
}
if (!translationEnabled) {
return
}
val item = controller.currentTrainingItem() ?: return
@@ -411,12 +435,14 @@ class MainActivity : Activity() {
screenState.chineseTranslationLoading = false
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
return
}
val generation = screenState.beginTranslationRequest()
screenState.chineseTranslationLoading = true
screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence())
screenFactory.refreshReadingTranslations()
translationRequest?.cancel()
val expectedIndexes = sentences.mapNotNull { sentence ->
sentence.index.takeIf { !sentence.text.isNullOrBlank() }
@@ -505,6 +531,27 @@ class MainActivity : Activity() {
}
}
override fun onReadingTranslationVisible(visible: Boolean) {
screenState.readingTranslationVisible = visible
if (!visible) {
translationRequest?.cancel()
screenState.chineseTranslationLoading = false
} else {
ensureChineseTranslations()
}
screenFactory.refreshReadingTranslations()
}
override fun isSentenceFavorite(sentence: SentenceBoundary): Boolean {
val item = controller.currentTrainingItem() ?: return false
return sentence.index in sentenceFavoriteStore.indexes(favoriteUserKey(), item.id)
}
override fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean {
val item = controller.currentTrainingItem() ?: return false
return sentenceFavoriteStore.toggle(favoriteUserKey(), item.id, sentence.index)
}
override fun onExplainCurrentSentence() {
val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return
@@ -558,6 +605,12 @@ class MainActivity : Activity() {
override fun nickname(): String =
authSessionStore.session?.user?.nickname.orEmpty()
override fun favoriteSentences(): List<SentenceBoundary> {
val item = controller.currentTrainingItem() ?: return emptyList()
val indexes = sentenceFavoriteStore.indexes(favoriteUserKey(), item.id)
return item.sentences.filter { it.index in indexes }
}
}
private fun catalogHost() = object : CourseCatalogController.Host {

View File

@@ -31,6 +31,7 @@ internal class SampleBindings {
var dubbingStatusText: TextView? = null
var explainSentenceButton: ImageButton? = null
var myContentList: LinearLayout? = null
var favoriteSentenceList: LinearLayout? = null
val hasPlayerChrome: Boolean
get() = ::lessonTitleText.isInitialized &&

View File

@@ -1,5 +1,6 @@
package cn.learningpad.oraltrainer.sample
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
internal interface ModuleTabActions {
@@ -24,6 +25,12 @@ internal interface TrainScreenActions :
fun onChineseSubtitlesChanged(enabled: Boolean)
}
internal interface ReadingScreenActions {
fun onReadingTranslationVisible(visible: Boolean)
fun isSentenceFavorite(sentence: SentenceBoundary): Boolean
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
}
internal interface TestScreenActions :
SentenceExplainActions {
fun onSeekProgressPreview(progress: Int)
@@ -42,11 +49,14 @@ internal interface TestScreenActions :
internal interface MineScreenActions {
fun onLogout()
fun nickname(): String
fun favoriteSentences(): List<SentenceBoundary>
fun toggleSentenceFavorite(sentence: SentenceBoundary): Boolean
}
internal interface SampleScreenActions :
ModuleTabActions,
CourseScreenActions,
TrainScreenActions,
ReadingScreenActions,
TestScreenActions,
MineScreenActions

View File

@@ -7,6 +7,7 @@ import android.widget.LinearLayout
import cn.learningpad.oraltrainer.sample.ui.CourseScreen
import cn.learningpad.oraltrainer.sample.ui.MineScreen
import cn.learningpad.oraltrainer.sample.ui.PlayerChrome
import cn.learningpad.oraltrainer.sample.ui.ReadingScreen
import cn.learningpad.oraltrainer.sample.ui.SampleWidgets
import cn.learningpad.oraltrainer.sample.ui.TestScreen
import cn.learningpad.oraltrainer.sample.ui.TrainScreen
@@ -36,6 +37,13 @@ internal class SampleScreenFactory(
playerChrome = playerChrome,
)
private val testScreen = TestScreen(activity, bindings, screenState, playerChrome, actions)
private val readingScreen = ReadingScreen(
activity = activity,
screenState = screenState,
controller = controller,
widgets = widgets,
actions = actions,
)
private val mineScreen = MineScreen(activity, bindings, screenState, widgets, actions)
fun createContentView(): View {
@@ -59,11 +67,24 @@ internal class SampleScreenFactory(
Module.COURSE -> addView(courseScreen.createSection())
Module.MINE -> addView(mineScreen.createSection())
Module.TRAIN -> trainScreen.attachPortraitSections(this)
Module.READING -> addView(readingScreen.createSection())
Module.TEST -> testScreen.attachSections(this)
}
}
}
fun refreshReadingSentences() {
if (screenState.activeModule == Module.READING) {
readingScreen.renderSentences()
}
}
fun refreshReadingTranslations() {
if (screenState.activeModule == Module.READING) {
readingScreen.refreshTranslations()
}
}
fun renderCatalog() {
if (screenState.activeModule == Module.COURSE) {
courseScreen.renderCatalog()

View File

@@ -0,0 +1,55 @@
package cn.learningpad.oraltrainer.sample
import android.content.Context
import java.security.MessageDigest
/** Persists reading-mode favorites locally, scoped by signed-in user and video. */
internal class SentenceFavoriteStore(context: Context) {
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
fun toggle(userKey: String, mediaId: String, sentenceIndex: Int): Boolean {
val key = storageKey(userKey, mediaId) ?: return false
val favorites = readIndexes(key)
val isFavorite = sentenceIndex in favorites
val updated = if (isFavorite) {
favorites - sentenceIndex
} else {
favorites + sentenceIndex
}
preferences.edit()
.putString(key, updated.sorted().joinToString(SEPARATOR))
.apply()
return !isFavorite
}
fun indexes(userKey: String, mediaId: String): Set<Int> {
return storageKey(userKey, mediaId)?.let(::readIndexes).orEmpty()
}
private fun readIndexes(key: String): Set<Int> {
return preferences.getString(key, null).orEmpty()
.split(SEPARATOR)
.mapNotNull { it.trim().toIntOrNull() }
.toSet()
}
private fun storageKey(userKey: String, mediaId: String): String? {
val normalizedUser = userKey.trim()
val normalizedMedia = mediaId.trim()
if (normalizedUser.isEmpty() || normalizedMedia.isEmpty()) return null
return "$KEY_PREFIX$normalizedUser:$normalizedMedia"
}
companion object {
private const val PREFERENCES_NAME = "sentence_reading_favorites"
private const val KEY_PREFIX = "indexes"
private const val SEPARATOR = ","
fun userKey(token: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(token.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }.take(24)
}
}
}

View File

@@ -15,6 +15,7 @@ internal class TrainingScreenState {
var currentCourse: TrainingVideoSummary? = null
var continuousPlaybackEnabled = false
var chineseSubtitlesEnabled = false
var readingTranslationVisible = true
var chineseTranslationLoading = false
var chineseTranslationError = ""
var testSubtitleVisible = false

View File

@@ -16,6 +16,7 @@ import cn.learningpad.oraltrainer.sample.SampleBindings
import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.UserApi
internal class MineScreen(
@@ -33,6 +34,9 @@ internal class MineScreen(
bindings.myContentList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
bindings.favoriteSentenceList = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(activity).apply {
@@ -52,6 +56,13 @@ internal class MineScreen(
44.dp,
).withMargins(0, 12.dp, 0, 20.dp)
})
addView(TextView(activity).apply {
text = "本视频收藏句子"
textSize = 18f
typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE)
})
addView(bindings.favoriteSentenceList)
addView(TextView(activity).apply {
text = "我的测试结果"
textSize = 18f
@@ -64,6 +75,7 @@ internal class MineScreen(
}
fun refreshContent(userApi: UserApi, token: String) {
renderFavoriteSentences()
val list = bindings.myContentList ?: return
list.removeAllViews()
val course = screenState.currentCourse
@@ -125,6 +137,74 @@ internal class MineScreen(
}
}
private fun renderFavoriteSentences() {
val list = bindings.favoriteSentenceList ?: return
list.removeAllViews()
if (screenState.currentCourse == null) {
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
return
}
val favorites = actions.favoriteSentences()
if (favorites.isEmpty()) {
list.addView(widgets.infoCard("当前课程还没有收藏句子"))
return
}
favorites.forEach { sentence ->
list.addView(favoriteCard(sentence) {
if (!actions.toggleSentenceFavorite(sentence)) {
renderFavoriteSentences()
}
})
}
}
private fun favoriteCard(
sentence: SentenceBoundary,
onRemove: () -> Unit,
): View {
val foreign = sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"
val translation = sentence.zhText?.takeIf { it.isNotBlank() }
val cardText = buildString {
append("第 ${sentence.index + 1} 句")
append("\n$foreign")
translation?.let { append("\n$it") }
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.selected, 8f)
setPadding(12.dp, 10.dp, 10.dp, 10.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 8.dp)
addView(TextView(activity).apply {
text = cardText
setTextColor(SampleTheme.textDark)
textSize = 13f
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Button(activity).apply {
text = "移除"
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(10.dp, 0, 10.dp, 0)
background = rounded(SampleTheme.danger, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(8.dp, 0, 0, 0)
setOnClickListener { onRemove() }
})
}
}
private fun deleteResult(userApi: UserApi, token: String, attemptId: String) {
userApi.deleteResult(token, attemptId) { result ->
result.fold(

View File

@@ -0,0 +1,210 @@
package cn.learningpad.oraltrainer.sample.ui
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import cn.learningpad.oraltrainer.sample.ReadingScreenActions
import cn.learningpad.oraltrainer.sample.SampleTheme
import cn.learningpad.oraltrainer.sample.TrainingScreenState
import cn.learningpad.oraltrainer.sample.UiStyled
import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
/** Scrollable full-text reading view for the selected course. */
internal class ReadingScreen(
private val activity: Activity,
private val screenState: TrainingScreenState,
private val controller: OralTrainerController,
private val widgets: SampleWidgets,
private val actions: ReadingScreenActions,
) : UiStyled {
override val uiContext get() = activity
private data class SentenceRow(
val translation: TextView,
val favoriteButton: Button,
)
private val sentenceRows = mutableMapOf<Int, SentenceRow>()
private var sentenceList: LinearLayout? = null
private var titleText: TextView? = null
fun createSection(): View {
val content = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
}
val list = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
}
sentenceList = list
val title = TextView(activity).apply {
setTextColor(Color.WHITE)
textSize = 24f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 0, 0, 6.dp)
}
titleText = title
content.addView(title)
content.addView(createTranslationToggle())
content.addView(list)
renderSentences()
return ScrollView(activity).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
addView(content)
}
}
fun renderSentences() {
val list = sentenceList ?: return
val item = controller.currentTrainingItem()
titleText?.text = screenState.currentCourse?.title.orEmpty()
.ifBlank { item?.title ?: "阅读模式" }
sentenceRows.clear()
list.removeAllViews()
if (item == null || item.sentences.isEmpty()) {
list.addView(widgets.infoCard("当前课程还没有可阅读的句子文本"))
return
}
val currentTitle = titleText?.text?.toString().orEmpty()
titleText?.text = "$currentTitle · ${item.sentences.size} 句"
item.sentences.forEach { sentence ->
list.addView(sentenceRow(sentence))
}
}
fun refreshTranslations() {
val item = controller.currentTrainingItem() ?: return
item.sentences.forEach { sentence ->
val row = sentenceRows[sentence.index] ?: return@forEach
row.translation.visibility = if (screenState.readingTranslationVisible) {
View.VISIBLE
} else {
View.GONE
}
row.translation.text = translationText(sentence)
}
}
fun refreshFavorites() {
val item = controller.currentTrainingItem() ?: return
item.sentences.forEach { sentence ->
sentenceRows[sentence.index]?.favoriteButton?.apply {
val isFavorite = actions.isSentenceFavorite(sentence)
setText(if (isFavorite) "已收藏" else "收藏")
background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
}
private fun createTranslationToggle(): View =
LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, 4.dp, 0, 10.dp)
addView(TextView(activity).apply {
text = "中文翻译"
setTextColor(Color.WHITE)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
addView(Switch(activity).apply {
isChecked = screenState.readingTranslationVisible
buttonTintList = android.content.res.ColorStateList.valueOf(SampleTheme.accent)
setOnCheckedChangeListener { _, checked ->
actions.onReadingTranslationVisible(checked)
}
})
}
private fun sentenceRow(sentence: SentenceBoundary): View {
val translation = TextView(activity).apply {
text = translationText(sentence)
setTextColor(SampleTheme.textSubtle)
textSize = 14f
setPadding(0, 6.dp, 0, 0)
visibility = if (screenState.readingTranslationVisible) View.VISIBLE else View.GONE
}
val favoriteButton = Button(activity).apply {
setText("收藏")
isAllCaps = false
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
minWidth = 0
minHeight = 0
minimumWidth = 0
minimumHeight = 0
setPadding(12.dp, 0, 12.dp, 0)
background = rounded(SampleTheme.button, 8f)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
32.dp,
).withMargins(10.dp, 0, 0, 0)
setOnClickListener {
val isFavorite = actions.toggleSentenceFavorite(sentence)
setText(if (isFavorite) "已收藏" else "收藏")
background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
}
}
val isFavorite = actions.isSentenceFavorite(sentence)
favoriteButton.setText(if (isFavorite) "已收藏" else "收藏")
favoriteButton.background = rounded(
if (isFavorite) SampleTheme.accent else SampleTheme.button,
8f,
)
val textColumn = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(activity).apply {
text = "${sentence.index + 1}. ${sentence.text?.takeIf { it.isNotBlank() } ?: "未提供文本"}"
setTextColor(Color.WHITE)
textSize = 16f
})
addView(translation)
}
return LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
background = rounded(SampleTheme.surface, 10f, SampleTheme.border)
setPadding(14.dp, 12.dp, 10.dp, 12.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 10.dp)
addView(textColumn)
addView(favoriteButton)
}
}
private fun translationText(sentence: SentenceBoundary): String = when {
!sentence.zhText.isNullOrBlank() -> sentence.zhText.orEmpty()
screenState.chineseTranslationLoading -> "正在翻译中文…"
screenState.chineseTranslationError.isNotBlank() -> screenState.chineseTranslationError
else -> "暂无中文翻译"
}
}

View File

@@ -30,6 +30,7 @@ internal class SampleWidgets(
setPadding(16.dp, 40.dp, 16.dp, 0)
addView(moduleTab("课程", Module.COURSE, actions))
addView(moduleTab("训练", Module.TRAIN, actions))
addView(moduleTab("阅读", Module.READING, actions))
addView(moduleTab("测试", Module.TEST, actions))
addView(moduleTab("我的", Module.MINE, actions))
}