optimized structures
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
enum class Module {
|
||||
COURSE,
|
||||
TRAIN,
|
||||
TEST,
|
||||
MINE,
|
||||
}
|
||||
|
||||
internal const val SERVER_BASE_URL = "https://videoservice.d1kt.cn"
|
||||
internal const val ASSESSMENT_API_KEY =
|
||||
"fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
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
|
||||
|
||||
internal val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$")
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
|
||||
internal class AssessmentState {
|
||||
private val scores = mutableMapOf<Int, ImitationAssessmentResult>()
|
||||
|
||||
fun store(sentenceIndex: Int, result: ImitationAssessmentResult) {
|
||||
scores[sentenceIndex] = result
|
||||
}
|
||||
|
||||
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
|
||||
internal class AttemptRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var file: File? = null
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = recorder != null
|
||||
|
||||
fun start(cacheDir: File): File {
|
||||
release()
|
||||
|
||||
val outputFile = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a")
|
||||
val mediaRecorder = createRecorder()
|
||||
try {
|
||||
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
mediaRecorder.setAudioSamplingRate(16_000)
|
||||
mediaRecorder.setAudioEncodingBitRate(96_000)
|
||||
mediaRecorder.setOutputFile(outputFile.absolutePath)
|
||||
mediaRecorder.prepare()
|
||||
mediaRecorder.start()
|
||||
recorder = mediaRecorder
|
||||
file = outputFile
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
runCatching { mediaRecorder.release() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): File {
|
||||
val activeRecorder = requireNotNull(recorder) { "当前没有录音" }
|
||||
val outputFile = requireNotNull(file) { "录音文件不存在" }
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
return outputFile
|
||||
} catch (error: Throwable) {
|
||||
outputFile.delete()
|
||||
throw error
|
||||
} finally {
|
||||
runCatching { activeRecorder.release() }
|
||||
recorder = null
|
||||
file = null
|
||||
}
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
recorder?.let { mediaRecorder ->
|
||||
runCatching { mediaRecorder.stop() }
|
||||
runCatching { mediaRecorder.release() }
|
||||
}
|
||||
recorder = null
|
||||
file?.delete()
|
||||
file = null
|
||||
}
|
||||
|
||||
fun release() = discard()
|
||||
|
||||
private fun createRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
import cn.learningpad.oraltrainer.sdk.AuthUser
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
internal class AuthCoordinator(
|
||||
private val activity: Activity,
|
||||
private val userApi: UserApi,
|
||||
private val onAuthenticated: (AuthSession) -> Unit,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
private var screen = Screen.LOGIN
|
||||
private var pendingSsoToken: String? = null
|
||||
private var webView: WebView? = null
|
||||
private var loginStatusMessage = ""
|
||||
|
||||
fun createView(): View = when (screen) {
|
||||
Screen.LOGIN -> createLoginView()
|
||||
Screen.REGISTER -> createRegisterView()
|
||||
Screen.SSO -> createSsoView()
|
||||
Screen.PHONE_BINDING -> createPhoneBindingView()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
pendingSsoToken = null
|
||||
loginStatusMessage = ""
|
||||
screen = Screen.LOGIN
|
||||
}
|
||||
|
||||
private fun createLoginView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
setPadding(28.dp, 28.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "请先注册或登录后开始学习"
|
||||
textSize = 15f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
})
|
||||
|
||||
val usernameInput = EditText(activity).apply {
|
||||
hint = "用户名"
|
||||
setSingleLine(true)
|
||||
}
|
||||
val passwordInput = EditText(activity).apply {
|
||||
hint = "密码(至少 8 位)"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
setSingleLine(true)
|
||||
}
|
||||
val statusText = statusText(loginStatusMessage)
|
||||
addView(usernameInput)
|
||||
addView(passwordInput)
|
||||
addView(primaryButton("登录") {
|
||||
statusText.text = "登录中..."
|
||||
userApi.login(
|
||||
usernameInput.text?.toString()?.trim().orEmpty(),
|
||||
passwordInput.text?.toString().orEmpty(),
|
||||
) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error -> statusText.text = "登录失败:${error.message}" },
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "注册"
|
||||
setOnClickListener {
|
||||
screen = Screen.REGISTER
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "── 或 ──"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 12.dp, 0, 12.dp)
|
||||
})
|
||||
addView(primaryButton("第一课堂一键登录", SampleTheme.button) {
|
||||
screen = Screen.SSO
|
||||
activity.setContentView(createView())
|
||||
})
|
||||
addView(statusText)
|
||||
}
|
||||
|
||||
private fun createRegisterView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("注册账号", 28f))
|
||||
addView(subtitle("填写以下信息完成注册"))
|
||||
|
||||
val usernameInput = labeledInput("用户名", "3-50 位字母、数字或下划线")
|
||||
val passwordInput = labeledInput(
|
||||
"密码",
|
||||
"至少 8 位",
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD,
|
||||
)
|
||||
val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称")
|
||||
val phoneInput = labeledInput("手机号", "选填", InputType.TYPE_CLASS_PHONE)
|
||||
val statusText = statusText()
|
||||
|
||||
addView(primaryButton("注册") {
|
||||
val username = usernameInput.text?.toString()?.trim().orEmpty()
|
||||
val password = passwordInput.text?.toString().orEmpty()
|
||||
val nickname = nicknameInput.text?.toString()?.trim().orEmpty()
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
when {
|
||||
username.length < 3 -> statusText.text = "用户名至少 3 个字符"
|
||||
password.length < 8 -> statusText.text = "密码至少 8 位"
|
||||
nickname.isBlank() -> statusText.text = "请填写姓名"
|
||||
else -> {
|
||||
statusText.text = "注册中..."
|
||||
userApi.register(username, password, nickname, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = onAuthenticated,
|
||||
onFailure = { error ->
|
||||
statusText.text = "注册失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 8.dp)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "返回登录"
|
||||
setOnClickListener {
|
||||
screen = Screen.LOGIN
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun createSsoView(): View = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
|
||||
addView(Button(activity).apply {
|
||||
text = "取消登录"
|
||||
setOnClickListener {
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
})
|
||||
|
||||
webView = WebView(activity).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
loadUrl(SSO_LOGIN_URL)
|
||||
}
|
||||
addView(
|
||||
webView,
|
||||
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f),
|
||||
)
|
||||
addView(primaryButton("已登录,点击继续") { readJwtAndLogin() }.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(8.dp, 8.dp, 8.dp, 8.dp)
|
||||
})
|
||||
}
|
||||
|
||||
private fun readJwtAndLogin() {
|
||||
webView?.evaluateJavascript(JWT_SCRIPT) { result ->
|
||||
val jwt = result?.removeSurrounding("\"").orEmpty()
|
||||
if (jwt.isBlank() || jwt == "null") {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(activity, "未找到登录凭证,请先在页面中登录", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@evaluateJavascript
|
||||
}
|
||||
userApi.ssoExchange(jwt) { exchangeResult ->
|
||||
exchangeResult.fold(
|
||||
onSuccess = { ssoResult ->
|
||||
activity.runOnUiThread {
|
||||
leaveSso(if (ssoResult.phoneBound) Screen.LOGIN else Screen.PHONE_BINDING)
|
||||
pendingSsoToken = ssoResult.token
|
||||
if (ssoResult.phoneBound) {
|
||||
onAuthenticated(AuthSession(ssoResult.token, ssoResult.user))
|
||||
} else {
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
activity.runOnUiThread {
|
||||
loginStatusMessage = "SSO 登录失败:${error.message}"
|
||||
leaveSso(Screen.LOGIN)
|
||||
activity.setContentView(createView())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPhoneBindingView(): View = ScrollView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(title("绑定手机号", 28f))
|
||||
addView(subtitle("已通过第一课堂登录,请绑定手机号后继续"))
|
||||
val phoneInput = labeledInput(
|
||||
"手机号",
|
||||
"请输入 11 位手机号",
|
||||
InputType.TYPE_CLASS_PHONE,
|
||||
)
|
||||
val statusText = statusText()
|
||||
addView(primaryButton("绑定并继续") {
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
val token = pendingSsoToken
|
||||
when {
|
||||
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
|
||||
token.isNullOrBlank() -> statusText.text = "登录状态已过期,请重新登录"
|
||||
else -> {
|
||||
statusText.text = "绑定中..."
|
||||
userApi.ssoBindPhone(token, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
pendingSsoToken = null
|
||||
screen = Screen.LOGIN
|
||||
onAuthenticated(AuthSession(token, AuthUser("", "", "")))
|
||||
},
|
||||
onFailure = { error ->
|
||||
statusText.text = "绑定失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
48.dp,
|
||||
).withMargins(0, 20.dp, 0, 0)
|
||||
})
|
||||
addView(statusText)
|
||||
})
|
||||
}
|
||||
|
||||
private fun LinearLayout.labeledInput(
|
||||
label: String,
|
||||
hint: String,
|
||||
inputType: Int? = null,
|
||||
): EditText {
|
||||
addView(TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 12.dp, 0, 4.dp)
|
||||
})
|
||||
return EditText(activity).apply {
|
||||
this.hint = hint
|
||||
inputType?.let { this.inputType = it }
|
||||
setSingleLine(true)
|
||||
}.also(::addView)
|
||||
}
|
||||
|
||||
private fun title(value: String, size: Float) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = size
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
}
|
||||
|
||||
private fun subtitle(value: String) = TextView(activity).apply {
|
||||
text = value
|
||||
textSize = 14f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
}
|
||||
|
||||
private fun statusText(value: String = "") = TextView(activity).apply {
|
||||
text = value
|
||||
setTextColor(SampleTheme.accentLight)
|
||||
textSize = 13f
|
||||
setPadding(0, 14.dp, 0, 0)
|
||||
}
|
||||
|
||||
private fun primaryButton(
|
||||
label: String,
|
||||
color: Int = SampleTheme.accent,
|
||||
action: () -> Unit,
|
||||
) = Button(activity).apply {
|
||||
text = label
|
||||
background = rounded(color, 8f)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener { action() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
)
|
||||
}
|
||||
|
||||
private fun leaveSso(destination: Screen) {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
screen = destination
|
||||
}
|
||||
|
||||
private enum class Screen {
|
||||
LOGIN,
|
||||
REGISTER,
|
||||
SSO,
|
||||
PHONE_BINDING,
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")
|
||||
private const val JWT_SCRIPT =
|
||||
"(function(){ try { return localStorage.getItem('token') || " +
|
||||
"sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
|
||||
internal class AuthSessionStore(context: Context) {
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var session: AuthSession?
|
||||
get() {
|
||||
val token = preferences.getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val nickname = preferences.getString(KEY_NICKNAME, "").orEmpty()
|
||||
return AuthSession(token, cn.learningpad.oraltrainer.sdk.AuthUser("", "", nickname))
|
||||
}
|
||||
set(value) {
|
||||
val editor = preferences.edit()
|
||||
if (value == null) {
|
||||
editor.clear()
|
||||
} else {
|
||||
editor.putString(KEY_TOKEN, value.token)
|
||||
editor.putString(KEY_NICKNAME, value.user.nickname)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = session != null
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "oral_trainer_auth"
|
||||
private const val KEY_TOKEN = "token"
|
||||
private const val KEY_NICKNAME = "nickname"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
|
||||
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/**
|
||||
* Owns course list loading, enrollment, and remote sentence-boundary refresh.
|
||||
* UI updates stay in the host via [Host].
|
||||
*/
|
||||
internal class CourseCatalogController(
|
||||
private val sdk: OralTrainerSdk,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val tokenProvider: () -> String,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
fun onCatalogChanged()
|
||||
fun onCatalogStatus(text: String)
|
||||
fun onCatalogLoadingPlaceholder()
|
||||
fun onPlayerLoading(video: TrainingVideoSummary)
|
||||
fun onStatus(text: String)
|
||||
fun onSentencePlaceholder(meta: String, text: String)
|
||||
fun refreshPlayerUi()
|
||||
fun openTrainingModule()
|
||||
}
|
||||
|
||||
fun loadCatalog() {
|
||||
screenState.catalogStatus = "正在同步"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogLoadingPlaceholder()
|
||||
sdk.userApi.courses(tokenProvider()) { result ->
|
||||
result.fold(
|
||||
onSuccess = { courses ->
|
||||
screenState.catalogVideos = courses.map { it.toVideoSummary() }
|
||||
screenState.courseEnrollment.clear()
|
||||
courses.forEach { screenState.courseEnrollment[it.videoHash] = it.enrolled }
|
||||
screenState.catalogStatus = "${screenState.catalogVideos.size} 个课程"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
onFailure = {
|
||||
screenState.catalogStatus = "课程加载失败:${it.message}"
|
||||
host.onCatalogStatus(screenState.catalogStatus)
|
||||
screenState.courseEnrollment.clear()
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUserData(target: Module, onMine: () -> Unit) {
|
||||
when (target) {
|
||||
Module.COURSE -> loadCatalog()
|
||||
Module.MINE -> onMine()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCourse(video: TrainingVideoSummary) {
|
||||
screenState.currentCourse = video
|
||||
if (screenState.courseEnrollment[video.videoHash] != true) {
|
||||
enrollCourse(video)
|
||||
}
|
||||
screenState.activeModule = Module.TRAIN
|
||||
screenState.landscapeSubtitleVisible = true
|
||||
host.openTrainingModule()
|
||||
loadRemoteVideo(video)
|
||||
}
|
||||
|
||||
fun enrollCourse(video: TrainingVideoSummary) {
|
||||
sdk.userApi.enroll(tokenProvider(), video.videoHash) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
screenState.courseEnrollment[video.videoHash] = true
|
||||
host.onCatalogChanged()
|
||||
},
|
||||
onFailure = {
|
||||
host.onCatalogStatus("选修失败:${it.message}")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRemoteVideo(video: TrainingVideoSummary) {
|
||||
screenState.activeItemId = video.videoHash
|
||||
host.onPlayerLoading(video)
|
||||
host.onCatalogChanged()
|
||||
|
||||
controller.loadItem(video.toTrainingMediaItem())
|
||||
screenState.currentSentenceCount = video.sentenceCount
|
||||
refreshSentenceBoundaries(video, background = false)
|
||||
}
|
||||
|
||||
fun refreshSentenceBoundaries(
|
||||
video: TrainingVideoSummary,
|
||||
background: Boolean,
|
||||
) {
|
||||
val requestGeneration = screenState.beginBoundaryRequest()
|
||||
host.onStatus(
|
||||
if (background) "正在刷新句子边界..." else "正在加载云端课程...",
|
||||
)
|
||||
sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback {
|
||||
override fun onSuccess(result: SentenceBoundaryApiResult) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
screenState.currentSentenceCount = result.sentences.size
|
||||
controller.updateSentences(video.videoHash, result.sentences)
|
||||
host.refreshPlayerUi()
|
||||
host.onStatus("课程已就绪:${result.sentences.size} 句")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||
return
|
||||
}
|
||||
host.onStatus("句子边界加载失败:${error.message.orEmpty()}")
|
||||
host.onSentencePlaceholder("暂无句子边界", video.title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.max
|
||||
|
||||
internal class DubbingMerger(private val cacheDir: File) {
|
||||
fun merge(segments: List<Pair<Int, File>>): File {
|
||||
require(segments.isNotEmpty()) { "没有可合成的配音" }
|
||||
|
||||
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
|
||||
var muxer: MediaMuxer? = null
|
||||
try {
|
||||
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
var audioTrack = -1
|
||||
var presentationTimeUs = 0L
|
||||
|
||||
segments.forEach { (_, segment) ->
|
||||
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
|
||||
try {
|
||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
|
||||
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
|
||||
?.startsWith("audio/") == true
|
||||
} ?: throw IllegalStateException("录音没有音频轨道")
|
||||
extractor.selectTrack(trackIndex)
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
if (audioTrack == -1) {
|
||||
audioTrack = muxer.addTrack(format)
|
||||
muxer.start()
|
||||
}
|
||||
val buffer = ByteBuffer.allocateDirect(
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
64 * 1024
|
||||
}
|
||||
)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
|
||||
muxer.writeSampleData(audioTrack, buffer, info)
|
||||
presentationTimeUs += 23_000L
|
||||
extractor.advance()
|
||||
}
|
||||
presentationTimeUs = max(presentationTimeUs, segmentDurationUs(segment))
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
require(audioTrack != -1) { "录音没有可用音频轨道" }
|
||||
muxer.stop()
|
||||
muxer.release()
|
||||
muxer = null
|
||||
return output
|
||||
} catch (error: Throwable) {
|
||||
runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
output.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun segmentDurationUs(segment: File): Long {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
retriever.setDataSource(segment.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()?.times(1000L) ?: 0L
|
||||
} catch (error: Throwable) {
|
||||
0L
|
||||
} finally {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingPlayback(
|
||||
context: Context,
|
||||
private val mainHandler: Handler = Handler(Looper.getMainLooper()),
|
||||
) {
|
||||
private val player = ExoPlayer.Builder(context).build()
|
||||
private var listener: Player.Listener? = null
|
||||
private var generation = 0
|
||||
|
||||
val isActive: Boolean
|
||||
get() = player.isPlaying || player.playWhenReady
|
||||
|
||||
fun start(
|
||||
audioFile: File,
|
||||
controller: OralTrainerController,
|
||||
syncToSentence: Boolean,
|
||||
sentenceStartMs: Long?,
|
||||
onComplete: () -> Unit,
|
||||
) {
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
val playbackGeneration = ++generation
|
||||
listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState != Player.STATE_ENDED) return
|
||||
mainHandler.postDelayed({
|
||||
if (generation == playbackGeneration) {
|
||||
stop(controller)
|
||||
onComplete()
|
||||
}
|
||||
}, 250L)
|
||||
}
|
||||
}.also(player::addListener)
|
||||
|
||||
if (syncToSentence && sentenceStartMs != null) {
|
||||
controller.seekTo(sentenceStartMs)
|
||||
}
|
||||
controller.setVolume(0f)
|
||||
controller.play()
|
||||
player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile)))
|
||||
player.prepare()
|
||||
player.volume = 1f
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun stop(controller: OralTrainerController) {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.pause()
|
||||
player.seekTo(0L)
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
generation++
|
||||
listener?.let(player::removeListener)
|
||||
listener = null
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.os.StrictMode
|
||||
import java.io.DataOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class DubbingShareApi {
|
||||
fun upload(
|
||||
baseUrl: String,
|
||||
videoHash: String,
|
||||
videoTitle: String,
|
||||
segments: Map<Int, java.io.File>,
|
||||
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
|
||||
): String {
|
||||
val previousPolicy = StrictMode.getThreadPolicy()
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
||||
val boundary = "oral-trainer-${System.currentTimeMillis()}"
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = URL(URL(baseUrl), "/api/v1/dub-shares").openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
|
||||
DataOutputStream(connection.outputStream).use { output ->
|
||||
fun text(name: String, value: String) {
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
|
||||
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
|
||||
}
|
||||
text("video_hash", videoHash.lowercase(Locale.US))
|
||||
text("title", videoTitle.ifBlank { "我的口语配音" })
|
||||
text(
|
||||
"segments",
|
||||
segments.keys.sorted().joinToString(",", "[", "]") {
|
||||
"{\"sentence_index\":$it}"
|
||||
},
|
||||
)
|
||||
text(
|
||||
"scores",
|
||||
segments.keys.sorted().mapNotNull { index ->
|
||||
scores[index]?.let { score -> index to score }
|
||||
}.joinToString(",", "[", "]") { (index, score) ->
|
||||
buildString {
|
||||
append("{\"sentence_index\":")
|
||||
append(index)
|
||||
score.overallScore?.let { append(",\"overall_score\":$it") }
|
||||
listOf(
|
||||
"content_score" to score.contentScore,
|
||||
"fluency_score" to score.fluencyScore,
|
||||
"duration_score" to score.durationScore,
|
||||
"pause_score" to score.pauseScore,
|
||||
"speech_rate_score" to score.speechRateScore,
|
||||
).forEach { (name, value) ->
|
||||
value?.let { append(",\"$name\":$it") }
|
||||
}
|
||||
score.recognizedText?.let {
|
||||
append(",\"recognized_text\":\"")
|
||||
append(it.replace("\\", "\\\\").replace("\"", "\\\""))
|
||||
append("\"")
|
||||
}
|
||||
append("}")
|
||||
}
|
||||
},
|
||||
)
|
||||
segments.toSortedMap().forEach { (index, file) ->
|
||||
output.writeBytes("--$boundary\r\n")
|
||||
output.writeBytes(
|
||||
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
|
||||
)
|
||||
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
output.writeBytes("\r\n")
|
||||
}
|
||||
output.writeBytes("--$boundary--\r\n")
|
||||
}
|
||||
|
||||
val code = connection.responseCode
|
||||
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
|
||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
require(code in 200..299) { "HTTP $code: $body" }
|
||||
val response = JSONObject(body).getString("share_id")
|
||||
return "$baseUrl/dub-shares/$response"
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
StrictMode.setThreadPolicy(previousPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class DubbingState {
|
||||
val segments = mutableMapOf<Int, File>()
|
||||
var mergedFile: File? = null
|
||||
private set
|
||||
|
||||
fun storeSegment(sentenceIndex: Int, file: File) {
|
||||
segments[sentenceIndex]?.delete()
|
||||
segments[sentenceIndex] = file
|
||||
mergedFile = null
|
||||
}
|
||||
|
||||
fun setMergedFile(file: File) {
|
||||
mergedFile = file
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot
|
||||
import java.util.Locale
|
||||
|
||||
internal object PlaybackFormat {
|
||||
fun status(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"
|
||||
}
|
||||
|
||||
fun progress(snapshot: PlaybackSnapshot, max: Int = PROGRESS_MAX): Int {
|
||||
if (snapshot.durationMs <= 0L) {
|
||||
return 0
|
||||
}
|
||||
return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * max) / snapshot.durationMs).toInt()
|
||||
}
|
||||
|
||||
fun seekPosition(progress: Int, durationMs: Long, max: Int = PROGRESS_MAX): Long {
|
||||
if (durationMs <= 0L) {
|
||||
return 0L
|
||||
}
|
||||
return progress.toLong() * durationMs / max
|
||||
}
|
||||
|
||||
fun time(ms: Long): String {
|
||||
if (ms < 0) {
|
||||
return "--:--"
|
||||
}
|
||||
val totalSeconds = ms / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(Locale.US, hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(Locale.US, minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun speed(speed: Float): String {
|
||||
val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.')
|
||||
return "${value}x"
|
||||
}
|
||||
|
||||
fun isVideoHash(value: String): Boolean = SHA256_PATTERN.matches(value)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Holds view references recreated when the sample app rebuilds its content tree. */
|
||||
internal class SampleBindings {
|
||||
lateinit var playerView: OralTrainerPlayerView
|
||||
lateinit var lessonTitleText: TextView
|
||||
lateinit var statusText: TextView
|
||||
lateinit var sentenceText: TextView
|
||||
lateinit var sentenceMetaText: TextView
|
||||
lateinit var timeText: TextView
|
||||
lateinit var speedText: TextView
|
||||
lateinit var progressBar: SeekBar
|
||||
lateinit var catalogList: LinearLayout
|
||||
lateinit var catalogStatusText: TextView
|
||||
lateinit var testStatusText: TextView
|
||||
lateinit var scoreSummaryText: TextView
|
||||
lateinit var scoreDetailText: TextView
|
||||
lateinit var recordButton: Button
|
||||
lateinit var playDubButton: Button
|
||||
lateinit var mergeDubButton: Button
|
||||
lateinit var shareDubButton: Button
|
||||
var dubbingStatusText: TextView? = null
|
||||
var myContentList: LinearLayout? = null
|
||||
|
||||
val hasPlayerChrome: Boolean
|
||||
get() = ::lessonTitleText.isInitialized &&
|
||||
::statusText.isInitialized &&
|
||||
::sentenceText.isInitialized &&
|
||||
::sentenceMetaText.isInitialized &&
|
||||
::timeText.isInitialized &&
|
||||
::speedText.isInitialized &&
|
||||
::progressBar.isInitialized
|
||||
|
||||
val hasCatalogChrome: Boolean
|
||||
get() = ::catalogList.isInitialized && ::catalogStatusText.isInitialized
|
||||
|
||||
val hasTestChrome: Boolean
|
||||
get() = ::testStatusText.isInitialized &&
|
||||
::scoreSummaryText.isInitialized &&
|
||||
::scoreDetailText.isInitialized &&
|
||||
::recordButton.isInitialized &&
|
||||
::playDubButton.isInitialized &&
|
||||
::mergeDubButton.isInitialized &&
|
||||
::shareDubButton.isInitialized
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal interface ModuleTabActions {
|
||||
fun onModuleSelected(module: Module)
|
||||
}
|
||||
|
||||
internal interface CourseScreenActions {
|
||||
fun onSelectCourse(video: TrainingVideoSummary)
|
||||
}
|
||||
|
||||
internal interface TrainScreenActions {
|
||||
fun onImportLocalVideo()
|
||||
fun onRefreshCatalog()
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onContinuousPlaybackChanged(enabled: Boolean)
|
||||
fun onTrainCatalogVideoClick(video: TrainingVideoSummary, enrolled: Boolean)
|
||||
}
|
||||
|
||||
internal interface TestScreenActions {
|
||||
fun onSeekProgressPreview(progress: Int)
|
||||
fun onSeekStart()
|
||||
fun onSeekStop(progress: Int)
|
||||
fun onTestSubtitleChanged(visible: Boolean)
|
||||
fun onPrevSentence()
|
||||
fun onReplaySentence()
|
||||
fun onNextSentence()
|
||||
fun onToggleRecording()
|
||||
fun onToggleDubPlayback()
|
||||
fun onMergeDub()
|
||||
fun onShareDub()
|
||||
}
|
||||
|
||||
internal interface MineScreenActions {
|
||||
fun onLogout()
|
||||
fun nickname(): String
|
||||
}
|
||||
|
||||
internal interface SampleScreenActions :
|
||||
ModuleTabActions,
|
||||
CourseScreenActions,
|
||||
TrainScreenActions,
|
||||
TestScreenActions,
|
||||
MineScreenActions
|
||||
@@ -0,0 +1,91 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
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.SampleWidgets
|
||||
import cn.learningpad.oraltrainer.sample.ui.TestScreen
|
||||
import cn.learningpad.oraltrainer.sample.ui.TrainScreen
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.UserApi
|
||||
|
||||
/** Facade that routes content creation to module-specific screens. */
|
||||
internal class SampleScreenFactory(
|
||||
private val activity: Activity,
|
||||
bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
controller: OralTrainerController,
|
||||
private val actions: SampleScreenActions,
|
||||
) {
|
||||
private val widgets = SampleWidgets(activity, screenState)
|
||||
private val playerChrome = PlayerChrome(
|
||||
activity = activity,
|
||||
bindings = bindings,
|
||||
screenState = screenState,
|
||||
controller = controller,
|
||||
widgets = widgets,
|
||||
seekActions = actions,
|
||||
)
|
||||
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)
|
||||
|
||||
fun createContentView(): View {
|
||||
val isLandscape =
|
||||
activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
if ((screenState.activeModule == Module.TRAIN || screenState.activeModule == Module.TEST) &&
|
||||
isLandscape
|
||||
) {
|
||||
return playerChrome.createLandscapeContentView()
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(SampleTheme.background)
|
||||
addView(widgets.moduleTabBar(actions))
|
||||
when (screenState.activeModule) {
|
||||
Module.COURSE -> addView(courseScreen.createSection())
|
||||
Module.MINE -> addView(mineScreen.createSection())
|
||||
Module.TRAIN -> trainScreen.attachPortraitSections(this)
|
||||
Module.TEST -> testScreen.attachSections(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
when (screenState.activeModule) {
|
||||
Module.COURSE -> courseScreen.renderCatalog()
|
||||
Module.TRAIN -> trainScreen.renderCatalogStrip()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun showCatalogLoading() {
|
||||
when (screenState.activeModule) {
|
||||
Module.COURSE -> courseScreen.showLoading()
|
||||
Module.TRAIN -> trainScreen.showCatalogLoading()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun setCatalogStatus(text: String) {
|
||||
courseScreen.setStatus(text)
|
||||
trainScreen.setCatalogStatus(text)
|
||||
}
|
||||
|
||||
fun refreshMyContent(userApi: UserApi, token: String) {
|
||||
mineScreen.refreshContent(userApi, token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.graphics.Color
|
||||
|
||||
internal object SampleTheme {
|
||||
val background = Color.rgb(12, 15, 18)
|
||||
val surface = Color.rgb(28, 34, 40)
|
||||
val border = Color.rgb(50, 59, 67)
|
||||
val button = Color.rgb(37, 99, 235)
|
||||
val accent = Color.rgb(21, 184, 132)
|
||||
val accentDeep = Color.rgb(7, 118, 86)
|
||||
val accentLight = Color.rgb(94, 234, 182)
|
||||
val selected = Color.rgb(229, 248, 240)
|
||||
val progressTrack = Color.rgb(224, 231, 235)
|
||||
val progressTrackDark = Color.rgb(55, 62, 70)
|
||||
val lightBorder = Color.rgb(218, 226, 232)
|
||||
val overlay = Color.argb(178, 9, 12, 16)
|
||||
val overlayFaint = Color.argb(51, 9, 12, 16)
|
||||
val textDark = Color.rgb(18, 24, 31)
|
||||
val textMuted = Color.rgb(151, 162, 174)
|
||||
val textSubtle = Color.rgb(88, 98, 108)
|
||||
val textOverlay = Color.rgb(163, 172, 182)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig
|
||||
import cn.learningpad.oraltrainer.sdk.PlayerConfig
|
||||
|
||||
internal data class SdkComponents(
|
||||
val sdk: OralTrainerSdk,
|
||||
val controller: OralTrainerController,
|
||||
)
|
||||
|
||||
internal object SdkModule {
|
||||
fun create(context: Context, userToken: String?): SdkComponents {
|
||||
val sdk = OralTrainerSdk.init(
|
||||
context,
|
||||
OralTrainerSdkConfig(
|
||||
sentenceBoundaryApiBaseUrl = SERVER_BASE_URL,
|
||||
assessmentApiKey = ASSESSMENT_API_KEY,
|
||||
userToken = userToken?.takeIf { it.isNotBlank() },
|
||||
allowHttpFallback = true,
|
||||
),
|
||||
)
|
||||
val controller = sdk.createController(
|
||||
playerConfig = PlayerConfig(
|
||||
sentenceMode = true,
|
||||
defaultSeekStepMs = 10_000L,
|
||||
autoPlay = false,
|
||||
continuousPlayback = false,
|
||||
),
|
||||
imitationAssessor = sdk.createRemoteImitationQualityAssessor(),
|
||||
).also {
|
||||
it.setLoopMode(LoopMode.ALL)
|
||||
}
|
||||
return SdkComponents(sdk, controller)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** Recording, assessment, and dubbing flow for the TEST module. */
|
||||
internal class TestSessionController(
|
||||
private val activity: Activity,
|
||||
private val controller: OralTrainerController,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val attemptRecorder: AttemptRecorder,
|
||||
private val dubbingState: DubbingState,
|
||||
private val dubbingMerger: DubbingMerger,
|
||||
private val dubbingShareApi: DubbingShareApi,
|
||||
private val dubbingPlayback: DubbingPlayback,
|
||||
private val assessmentState: AssessmentState,
|
||||
private val host: Host,
|
||||
) {
|
||||
interface Host {
|
||||
val bindings: SampleBindings
|
||||
fun setMainStatus(text: String)
|
||||
fun rebuildContent()
|
||||
fun refreshPlayerUi()
|
||||
}
|
||||
|
||||
val isDubbingActive: Boolean
|
||||
get() = dubbingPlayback.isActive
|
||||
|
||||
fun onScreenOff() {
|
||||
if (!dubbingPlayback.isActive) {
|
||||
controller.pause()
|
||||
return
|
||||
}
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
attemptRecorder.discard()
|
||||
dubbingPlayback.release()
|
||||
}
|
||||
|
||||
fun stopDubbingIfNeeded() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTestUi() {
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
applyTestSubtitleVisibility()
|
||||
ui.recordButton.text = if (attemptRecorder.isRecording) "停止并评测" else "开始录音"
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
val currentRecording = sentence?.let { dubbingState.segments[it.index] }
|
||||
val segmentLabel = when {
|
||||
currentRecording != null -> " · 本句已录音,重录将覆盖"
|
||||
else -> ""
|
||||
}
|
||||
ui.testStatusText.text = if (item == null || sentence == null) {
|
||||
"请先在训练模块选择课程$segmentLabel"
|
||||
} else {
|
||||
"评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel"
|
||||
}
|
||||
refreshDubButtons()
|
||||
if (dubbingState.mergedFile == null) {
|
||||
ui.dubbingStatusText?.text = when (dubbingState.segments.size) {
|
||||
0 -> "配音:请先录音"
|
||||
1 -> "配音:已保存 1 句,可生成配音"
|
||||
else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (!attemptRecorder.isRecording) {
|
||||
startRecording()
|
||||
} else {
|
||||
stopRecordingAndAssess()
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
controller.pause()
|
||||
val ui = host.bindings
|
||||
if (!ui.hasTestChrome) {
|
||||
return
|
||||
}
|
||||
if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
RECORD_AUDIO_REQUEST,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
attemptRecorder.start(activity.cacheDir)
|
||||
val sentence = controller.currentSentence()
|
||||
if (sentence != null) {
|
||||
controller.seekTo(sentence.startMs)
|
||||
controller.setVolume(0f)
|
||||
}
|
||||
controller.play()
|
||||
ui.recordButton.text = "停止并保存"
|
||||
ui.testStatusText.text = if (sentence == null) {
|
||||
"正在录音…读完当前句子后点击“停止并保存”"
|
||||
} else {
|
||||
"正在录音…请对照静音视频口形,读完点击“停止并保存”"
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音启动失败:${error.message.orEmpty()}"
|
||||
attemptRecorder.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDubPlayback() {
|
||||
if (dubbingPlayback.isActive) {
|
||||
dubbingPlayback.stop(controller)
|
||||
refreshTestUi()
|
||||
host.setMainStatus("已停止配音播放")
|
||||
return
|
||||
}
|
||||
val audio = dubbingState.mergedFile ?: activeAudioForPlayback() ?: return
|
||||
startDubPlayback(audio)
|
||||
}
|
||||
|
||||
fun mergeDubSegments() {
|
||||
val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file }
|
||||
if (segments.isEmpty()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val output = dubbingMerger.merge(segments)
|
||||
dubbingState.setMergedFile(output)
|
||||
refreshTestUi()
|
||||
setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "merge failed", error)
|
||||
setDubbingStatus(
|
||||
"生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}" +
|
||||
" · 已保存 ${dubbingState.segments.size} 句",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun shareMergedDubbing() {
|
||||
val item = controller.currentTrainingItem()
|
||||
if (item == null || !PlaybackFormat.isVideoHash(item.id)) {
|
||||
setDubbingStatus("分享失败:网页分享仅支持云端课程视频")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val shareUrl = dubbingShareApi.upload(
|
||||
baseUrl = SERVER_BASE_URL,
|
||||
videoHash = item.id,
|
||||
videoTitle = item.title,
|
||||
segments = dubbingState.segments,
|
||||
scores = assessmentState.toMap(),
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, "我的口语配音:$shareUrl")
|
||||
putExtra(Intent.EXTRA_TITLE, "我的口语配音")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(intent, "分享配音"))
|
||||
setDubbingStatus("已生成分享链接:$shareUrl")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "share failed", error)
|
||||
setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun onTestSubtitleChanged(visible: Boolean) {
|
||||
screenState.testSubtitleVisible = visible
|
||||
screenState.landscapeSubtitleVisible = visible
|
||||
host.rebuildContent()
|
||||
host.refreshPlayerUi()
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun stopRecordingAndAssess() {
|
||||
if (!attemptRecorder.isRecording) return
|
||||
val ui = host.bindings
|
||||
ui.recordButton.text = "重新录音"
|
||||
val file = try {
|
||||
attemptRecorder.stop()
|
||||
} catch (error: Throwable) {
|
||||
ui.testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}"
|
||||
return
|
||||
}
|
||||
controller.pause()
|
||||
controller.setVolume(1f)
|
||||
val sentenceIndex = controller.currentSentence()?.index
|
||||
if (sentenceIndex == null) {
|
||||
ui.testStatusText.text = "没有可归属的句子,本次录音已丢弃"
|
||||
file.delete()
|
||||
attemptRecorder.release()
|
||||
refreshTestUi()
|
||||
return
|
||||
}
|
||||
dubbingState.storeSegment(sentenceIndex, file)
|
||||
submitAssessment(Uri.fromFile(file))
|
||||
refreshTestUi()
|
||||
}
|
||||
|
||||
private fun submitAssessment(recordingUri: Uri) {
|
||||
val ui = host.bindings
|
||||
val item = controller.currentTrainingItem()
|
||||
val sentence = controller.currentSentence()
|
||||
if (item == null || sentence == null) {
|
||||
ui.testStatusText.text = "没有正在学习的句子,无法评测"
|
||||
return
|
||||
}
|
||||
if (!PlaybackFormat.isVideoHash(item.id)) {
|
||||
ui.testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
|
||||
return
|
||||
}
|
||||
if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) {
|
||||
ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY"
|
||||
return
|
||||
}
|
||||
ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…"
|
||||
ui.scoreSummaryText.text = "评测中…"
|
||||
ui.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 (host.bindings.hasTestChrome) {
|
||||
applyAssessmentResult(result)
|
||||
}
|
||||
assessmentState.store(sentence.index, result)
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
if (host.bindings.hasTestChrome) {
|
||||
host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}"
|
||||
host.bindings.scoreSummaryText.text = "评测失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyAssessmentResult(result: ImitationAssessmentResult) {
|
||||
val ui = host.bindings
|
||||
ui.testStatusText.text = if (result.passed == true) "已通过" else "未通过"
|
||||
ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore)
|
||||
ui.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 applyTestSubtitleVisibility() {
|
||||
if (screenState.activeModule != Module.TEST) {
|
||||
return
|
||||
}
|
||||
screenState.landscapeSubtitleVisible = screenState.testSubtitleVisible
|
||||
val ui = host.bindings
|
||||
if (!ui.hasPlayerChrome) {
|
||||
return
|
||||
}
|
||||
val visibility = if (screenState.testSubtitleVisible) View.VISIBLE else View.GONE
|
||||
ui.sentenceMetaText.visibility = visibility
|
||||
ui.sentenceText.visibility = visibility
|
||||
ui.progressBar.visibility = visibility
|
||||
}
|
||||
|
||||
private fun refreshDubButtons() {
|
||||
val ui = host.bindings
|
||||
val dubbingActive = dubbingPlayback.isActive
|
||||
ui.playDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
||||
ui.mergeDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive
|
||||
ui.shareDubButton.isEnabled = dubbingState.mergedFile?.exists() == true && !dubbingActive
|
||||
val activeAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] }
|
||||
ui.playDubButton.text = when {
|
||||
dubbingActive -> "停止配音"
|
||||
dubbingState.mergedFile?.exists() == true -> "播放配音"
|
||||
activeAudio != null -> "播放本句配音"
|
||||
else -> "播放录音(${dubbingState.segments.size})"
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeAudioForPlayback(): File? {
|
||||
if (dubbingState.mergedFile != null) {
|
||||
return dubbingState.mergedFile
|
||||
}
|
||||
return controller.currentSentence()?.let { sentence -> dubbingState.segments[sentence.index] }
|
||||
}
|
||||
|
||||
private fun startDubPlayback(audioFile: File) {
|
||||
val sentence = controller.currentSentence()
|
||||
dubbingPlayback.start(
|
||||
audioFile = audioFile,
|
||||
controller = controller,
|
||||
syncToSentence = dubbingState.mergedFile == null,
|
||||
sentenceStartMs = sentence?.startMs,
|
||||
) {
|
||||
refreshTestUi()
|
||||
host.setMainStatus("配音播放完成")
|
||||
}
|
||||
refreshTestUi()
|
||||
host.setMainStatus(
|
||||
if (dubbingState.mergedFile == null) {
|
||||
"正在播放当前句配音,原视频画面同步但原声静音"
|
||||
} else {
|
||||
"正在播放整段配音,原视频画面同步但原声静音"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun setDubbingStatus(message: String) {
|
||||
host.bindings.dubbingStatusText?.text = message
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "Dubbing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
internal class TrainingScreenState {
|
||||
var activeItemId = ""
|
||||
var currentSentenceCount = 0
|
||||
var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||
val courseEnrollment = mutableMapOf<String, Boolean>()
|
||||
var catalogStatus = "正在同步"
|
||||
var activeModule = Module.TRAIN
|
||||
var currentCourse: TrainingVideoSummary? = null
|
||||
var continuousPlaybackEnabled = false
|
||||
var testSubtitleVisible = false
|
||||
var landscapeSubtitleVisible = true
|
||||
|
||||
private var boundaryRequestGeneration = 0
|
||||
|
||||
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
|
||||
|
||||
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
|
||||
boundaryRequestGeneration == generation && activeItemId == itemId
|
||||
|
||||
fun clearCourse() {
|
||||
currentCourse = null
|
||||
activeItemId = ""
|
||||
currentSentenceCount = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.learningpad.oraltrainer.sample
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.widget.LinearLayout
|
||||
|
||||
/** Shared density/style helpers for Activity and coordinator UI builders. */
|
||||
internal interface UiStyled {
|
||||
val uiContext: Context
|
||||
|
||||
val Int.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
val Float.dp: Int
|
||||
get() = (this * uiContext.resources.displayMetrics.density).toInt()
|
||||
|
||||
fun rounded(
|
||||
color: Int,
|
||||
radiusDp: Float,
|
||||
strokeColor: Int? = null,
|
||||
): GradientDrawable = GradientDrawable().apply {
|
||||
setColor(color)
|
||||
cornerRadius = radiusDp.dp.toFloat()
|
||||
strokeColor?.let { setStroke(1.dp, it) }
|
||||
}
|
||||
|
||||
fun LinearLayout.LayoutParams.withMargins(
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
): LinearLayout.LayoutParams = apply {
|
||||
setMargins(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.CourseScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class CourseScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: CourseScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.catalogStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
text = screenState.catalogStatus
|
||||
setPadding(16.dp, 0.dp, 16.dp, 8.dp)
|
||||
}
|
||||
bindings.catalogList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 8.dp, 16.dp, 16.dp)
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0.dp, 12.dp, 0.dp, 16.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = "课程首页"
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
setPadding(16.dp, 0.dp, 16.dp, 0.dp)
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "选择一门课程开始训练"
|
||||
textSize = 13f
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
setPadding(16.dp, 4.dp, 16.dp, 8.dp)
|
||||
})
|
||||
addView(bindings.catalogStatusText)
|
||||
addView(bindings.catalogList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun renderCatalog() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogStatusText.text = screenState.catalogStatus
|
||||
if (screenState.catalogVideos.isEmpty()) {
|
||||
bindings.catalogList.addView(widgets.infoCard("暂无课程"))
|
||||
return
|
||||
}
|
||||
screenState.catalogVideos.forEach { video ->
|
||||
bindings.catalogList.addView(
|
||||
widgets.courseListCard(video) { actions.onSelectCourse(video) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLoading() {
|
||||
if (!bindings.hasCatalogChrome) return
|
||||
bindings.catalogList.removeAllViews()
|
||||
bindings.catalogList.addView(widgets.infoCard("课程加载中..."))
|
||||
}
|
||||
|
||||
fun setStatus(text: String) {
|
||||
if (bindings.hasCatalogChrome) {
|
||||
bindings.catalogStatusText.text = text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.MineScreenActions
|
||||
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.UserApi
|
||||
|
||||
internal class MineScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val widgets: SampleWidgets,
|
||||
private val actions: MineScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createSection(): View {
|
||||
bindings.myContentList = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
return ScrollView(activity).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(16.dp, 12.dp, 16.dp, 16.dp)
|
||||
addView(TextView(activity).apply {
|
||||
text = actions.nickname().ifBlank { "我的学习" }
|
||||
textSize = 26f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(Button(activity).apply {
|
||||
text = "退出登录"
|
||||
setOnClickListener { actions.onLogout() }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
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.myContentList)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshContent(userApi: UserApi, token: String) {
|
||||
val list = bindings.myContentList ?: return
|
||||
list.removeAllViews()
|
||||
val course = screenState.currentCourse
|
||||
if (course == null) {
|
||||
list.addView(widgets.infoCard("请先在「课程」中选择一门课程"))
|
||||
return
|
||||
}
|
||||
list.addView(courseHeading(course.title))
|
||||
list.addView(widgets.infoCard("加载中..."))
|
||||
userApi.results(token) { result ->
|
||||
list.removeAllViews()
|
||||
list.addView(courseHeading(course.title))
|
||||
result.fold(
|
||||
onSuccess = { rows ->
|
||||
val filtered = rows.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) list.addView(widgets.infoCard("该课程还没有测试记录"))
|
||||
filtered.forEach { row ->
|
||||
val passedLabel = if (row.passed) "通过" else "未通过"
|
||||
list.addView(
|
||||
widgets.infoCard(
|
||||
"第 ${row.sentenceIndex + 1} 句" + "\n" +
|
||||
"得分 ${row.overallScore.toInt()} · $passedLabel",
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { list.addView(widgets.infoCard("测试记录加载失败:${it.message}")) },
|
||||
)
|
||||
userApi.dubShares(token) { shareResult ->
|
||||
list.post {
|
||||
list.addView(widgets.titleDivider("我的配音分享"))
|
||||
shareResult.fold(
|
||||
onSuccess = { shares ->
|
||||
val filtered = shares.filter { it.videoHash == course.videoHash }
|
||||
if (filtered.isEmpty()) {
|
||||
list.addView(widgets.infoCard("该课程还没有分享配音"))
|
||||
}
|
||||
filtered.forEach { row ->
|
||||
list.addView(
|
||||
widgets.infoCard(
|
||||
"${row.title}" + "\n" +
|
||||
"${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分",
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
list.addView(widgets.infoCard("配音分享加载失败:${it.message}"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun courseHeading(title: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = "当前课程:$title"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.TranslateAnimation
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.PROGRESS_MAX
|
||||
import cn.learningpad.oraltrainer.sample.R
|
||||
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
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerController
|
||||
import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView
|
||||
|
||||
/** Player surface, overlays, sentence panel, and landscape subtitle chrome. */
|
||||
internal class PlayerChrome(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val controller: OralTrainerController,
|
||||
private val widgets: SampleWidgets,
|
||||
private val seekActions: TrainScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun createPlayerSection(): View =
|
||||
FrameLayout(activity).apply {
|
||||
background = rounded(SampleTheme.surface, 8f, SampleTheme.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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createSentenceSection(): View =
|
||||
createSentenceContent(translucent = false).apply {
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
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)
|
||||
}
|
||||
|
||||
fun createLandscapeContentView(): View =
|
||||
FrameLayout(activity).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(createPlayerView())
|
||||
addView(createPlayerOverlay())
|
||||
val subtitleContainer = LinearLayout(activity).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(subtitleContainer)
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = false)
|
||||
installLandscapeSwipeGesture(subtitleContainer)
|
||||
}
|
||||
|
||||
fun createSentenceContent(translucent: Boolean): LinearLayout {
|
||||
bindings.lessonTitleText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 17f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
}
|
||||
bindings.sentenceMetaText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.accentLight else SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.sentenceText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) Color.WHITE else SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setLineSpacing(2.dp.toFloat(), 1.05f)
|
||||
setPadding(0, 10.dp, 0, 10.dp)
|
||||
}
|
||||
bindings.progressBar = SeekBar(activity).apply {
|
||||
max = PROGRESS_MAX
|
||||
progress = 0
|
||||
progressTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.progressTrackDark else SampleTheme.progressTrack,
|
||||
)
|
||||
thumbTintList = ColorStateList.valueOf(SampleTheme.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) seekActions.onSeekProgressPreview(progress)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStart()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
seekActions.onSeekStop(seekBar.progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
bindings.statusText = TextView(activity).apply {
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
if (translucent) {
|
||||
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))
|
||||
}
|
||||
addView(bindings.sentenceMetaText)
|
||||
addView(bindings.sentenceText)
|
||||
addView(bindings.progressBar)
|
||||
addView(bindings.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerView(): OralTrainerPlayerView =
|
||||
OralTrainerPlayerView(activity).apply {
|
||||
bind(controller)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}.also { bindings.playerView = it }
|
||||
|
||||
private fun createPlayerOverlay(): View {
|
||||
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,
|
||||
)
|
||||
}
|
||||
bindings.timeText = widgets.overlayPill("--:-- / --:--").apply {
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
bindings.speedText = widgets.overlayPill("1x")
|
||||
overlay.addView(bindings.timeText)
|
||||
overlay.addView(bindings.speedText)
|
||||
return overlay
|
||||
}
|
||||
|
||||
private fun createRotationButton(): ImageButton =
|
||||
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())
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
setPadding(10.dp, 10.dp, 10.dp, 10.dp)
|
||||
contentDescription = "横屏"
|
||||
layoutParams = LinearLayout.LayoutParams(44.dp, 44.dp)
|
||||
setOnClickListener {
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLandscapeSentencePanel(): View =
|
||||
createSentenceContent(translucent = true).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createContinuousPlaybackRow(translucent: Boolean): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "连续播放"
|
||||
setTextColor(if (translucent) SampleTheme.textOverlay else SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.continuousPlaybackEnabled
|
||||
buttonTintList = ColorStateList.valueOf(
|
||||
if (translucent) SampleTheme.accentLight else SampleTheme.accent,
|
||||
)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
seekActions.onContinuousPlaybackChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun installLandscapeSwipeGesture(subtitleContainer: View) {
|
||||
bindings.playerView.setVerticalSwipeListener { isSwipeUp ->
|
||||
if (isSwipeUp == screenState.landscapeSubtitleVisible) return@setVerticalSwipeListener
|
||||
screenState.landscapeSubtitleVisible = isSwipeUp
|
||||
applyLandscapeSubtitleState(subtitleContainer, animate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLandscapeSubtitleState(container: View, animate: Boolean) {
|
||||
if (!animate || container.visibility == View.GONE) {
|
||||
container.clearAnimation()
|
||||
container.visibility =
|
||||
if (screenState.landscapeSubtitleVisible) View.VISIBLE else View.GONE
|
||||
return
|
||||
}
|
||||
if (screenState.landscapeSubtitleVisible) {
|
||||
container.visibility = View.VISIBLE
|
||||
container.post {
|
||||
if (screenState.landscapeSubtitleVisible && container.visibility == View.VISIBLE) {
|
||||
startSlideAnimation(container, fromYDelta = 1f, toYDelta = 0f) {
|
||||
container.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startSlideAnimation(container, fromYDelta = 0f, toYDelta = 1f) {
|
||||
container.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSlideAnimation(
|
||||
view: View,
|
||||
fromYDelta: Float,
|
||||
toYDelta: Float,
|
||||
onEnd: () -> Unit,
|
||||
) {
|
||||
val animation = TranslateAnimation(0f, 0f, view.height * fromYDelta, view.height * toYDelta).apply {
|
||||
duration = 220L
|
||||
fillAfter = false
|
||||
setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation?) = Unit
|
||||
override fun onAnimationRepeat(animation: Animation?) = Unit
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
view.clearAnimation()
|
||||
onEnd()
|
||||
}
|
||||
})
|
||||
}
|
||||
view.startAnimation(animation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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.TextView
|
||||
import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sample.Module
|
||||
import cn.learningpad.oraltrainer.sample.ModuleTabActions
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||
|
||||
/** Shared compact widgets used across module screens. */
|
||||
internal class SampleWidgets(
|
||||
private val activity: Activity,
|
||||
private val screenState: TrainingScreenState,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun moduleTabBar(actions: ModuleTabActions): View =
|
||||
LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(16.dp, 40.dp, 16.dp, 0)
|
||||
addView(moduleTab("课程", Module.COURSE, actions))
|
||||
addView(moduleTab("训练", Module.TRAIN, actions))
|
||||
addView(moduleTab("测试", Module.TEST, actions))
|
||||
addView(moduleTab("我的", Module.MINE, actions))
|
||||
}
|
||||
|
||||
fun moduleTab(label: String, module: Module, actions: ModuleTabActions): TextView {
|
||||
val selected = screenState.activeModule == module
|
||||
return TextView(activity).apply {
|
||||
text = label
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = Gravity.CENTER
|
||||
isClickable = true
|
||||
setTextColor(if (selected) Color.WHITE else SampleTheme.textMuted)
|
||||
background = if (selected) {
|
||||
rounded(SampleTheme.button, 8f)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 8f, SampleTheme.border)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener {
|
||||
if (screenState.activeModule == module) return@setOnClickListener
|
||||
if (module != Module.COURSE && screenState.currentCourse == null) {
|
||||
Toast.makeText(activity, "请先选择课程", Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
actions.onModuleSelected(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun headerButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
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(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
40.dp,
|
||||
).withMargins(8.dp, 0, 0, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun overlayPill(label: String): TextView =
|
||||
TextView(activity).apply {
|
||||
text = label
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
background = rounded(SampleTheme.overlayFaint, 8f)
|
||||
setPadding(10.dp, 6.dp, 10.dp, 6.dp)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
fun infoCard(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
background = rounded(SampleTheme.selected, 8f)
|
||||
setPadding(12.dp, 10.dp, 12.dp, 10.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun titleDivider(text: String): TextView =
|
||||
TextView(activity).apply {
|
||||
this.text = text
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 18f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 20.dp, 0, 8.dp)
|
||||
}
|
||||
|
||||
fun courseListCard(
|
||||
video: TrainingVideoSummary,
|
||||
onClick: () -> Unit,
|
||||
): View {
|
||||
val enrolled = screenState.courseEnrollment[video.videoHash] == true
|
||||
val isSelected = screenState.currentCourse?.videoHash == video.videoHash
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
isClickable = true
|
||||
background = if (isSelected) {
|
||||
rounded(SampleTheme.selected, 10f, SampleTheme.accent)
|
||||
} else {
|
||||
rounded(SampleTheme.surface, 10f, SampleTheme.border)
|
||||
}
|
||||
setPadding(16.dp, 14.dp, 16.dp, 14.dp)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).withMargins(0, 0, 0, 10.dp)
|
||||
setOnClickListener { onClick() }
|
||||
addView(TextView(activity).apply {
|
||||
text = video.title
|
||||
setTextColor(if (isSelected) SampleTheme.textDark else Color.WHITE)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
maxLines = 2
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = buildString {
|
||||
append(if (enrolled) "已选修" else "未选修")
|
||||
append(" · ${video.sentenceCount} 句")
|
||||
if (isSelected) append(" · 当前课程")
|
||||
}
|
||||
setTextColor(if (isSelected) SampleTheme.accentDeep else SampleTheme.textMuted)
|
||||
textSize = 13f
|
||||
setPadding(0, 6.dp, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package cn.learningpad.oraltrainer.sample.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.ColorStateList
|
||||
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.Switch
|
||||
import android.widget.TextView
|
||||
import cn.learningpad.oraltrainer.sample.SampleBindings
|
||||
import cn.learningpad.oraltrainer.sample.SampleTheme
|
||||
import cn.learningpad.oraltrainer.sample.TestScreenActions
|
||||
import cn.learningpad.oraltrainer.sample.TrainingScreenState
|
||||
import cn.learningpad.oraltrainer.sample.UiStyled
|
||||
|
||||
internal class TestScreen(
|
||||
private val activity: Activity,
|
||||
private val bindings: SampleBindings,
|
||||
private val screenState: TrainingScreenState,
|
||||
private val playerChrome: PlayerChrome,
|
||||
private val actions: TestScreenActions,
|
||||
) : UiStyled {
|
||||
override val uiContext get() = activity
|
||||
|
||||
fun attachSections(root: LinearLayout) {
|
||||
root.addView(playerChrome.createPlayerSection())
|
||||
if (screenState.testSubtitleVisible) {
|
||||
root.addView(playerChrome.createSentenceSection())
|
||||
}
|
||||
root.addView(createTestSection())
|
||||
}
|
||||
|
||||
private fun createTestSection(): View {
|
||||
bindings.testStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 0, 0, 8.dp)
|
||||
}
|
||||
bindings.scoreSummaryText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
text = "尚未评测"
|
||||
}
|
||||
bindings.scoreDetailText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.textSubtle)
|
||||
textSize = 13f
|
||||
setLineSpacing(4.dp.toFloat(), 1f)
|
||||
setPadding(0, 8.dp, 0, 0)
|
||||
}
|
||||
bindings.dubbingStatusText = TextView(activity).apply {
|
||||
setTextColor(SampleTheme.accentDeep)
|
||||
textSize = 13f
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
text = "配音:尚未生成"
|
||||
}
|
||||
|
||||
fun sentenceButton(label: String, action: () -> Unit): Button =
|
||||
Button(activity).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(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
|
||||
fun dubButton(label: String): Button =
|
||||
Button(activity).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.button, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0)
|
||||
}
|
||||
|
||||
bindings.recordButton = Button(activity).apply {
|
||||
text = "开始录音"
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 14f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
minWidth = 0
|
||||
minHeight = 0
|
||||
minimumWidth = 0
|
||||
minimumHeight = 0
|
||||
background = rounded(SampleTheme.accent, 8f)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
44.dp,
|
||||
1f,
|
||||
)
|
||||
setOnClickListener { actions.onToggleRecording() }
|
||||
}
|
||||
|
||||
val subtitleToggle = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, 10.dp, 0, 0)
|
||||
addView(TextView(activity).apply {
|
||||
text = "显示字幕"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 13f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
addView(Switch(activity).apply {
|
||||
isChecked = screenState.testSubtitleVisible
|
||||
buttonTintList = ColorStateList.valueOf(SampleTheme.accent)
|
||||
setOnCheckedChangeListener { _, checked ->
|
||||
actions.onTestSubtitleChanged(checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bindings.playDubButton = dubButton("播放配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onToggleDubPlayback() }
|
||||
}
|
||||
bindings.mergeDubButton = dubButton("生成配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onMergeDub() }
|
||||
}
|
||||
bindings.shareDubButton = dubButton("分享配音").apply {
|
||||
isEnabled = false
|
||||
setOnClickListener { actions.onShareDub() }
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0)
|
||||
}
|
||||
|
||||
val controlRow = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 4.dp, 0, 8.dp)
|
||||
addView(sentenceButton("上一句") { actions.onPrevSentence() })
|
||||
addView(sentenceButton("重播本句") { actions.onReplaySentence() })
|
||||
addView(
|
||||
sentenceButton("下一句") { actions.onNextSentence() }.apply {
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 0, 0, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = rounded(Color.WHITE, 8f, SampleTheme.lightBorder)
|
||||
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(activity).apply {
|
||||
text = "朗读评测(测试)"
|
||||
setTextColor(SampleTheme.textDark)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
})
|
||||
addView(subtitleToggle, 0)
|
||||
addView(bindings.testStatusText)
|
||||
addView(controlRow)
|
||||
addView(bindings.recordButton)
|
||||
addView(LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(bindings.playDubButton)
|
||||
addView(bindings.mergeDubButton)
|
||||
addView(bindings.shareDubButton)
|
||||
})
|
||||
addView(bindings.scoreSummaryText)
|
||||
addView(bindings.scoreDetailText)
|
||||
addView(bindings.dubbingStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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.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
|
||||
|
||||
fun attachPortraitSections(root: LinearLayout) {
|
||||
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, enrolled) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
addView(TextView(activity).apply {
|
||||
text = "八哥口语"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 32f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
includeFontPadding = false
|
||||
})
|
||||
addView(TextView(activity).apply {
|
||||
text = "外语跟读训练神器"
|
||||
setTextColor(SampleTheme.textMuted)
|
||||
textSize = 20f
|
||||
setPadding(1.dp, 4.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user