Compare commits
4 Commits
f9f65988b7
...
cb87cf8cb1
| Author | SHA1 | Date | |
|---|---|---|---|
| cb87cf8cb1 | |||
| 1adba4d227 | |||
| 09fbe8a2de | |||
| b5934f21f5 |
@@ -19,7 +19,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.2"
|
||||
versionName = "0.1.3"
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
/* 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 */; };
|
||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||
@@ -33,6 +35,8 @@
|
||||
/* End PBXBuildFile 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; };
|
||||
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>"; };
|
||||
@@ -117,6 +121,7 @@
|
||||
children = (
|
||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||
@@ -139,6 +144,7 @@
|
||||
children = (
|
||||
AA000000000000000000002E /* Theme.swift */,
|
||||
AA000000000000000000002F /* RootView.swift */,
|
||||
AA0000000000000000000093 /* AuthView.swift */,
|
||||
AA0000000000000000000030 /* TrainingView.swift */,
|
||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||
AA0000000000000000000032 /* CatalogView.swift */,
|
||||
@@ -248,6 +254,8 @@
|
||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||
AA0000000000000000000053 /* TestView.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 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,
|
||||
baseURL: String,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
timeout: TimeInterval,
|
||||
additionalHeaders: [String: String] = [:]
|
||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||
var headers = ["Accept": "application/json"]
|
||||
headers.merge(additionalHeaders) { _, new in new }
|
||||
let result = try await execute(
|
||||
baseURL: baseURL,
|
||||
path: path,
|
||||
method: "GET",
|
||||
timeout: timeout,
|
||||
headers: ["Accept": "application/json"]
|
||||
headers: headers
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||||
|
||||
@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
var headers: [String: String] = [:]
|
||||
if !AppConfig.authToken.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
|
||||
}
|
||||
let result = try await HTTPClient.getJSON(
|
||||
SentenceBoundaryDocument.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||
timeout: AppConfig.readTimeout
|
||||
timeout: AppConfig.readTimeout,
|
||||
additionalHeaders: headers
|
||||
)
|
||||
guard result.value.videoHash.lowercased() == hash else {
|
||||
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
|
||||
|
||||
struct RootView: View {
|
||||
@StateObject private var auth = AuthManager()
|
||||
@StateObject private var controller = PlayerController()
|
||||
@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 showImporter = 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 {
|
||||
case course
|
||||
case train
|
||||
case test
|
||||
case mine
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if auth.isLoggedIn {
|
||||
mainContent
|
||||
} else {
|
||||
AuthView(auth: auth) {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
moduleTabs
|
||||
if activeModule == .train {
|
||||
switch activeModule {
|
||||
case .course:
|
||||
coursePage
|
||||
case .train:
|
||||
TrainingView(
|
||||
controller: controller,
|
||||
catalogVM: catalogVM,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
} else {
|
||||
case .test:
|
||||
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 {
|
||||
catalogVM.controller = controller
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
if auth.isLoggedIn && courses.isEmpty {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
.onChange(of: activeModule) { module in
|
||||
if module == .train {
|
||||
@@ -70,18 +109,24 @@ struct RootView: View {
|
||||
Text("八哥口语")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("外语跟读训练神器")
|
||||
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
Spacer()
|
||||
if activeModule == .train {
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
}
|
||||
headerButton("刷新") {
|
||||
Task {
|
||||
await catalogVM.load()
|
||||
}
|
||||
headerButton("刷新") {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
headerButton("退出") {
|
||||
auth.logout()
|
||||
currentCourse = nil
|
||||
courses = []
|
||||
activeModule = .course
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
@@ -105,30 +150,150 @@ struct RootView: View {
|
||||
|
||||
private var moduleTabs: some View {
|
||||
HStack(spacing: 0) {
|
||||
moduleTab("课程", module: .course)
|
||||
moduleTab("训练", module: .train)
|
||||
moduleTab("测试", module: .test)
|
||||
moduleTab("我的", module: .mine)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||
let selected = activeModule == module
|
||||
return Button {
|
||||
Button {
|
||||
if module != .course && currentCourse == nil {
|
||||
showCourseGuardToast = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showCourseGuardToast = false
|
||||
}
|
||||
} else {
|
||||
activeModule = module
|
||||
}
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
||||
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
||||
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.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() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||
updateSupportedInterfaceOrientations()
|
||||
|
||||
Reference in New Issue
Block a user