Compare commits
4 Commits
f9f65988b7
...
cb87cf8cb1
| Author | SHA1 | Date | |
|---|---|---|---|
| cb87cf8cb1 | |||
| 1adba4d227 | |||
| 09fbe8a2de | |||
| b5934f21f5 |
@@ -19,7 +19,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
|
||||||
|
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
|
||||||
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
|
||||||
|
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||||
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||||
|
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||||
@@ -139,6 +144,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AA000000000000000000002E /* Theme.swift */,
|
AA000000000000000000002E /* Theme.swift */,
|
||||||
AA000000000000000000002F /* RootView.swift */,
|
AA000000000000000000002F /* RootView.swift */,
|
||||||
|
AA0000000000000000000093 /* AuthView.swift */,
|
||||||
AA0000000000000000000030 /* TrainingView.swift */,
|
AA0000000000000000000030 /* TrainingView.swift */,
|
||||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||||
AA0000000000000000000032 /* CatalogView.swift */,
|
AA0000000000000000000032 /* CatalogView.swift */,
|
||||||
@@ -248,6 +254,8 @@
|
|||||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||||
|
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||||
|
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||||
AA0000000000000000000053 /* TestView.swift in Sources */,
|
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||||
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
@@ -17,4 +17,7 @@ enum AppConfig {
|
|||||||
static let assessmentTimeout: TimeInterval = 180
|
static let assessmentTimeout: TimeInterval = 180
|
||||||
|
|
||||||
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
||||||
|
|
||||||
|
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
|
||||||
|
static var authToken: String = ""
|
||||||
}
|
}
|
||||||
|
|||||||
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
struct AuthUser: Codable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthSession: Codable {
|
||||||
|
let token: String
|
||||||
|
let user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SsoExchangeResult: Codable {
|
||||||
|
let token: String
|
||||||
|
let user: AuthUser
|
||||||
|
let phoneBound: Bool
|
||||||
|
let isNewUser: Bool
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case token, user
|
||||||
|
case phoneBound = "phone_bound"
|
||||||
|
case isNewUser = "is_new_user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Course: Identifiable, Codable {
|
||||||
|
let videoHash: String
|
||||||
|
let title: String
|
||||||
|
let durationMs: Int64?
|
||||||
|
let language: String?
|
||||||
|
let sentenceCount: Int
|
||||||
|
let streamUrl: String
|
||||||
|
let enrolled: Bool
|
||||||
|
|
||||||
|
var id: String { videoHash }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case title, language, enrolled
|
||||||
|
case videoHash = "video_hash"
|
||||||
|
case durationMs = "duration_ms"
|
||||||
|
case sentenceCount = "sentence_count"
|
||||||
|
case streamUrl = "stream_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
func toVideoSummary() -> TrainingVideoSummary {
|
||||||
|
TrainingVideoSummary(
|
||||||
|
videoHash: videoHash,
|
||||||
|
title: title,
|
||||||
|
streamUrl: streamUrl,
|
||||||
|
durationMs: durationMs,
|
||||||
|
sizeBytes: 0,
|
||||||
|
language: language,
|
||||||
|
sentenceCount: sentenceCount,
|
||||||
|
status: "ready"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth Error
|
||||||
|
|
||||||
|
enum AuthError: LocalizedError {
|
||||||
|
case badStatus(Int, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badStatus(let code, let body):
|
||||||
|
return "HTTP \(code): \(body.prefix(200))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth API
|
||||||
|
|
||||||
|
enum AuthAPI {
|
||||||
|
private static var baseURL: String { AppConfig.serverBaseURL }
|
||||||
|
|
||||||
|
// MARK: Login / Register
|
||||||
|
|
||||||
|
static func login(username: String, password: String) async throws -> AuthSession {
|
||||||
|
try await authRequest(path: "/api/v1/auth/login", body: [
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func register(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
nickname: String,
|
||||||
|
phone: String
|
||||||
|
) async throws -> AuthSession {
|
||||||
|
try await authRequest(path: "/api/v1/auth/register", body: [
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"nickname": nickname,
|
||||||
|
"phone": phone,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SSO
|
||||||
|
|
||||||
|
static func ssoExchange(jwtToken: String) async throws -> SsoExchangeResult {
|
||||||
|
let url = URL(string: "\(baseURL)/api/v1/auth/sso/exchange")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["access_token": jwtToken])
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw AuthError.badStatus(status, body)
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(SsoExchangeResult.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ssoBindPhone(token: String, phone: String) async throws {
|
||||||
|
let url = URL(string: "\(baseURL)/api/v1/auth/sso/bind-phone")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["phone": phone])
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw AuthError.badStatus(status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Courses
|
||||||
|
|
||||||
|
static func courses(token: String) async throws -> [Course] {
|
||||||
|
let url = URL(string: "\(baseURL)/api/v1/courses")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw AuthError.badStatus(status, body)
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode([Course].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private static func authRequest(path: String, body: [String: Any]) async throws -> AuthSession {
|
||||||
|
let url = URL(string: "\(baseURL)\(path)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
throw AuthError.badStatus(status, body)
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(AuthSession.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,17 @@ enum HTTPClient {
|
|||||||
_ type: T.Type,
|
_ type: T.Type,
|
||||||
baseURL: String,
|
baseURL: String,
|
||||||
path: String,
|
path: String,
|
||||||
timeout: TimeInterval
|
timeout: TimeInterval,
|
||||||
|
additionalHeaders: [String: String] = [:]
|
||||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||||
|
var headers = ["Accept": "application/json"]
|
||||||
|
headers.merge(additionalHeaders) { _, new in new }
|
||||||
let result = try await execute(
|
let result = try await execute(
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
path: path,
|
path: path,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
headers: ["Accept": "application/json"]
|
headers: headers
|
||||||
)
|
)
|
||||||
guard (200...299).contains(result.statusCode) else {
|
guard (200...299).contains(result.statusCode) else {
|
||||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
|
|||||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||||
throw SentenceBoundaryAPIError.invalidHash
|
throw SentenceBoundaryAPIError.invalidHash
|
||||||
}
|
}
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
if !AppConfig.authToken.isEmpty {
|
||||||
|
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||||
|
}
|
||||||
let result = try await HTTPClient.getJSON(
|
let result = try await HTTPClient.getJSON(
|
||||||
SentenceBoundaryDocument.self,
|
SentenceBoundaryDocument.self,
|
||||||
baseURL: AppConfig.serverBaseURL,
|
baseURL: AppConfig.serverBaseURL,
|
||||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||||
timeout: AppConfig.readTimeout
|
timeout: AppConfig.readTimeout,
|
||||||
|
additionalHeaders: headers
|
||||||
)
|
)
|
||||||
guard result.value.videoHash.lowercased() == hash else {
|
guard result.value.videoHash.lowercased() == hash else {
|
||||||
throw SentenceBoundaryAPIError.hashMismatch
|
throw SentenceBoundaryAPIError.hashMismatch
|
||||||
|
|||||||
509
ios/OralTrainer/Views/AuthView.swift
Normal file
509
ios/OralTrainer/Views/AuthView.swift
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Auth Manager (shared state)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthManager: ObservableObject {
|
||||||
|
@Published var token: String
|
||||||
|
@Published var nickname: String
|
||||||
|
@Published var showingRegister = false
|
||||||
|
@Published var showingSSO = false
|
||||||
|
@Published var showingPhoneBinding = false
|
||||||
|
@Published var ssoPendingToken: String?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
token = defaults.string(forKey: "oral_trainer_token") ?? ""
|
||||||
|
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
|
||||||
|
AppConfig.authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoggedIn: Bool { !token.isEmpty }
|
||||||
|
|
||||||
|
func saveSession(_ session: AuthSession) {
|
||||||
|
token = session.token
|
||||||
|
nickname = session.user.nickname
|
||||||
|
AppConfig.authToken = session.token
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(session.token, forKey: "oral_trainer_token")
|
||||||
|
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
token = ""
|
||||||
|
nickname = ""
|
||||||
|
AppConfig.authToken = ""
|
||||||
|
showingRegister = false
|
||||||
|
showingSSO = false
|
||||||
|
showingPhoneBinding = false
|
||||||
|
ssoPendingToken = nil
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.removeObject(forKey: "oral_trainer_token")
|
||||||
|
defaults.removeObject(forKey: "oral_trainer_nickname")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth View (root auth screen)
|
||||||
|
|
||||||
|
struct AuthView: View {
|
||||||
|
@ObservedObject var auth: AuthManager
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var statusText = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
var onLoginSuccess: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
Text("八哥口语")
|
||||||
|
.font(.system(size: 32, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("请先注册或登录后开始学习")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
|
TextField("用户名", text: $username)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
SecureField("密码(至少 8 位)", text: $password)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(12)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await login() }
|
||||||
|
} label: {
|
||||||
|
Text(isLoading ? "登录中..." : "登录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Theme.accent)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.disabled(isLoading)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
auth.showingRegister = true
|
||||||
|
} label: {
|
||||||
|
Text("注册")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Text("── 或 ──")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
auth.showingSSO = true
|
||||||
|
} label: {
|
||||||
|
Text("第一课堂一键登录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Theme.buttonBlue)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
if !statusText.isEmpty {
|
||||||
|
Text(statusText)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.accentLight)
|
||||||
|
.padding(.top, 14)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.background(Theme.background.ignoresSafeArea())
|
||||||
|
.fullScreenCover(isPresented: $auth.showingRegister) {
|
||||||
|
RegisterView(auth: auth) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $auth.showingSSO) {
|
||||||
|
SSOWebView(auth: auth) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $auth.showingPhoneBinding) {
|
||||||
|
PhoneBindingView(auth: auth) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func login() async {
|
||||||
|
guard !username.isEmpty, !password.isEmpty else {
|
||||||
|
statusText = "请填写用户名和密码"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
statusText = "登录中..."
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
let session = try await AuthAPI.login(username: username, password: password)
|
||||||
|
auth.saveSession(session)
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch {
|
||||||
|
statusText = "失败:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Register View
|
||||||
|
|
||||||
|
struct RegisterView: View {
|
||||||
|
@ObservedObject var auth: AuthManager
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var nickname = ""
|
||||||
|
@State private var phone = ""
|
||||||
|
@State private var statusText = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
var onLoginSuccess: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("注册账号")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("填写以下信息完成注册")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
|
fieldLabel("用户名")
|
||||||
|
TextField("3-50 位字母、数字或下划线", text: $username)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
fieldLabel("密码")
|
||||||
|
SecureField("至少 8 位", text: $password)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
fieldLabel("姓名")
|
||||||
|
TextField("你的真实姓名或昵称", text: $nickname)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
fieldLabel("手机号")
|
||||||
|
TextField("选填", text: $phone)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await register() }
|
||||||
|
} label: {
|
||||||
|
Text(isLoading ? "注册中..." : "注册")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Theme.accent)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.disabled(isLoading)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("返回登录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
if !statusText.isEmpty {
|
||||||
|
Text(statusText)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.accentLight)
|
||||||
|
.padding(.top, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.top, 60)
|
||||||
|
}
|
||||||
|
.background(Theme.background.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func register() async {
|
||||||
|
guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return }
|
||||||
|
guard password.count >= 8 else { statusText = "密码至少 8 位"; return }
|
||||||
|
guard !nickname.isEmpty else { statusText = "请填写姓名"; return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
statusText = "注册中..."
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
let session = try await AuthAPI.register(
|
||||||
|
username: username, password: password, nickname: nickname, phone: phone
|
||||||
|
)
|
||||||
|
auth.saveSession(session)
|
||||||
|
dismiss()
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch {
|
||||||
|
statusText = "注册失败:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SSO WebView
|
||||||
|
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct SSOWebView: View {
|
||||||
|
@ObservedObject var auth: AuthManager
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var onLoginSuccess: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("取消登录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
SSOWebViewRepresentable()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
readJwtAndLogin()
|
||||||
|
} label: {
|
||||||
|
Text("已登录,点击继续")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Theme.accent)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.background(Theme.background.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readJwtAndLogin() {
|
||||||
|
// Read JWT from WKWebView's localStorage via JavaScript
|
||||||
|
SSOWebViewHolder.shared.readToken { jwt in
|
||||||
|
guard let jwt = jwt, !jwt.isEmpty, jwt != "null" else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Toast
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await exchangeJwt(jwt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exchangeJwt(_ jwt: String) async {
|
||||||
|
do {
|
||||||
|
let result = try await AuthAPI.ssoExchange(jwtToken: jwt)
|
||||||
|
await MainActor.run {
|
||||||
|
dismiss()
|
||||||
|
if !result.phoneBound {
|
||||||
|
auth.ssoPendingToken = result.token
|
||||||
|
auth.showingPhoneBinding = true
|
||||||
|
} else {
|
||||||
|
auth.saveSession(AuthSession(token: result.token, user: result.user))
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SSO WebView Representable
|
||||||
|
|
||||||
|
struct SSOWebViewRepresentable: UIViewRepresentable {
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = .default()
|
||||||
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
webView.navigationDelegate = context.coordinator
|
||||||
|
SSOWebViewHolder.shared.webView = webView
|
||||||
|
if let url = URL(string: "https://d1kt.cn/login") {
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Holder for WebView reference
|
||||||
|
|
||||||
|
final class SSOWebViewHolder {
|
||||||
|
static let shared = SSOWebViewHolder()
|
||||||
|
weak var webView: WKWebView?
|
||||||
|
|
||||||
|
func readToken(completion: @escaping (String?) -> Void) {
|
||||||
|
guard let webView = webView else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
"(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
|
||||||
|
) { result, _ in
|
||||||
|
let token = (result as? String) ?? ""
|
||||||
|
completion(token.isEmpty || token == "null" ? nil : token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phone Binding View
|
||||||
|
|
||||||
|
struct PhoneBindingView: View {
|
||||||
|
@ObservedObject var auth: AuthManager
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var phone = ""
|
||||||
|
@State private var statusText = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
var onLoginSuccess: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("绑定手机号")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("已通过第一课堂登录,请绑定手机号后继续")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
|
fieldLabel("手机号")
|
||||||
|
TextField("请输入 11 位手机号", text: $phone)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await bindPhone() }
|
||||||
|
} label: {
|
||||||
|
Text(isLoading ? "绑定中..." : "绑定并继续")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(Theme.accent)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.disabled(isLoading)
|
||||||
|
|
||||||
|
if !statusText.isEmpty {
|
||||||
|
Text(statusText)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.accentLight)
|
||||||
|
.padding(.top, 14)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.top, 60)
|
||||||
|
.background(Theme.background.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindPhone() async {
|
||||||
|
let cleaned = phone.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard cleaned.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else {
|
||||||
|
statusText = "请输入有效的 11 位手机号"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let token = auth.ssoPendingToken else {
|
||||||
|
statusText = "登录状态已过期,请重新登录"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
statusText = "绑定中..."
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
|
||||||
|
auth.showingPhoneBinding = false
|
||||||
|
auth.ssoPendingToken = nil
|
||||||
|
auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: "")))
|
||||||
|
dismiss()
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch {
|
||||||
|
statusText = "绑定失败:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,33 +3,69 @@ import UIKit
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@StateObject private var auth = AuthManager()
|
||||||
@StateObject private var controller = PlayerController()
|
@StateObject private var controller = PlayerController()
|
||||||
@StateObject private var catalogVM = CatalogViewModel()
|
@StateObject private var catalogVM = CatalogViewModel()
|
||||||
@State private var activeModule: Module = .train
|
@State private var activeModule: Module = .course
|
||||||
@State private var continuousPlayback = false
|
@State private var continuousPlayback = false
|
||||||
@State private var showImporter = false
|
@State private var showImporter = false
|
||||||
@State private var showFullscreenPlayer = false
|
@State private var showFullscreenPlayer = false
|
||||||
|
@State private var courses: [Course] = []
|
||||||
|
@State private var currentCourse: Course?
|
||||||
|
@State private var courseStatusText = "正在同步"
|
||||||
|
@State private var showCourseGuardToast = false
|
||||||
|
|
||||||
enum Module {
|
enum Module {
|
||||||
|
case course
|
||||||
case train
|
case train
|
||||||
case test
|
case test
|
||||||
|
case mine
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if auth.isLoggedIn {
|
||||||
|
mainContent
|
||||||
|
} else {
|
||||||
|
AuthView(auth: auth) {
|
||||||
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.background.ignoresSafeArea()
|
Theme.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
moduleTabs
|
moduleTabs
|
||||||
if activeModule == .train {
|
switch activeModule {
|
||||||
|
case .course:
|
||||||
|
coursePage
|
||||||
|
case .train:
|
||||||
TrainingView(
|
TrainingView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
catalogVM: catalogVM,
|
catalogVM: catalogVM,
|
||||||
continuousPlayback: $continuousPlayback,
|
continuousPlayback: $continuousPlayback,
|
||||||
onFullscreen: enterFullscreen
|
onFullscreen: enterFullscreen
|
||||||
)
|
)
|
||||||
} else {
|
case .test:
|
||||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
TestView(controller: controller, onFullscreen: enterFullscreen)
|
||||||
|
case .mine:
|
||||||
|
minePage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showCourseGuardToast {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Text("请先选择课程")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Theme.overlay)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Spacer().frame(height: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +73,9 @@ struct RootView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
catalogVM.controller = controller
|
catalogVM.controller = controller
|
||||||
controller.setContinuousPlayback(continuousPlayback)
|
controller.setContinuousPlayback(continuousPlayback)
|
||||||
|
if auth.isLoggedIn && courses.isEmpty {
|
||||||
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: activeModule) { module in
|
.onChange(of: activeModule) { module in
|
||||||
if module == .train {
|
if module == .train {
|
||||||
@@ -70,18 +109,24 @@ struct RootView: View {
|
|||||||
Text("八哥口语")
|
Text("八哥口语")
|
||||||
.font(.system(size: 32, weight: .bold))
|
.font(.system(size: 32, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Text("外语跟读训练神器")
|
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundColor(Theme.textMuted)
|
.foregroundColor(Theme.textMuted)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
headerButton("导入") {
|
if activeModule == .train {
|
||||||
showImporter = true
|
headerButton("导入") {
|
||||||
|
showImporter = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headerButton("刷新") {
|
headerButton("刷新") {
|
||||||
Task {
|
Task { await loadCourses() }
|
||||||
await catalogVM.load()
|
}
|
||||||
}
|
headerButton("退出") {
|
||||||
|
auth.logout()
|
||||||
|
currentCourse = nil
|
||||||
|
courses = []
|
||||||
|
activeModule = .course
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -105,30 +150,150 @@ struct RootView: View {
|
|||||||
|
|
||||||
private var moduleTabs: some View {
|
private var moduleTabs: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
moduleTab("课程", module: .course)
|
||||||
moduleTab("训练", module: .train)
|
moduleTab("训练", module: .train)
|
||||||
moduleTab("测试", module: .test)
|
moduleTab("测试", module: .test)
|
||||||
|
moduleTab("我的", module: .mine)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||||
let selected = activeModule == module
|
Button {
|
||||||
return Button {
|
if module != .course && currentCourse == nil {
|
||||||
activeModule = module
|
showCourseGuardToast = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
showCourseGuardToast = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeModule = module
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 38)
|
.frame(height: 38)
|
||||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Course Page
|
||||||
|
|
||||||
|
private var coursePage: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("课程首页")
|
||||||
|
.font(.system(size: 26, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
Text("选择一门课程开始训练")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
Text(courseStatusText)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
if courses.isEmpty {
|
||||||
|
Text("暂无课程")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: 10) {
|
||||||
|
ForEach(courses) { course in
|
||||||
|
courseCard(course)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func courseCard(_ course: Course) -> some View {
|
||||||
|
let isSelected = currentCourse?.videoHash == course.videoHash
|
||||||
|
return Button {
|
||||||
|
selectCourse(course)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(course.title)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(isSelected ? Theme.textDark : .white)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(isSelected ? Theme.selected : Theme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectCourse(_ course: Course) {
|
||||||
|
currentCourse = course
|
||||||
|
let video = course.toVideoSummary()
|
||||||
|
catalogVM.select(video)
|
||||||
|
activeModule = .train
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mine Page
|
||||||
|
|
||||||
|
private var minePage: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
|
||||||
|
.font(.system(size: 26, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if let course = currentCourse {
|
||||||
|
Text("当前课程:\(course.title)")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("此页面显示当前课程的测试记录和配音分享")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Theme.textMuted)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load Courses
|
||||||
|
|
||||||
|
private func loadCourses() async {
|
||||||
|
courseStatusText = "正在同步"
|
||||||
|
do {
|
||||||
|
courses = try await AuthAPI.courses(token: auth.token)
|
||||||
|
courseStatusText = "\(courses.count) 个课程"
|
||||||
|
} catch {
|
||||||
|
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fullscreen
|
||||||
|
|
||||||
private func enterFullscreen() {
|
private func enterFullscreen() {
|
||||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||||
updateSupportedInterfaceOrientations()
|
updateSupportedInterfaceOrientations()
|
||||||
|
|||||||
Reference in New Issue
Block a user