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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user