From cb87cf8cb179a9f45917ae8314df6affc5ec64a9 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 2 Sep 2026 18:54:08 +0800 Subject: [PATCH] optimized structures --- .../oraltrainer/sample/AppConfig.kt | 20 + .../oraltrainer/sample/AssessmentState.kt | 13 + .../oraltrainer/sample/AttemptRecorder.kt | 73 + .../oraltrainer/sample/AuthCoordinator.kt | 379 +++ .../oraltrainer/sample/AuthSessionStore.kt | 36 + .../sample/CourseCatalogController.kt | 126 + .../oraltrainer/sample/DubbingMerger.kt | 83 + .../oraltrainer/sample/DubbingPlayback.kt | 75 + .../oraltrainer/sample/DubbingShareApi.kt | 94 + .../oraltrainer/sample/DubbingState.kt | 19 + .../oraltrainer/sample/MainActivity.kt | 2610 +++-------------- .../oraltrainer/sample/PlaybackFormat.kt | 53 + .../oraltrainer/sample/SampleBindings.kt | 51 + .../oraltrainer/sample/SampleScreenActions.kt | 47 + .../oraltrainer/sample/SampleScreenFactory.kt | 91 + .../oraltrainer/sample/SampleTheme.kt | 23 + .../oraltrainer/sample/SdkModule.kt | 39 + .../sample/TestSessionController.kt | 353 +++ .../oraltrainer/sample/TrainingScreenState.kt | 29 + .../learningpad/oraltrainer/sample/UiStyle.kt | 35 + .../oraltrainer/sample/ui/CourseScreen.kt | 87 + .../oraltrainer/sample/ui/MineScreen.kt | 126 + .../oraltrainer/sample/ui/PlayerChrome.kt | 297 ++ .../oraltrainer/sample/ui/SampleWidgets.kt | 192 ++ .../oraltrainer/sample/ui/TestScreen.kt | 189 ++ .../oraltrainer/sample/ui/TrainScreen.kt | 131 + .../UserInterfaceState.xcuserstate | Bin 22578 -> 22578 bytes 27 files changed, 2988 insertions(+), 2283 deletions(-) create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AttemptRecorder.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthSessionStore.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingMerger.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/PlaybackFormat.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SdkModule.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/UiStyle.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/PlayerChrome.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TestScreen.kt create mode 100644 android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TrainScreen.kt diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt new file mode 100644 index 0000000..0bb7d55 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AppConfig.kt @@ -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}$") diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt new file mode 100644 index 0000000..dcd453c --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt @@ -0,0 +1,13 @@ +package cn.learningpad.oraltrainer.sample + +import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult + +internal class AssessmentState { + private val scores = mutableMapOf() + + fun store(sentenceIndex: Int, result: ImitationAssessmentResult) { + scores[sentenceIndex] = result + } + + fun toMap(): Map = scores.toMap() +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AttemptRecorder.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AttemptRecorder.kt new file mode 100644 index 0000000..e18a26e --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AttemptRecorder.kt @@ -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() + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt new file mode 100644 index 0000000..5901016 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt @@ -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 ''; } })()" + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthSessionStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthSessionStore.kt new file mode 100644 index 0000000..1931d33 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthSessionStore.kt @@ -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" + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt new file mode 100644 index 0000000..197ca38 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt @@ -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) + } + }) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingMerger.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingMerger.kt new file mode 100644 index 0000000..c0c6b46 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingMerger.kt @@ -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>): 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() } + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt new file mode 100644 index 0000000..43154b6 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt @@ -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() + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt new file mode 100644 index 0000000..632c215 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt @@ -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, + scores: Map, + ): 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) + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt new file mode 100644 index 0000000..e1f7fb8 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt @@ -0,0 +1,19 @@ +package cn.learningpad.oraltrainer.sample + +import java.io.File + +internal class DubbingState { + val segments = mutableMapOf() + 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 + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 8569226..d0b3783 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -1,206 +1,115 @@ package cn.learningpad.oraltrainer.sample -import android.Manifest import android.app.Activity import android.content.BroadcastReceiver import android.content.Context -import android.content.pm.ActivityInfo import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager -import android.content.res.ColorStateList import android.content.res.Configuration -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.media.MediaCodec -import android.media.MediaFormat -import android.media.MediaExtractor -import android.media.MediaMuxer -import android.media.MediaRecorder -import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Handler -import android.os.Looper -import android.os.StrictMode -import android.os.Build import android.os.Bundle -import android.util.Log -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.Button -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.HorizontalScrollView -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.SeekBar -import android.widget.Switch -import android.widget.TextView -import android.widget.Toast +import cn.learningpad.oraltrainer.sdk.AuthSession import cn.learningpad.oraltrainer.sdk.GestureEvent import cn.learningpad.oraltrainer.sdk.GestureKind -import cn.learningpad.oraltrainer.sdk.AuthSession -import cn.learningpad.oraltrainer.sdk.AuthUser -import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback -import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult -import cn.learningpad.oraltrainer.sdk.LoopMode import cn.learningpad.oraltrainer.sdk.OralTrainerController import cn.learningpad.oraltrainer.sdk.OralTrainerListener -import cn.learningpad.oraltrainer.sdk.OralTrainerPlayerView import cn.learningpad.oraltrainer.sdk.OralTrainerSdk -import cn.learningpad.oraltrainer.sdk.OralTrainerSdkConfig import cn.learningpad.oraltrainer.sdk.PlaybackSnapshot -import cn.learningpad.oraltrainer.sdk.PlayerConfig import cn.learningpad.oraltrainer.sdk.SentenceBoundary import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.TrainingMediaItem import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary -import cn.learningpad.oraltrainer.sdk.VideoCatalogCallback -import java.io.File -import java.io.DataOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.nio.ByteBuffer -import java.util.Locale -import org.json.JSONObject -import android.webkit.WebView -import android.webkit.WebViewClient -import android.webkit.WebResourceRequest -import androidx.core.content.FileProvider -import kotlin.math.max -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer class MainActivity : Activity() { private lateinit var sdk: OralTrainerSdk private lateinit var controller: OralTrainerController private lateinit var controllerListener: OralTrainerListener - private lateinit var playerView: OralTrainerPlayerView - private lateinit var lessonTitleText: TextView - private lateinit var statusText: TextView - private lateinit var sentenceText: TextView - private lateinit var sentenceMetaText: TextView - private lateinit var timeText: TextView - private lateinit var speedText: TextView - private lateinit var progressBar: SeekBar - private var progressBarDragging = false - private lateinit var catalogList: LinearLayout - private lateinit var catalogStatusText: TextView - private lateinit var testStatusText: TextView - private lateinit var scoreSummaryText: TextView - private lateinit var scoreDetailText: TextView - private lateinit var recordButton: Button - private lateinit var playDubButton: Button - private lateinit var mergeDubButton: Button - private lateinit var shareDubButton: Button - private var dubPlayer: ExoPlayer? = null - private var dubPlayerListener: Player.Listener? = null - private var dubPlaybackGeneration = 0 - private val mainHandler = Handler(Looper.getMainLooper()) - private var mediaRecorder: MediaRecorder? = null - private var recordingFile: File? = null - private val dubSegments = mutableMapOf() - private val latestScores = mutableMapOf() - private var mergedDubFile: File? = null - private var dubbingStatusText: TextView? = null - private var dubbingPlayback = false - private var activeItemId: String = "" - private var currentSentenceCount = 0 - private var authToken: String = "" - private var authNickname: String = "" - private var authUsernameInput: EditText? = null - private var authPasswordInput: EditText? = null - private var authStatusText: TextView? = null - private var regUsernameInput: EditText? = null - private var regPasswordInput: EditText? = null - private var regNicknameInput: EditText? = null - private var regPhoneInput: EditText? = null - private var regStatusText: TextView? = null - private var showingRegisterPage = false - private var showingSsoWebView = false - private var showingPhoneBinding = false - private var ssoPendingToken: String? = null - private var ssoWebView: WebView? = null - private var ssoPhase = 0 // 0=login page, 1=authorize, 2=done - private var myContentList: LinearLayout? = null - private var sentenceBoundaryRequestGeneration = 0 - private var catalogVideos: List = emptyList() - private val courseEnrollment = mutableMapOf() - private var catalogStatusTextValue = "正在同步" - private var activeModule = Module.TRAIN - private var currentCourse: TrainingVideoSummary? = null - private var continuousPlaybackEnabled = false - private var testSubtitleVisible = false - private var landscapeSubtitleVisible = true + private val bindings = SampleBindings() + private val screenState = TrainingScreenState() + private val dubbingState = DubbingState() + private val assessmentState = AssessmentState() + private val dubbingShareApi = DubbingShareApi() + + private lateinit var attemptRecorder: AttemptRecorder + private lateinit var dubbingMerger: DubbingMerger + private lateinit var dubbingPlaybackController: DubbingPlayback + private lateinit var authSessionStore: AuthSessionStore + private lateinit var authCoordinator: AuthCoordinator + private lateinit var screenFactory: SampleScreenFactory + private lateinit var catalogController: CourseCatalogController + private lateinit var testSession: TestSessionController + + private var progressBarDragging = false private val screenActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_SCREEN_OFF) { - if (dubbingPlayback) { - stopDubPlayback("已停止配音播放") - } else { - controller.pause() - } + testSession.onScreenOff() } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + attemptRecorder = AttemptRecorder(this) + dubbingMerger = DubbingMerger(cacheDir) + dubbingPlaybackController = DubbingPlayback(this) configureWindow() - val authPreferences = getSharedPreferences("oral_trainer_auth", MODE_PRIVATE) - authToken = authPreferences.getString("token", "").orEmpty() - authNickname = authPreferences.getString("nickname", "").orEmpty() + authSessionStore = AuthSessionStore(this) + val sdkComponents = SdkModule.create(this, authSessionStore.session?.token) + sdk = sdkComponents.sdk + controller = sdkComponents.controller + authCoordinator = AuthCoordinator(this, sdk.userApi, ::saveAuthSession) - sdk = OralTrainerSdk.init( - this, - OralTrainerSdkConfig( - sentenceBoundaryApiBaseUrl = SERVER_BASE_URL, - assessmentApiKey = ASSESSMENT_API_KEY, - userToken = authToken.takeIf { it.isNotBlank() }, - allowHttpFallback = true, - ) + screenFactory = SampleScreenFactory( + activity = this, + bindings = bindings, + screenState = screenState, + controller = controller, + actions = screenActions(), ) - controller = sdk.createController( - playerConfig = PlayerConfig( - sentenceMode = true, - defaultSeekStepMs = 10_000L, - autoPlay = false, - continuousPlayback = false, - ), - imitationAssessor = sdk.createRemoteImitationQualityAssessor(), + catalogController = CourseCatalogController( + sdk = sdk, + controller = controller, + screenState = screenState, + tokenProvider = { authSessionStore.session?.token.orEmpty() }, + host = catalogHost(), ) - controller.setLoopMode(LoopMode.ALL) + testSession = TestSessionController( + activity = this, + controller = controller, + screenState = screenState, + attemptRecorder = attemptRecorder, + dubbingState = dubbingState, + dubbingMerger = dubbingMerger, + dubbingShareApi = dubbingShareApi, + dubbingPlayback = dubbingPlaybackController, + assessmentState = assessmentState, + host = testHost(), + ) + controllerListener = createControllerListener() controller.addListener(controllerListener) setRootView() - loadCatalog() - if (isLoggedIn()) refreshCurrentUi() - - registerReceiver( - screenActionReceiver, - IntentFilter(Intent.ACTION_SCREEN_OFF), - ) + if (isLoggedIn()) { + catalogController.loadCatalog() + refreshCurrentUi() + } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) setRootView() - renderCatalog() + if (!isLoggedIn()) return + screenFactory.renderCatalog() refreshCurrentUi() - if (activeModule == Module.TEST) { - refreshTestUi() + if (screenState.activeModule == Module.TEST) { + testSession.refreshTestUi() } } @@ -210,58 +119,54 @@ class MainActivity : Activity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode != RECORD_AUDIO_REQUEST) { - return - } + if (requestCode != RECORD_AUDIO_REQUEST) return if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { - startRecording() - } else if (::testStatusText.isInitialized) { - testStatusText.text = "需要麦克风权限才能进行录音评测" + testSession.startRecording() + } else if (bindings.hasTestChrome) { + bindings.testStatusText.text = "需要麦克风权限才能录音评测" } } override fun onPause() { - super.onPause() - if (dubbingPlayback) { - stopDubPlayback("已停止配音播放") + if (testSession.isDubbingActive) { + testSession.stopDubbingIfNeeded() + testSession.refreshTestUi() + if (bindings.hasPlayerChrome) { + bindings.statusText.text = "已停止配音播放" + } } else { controller.pause() } + super.onPause() } - override fun onResume() { - super.onResume() - registerReceiver( - screenActionReceiver, - IntentFilter(Intent.ACTION_SCREEN_OFF), - ) + override fun onStart() { + super.onStart() + registerReceiver(screenActionReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) } override fun onStop() { - runCatching { unregisterReceiver(screenActionReceiver) } + unregisterReceiver(screenActionReceiver) super.onStop() } @Deprecated("Deprecated in Java") override fun onBackPressed() { if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } else { - moveTaskToBack(true) + requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + return } + @Suppress("DEPRECATION") + super.onBackPressed() } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - if (requestCode != PICK_VIDEO_REQUEST || resultCode != RESULT_OK) { - return - } + if (requestCode != PICK_VIDEO_REQUEST || resultCode != RESULT_OK) return val uri = data?.data ?: return val persistableFlags = data.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION if (persistableFlags != 0) { - runCatching { - contentResolver.takePersistableUriPermission(uri, persistableFlags) - } + runCatching { contentResolver.takePersistableUriPermission(uri, persistableFlags) } } val item = TrainingMediaItem( @@ -269,2063 +174,88 @@ class MainActivity : Activity() { title = uri.lastPathSegment ?: "本地视频", uri = uri, ) - activeItemId = item.id - currentSentenceCount = 0 + screenState.activeItemId = item.id + screenState.currentSentenceCount = 0 controller.loadItem(item) - lessonTitleText.text = item.title - sentenceMetaText.text = "句子边界分析中" - sentenceText.text = "正在匹配服务端句子边界" - statusText.text = "正在获取本地视频的句子边界..." - renderCatalog() + if (bindings.hasPlayerChrome) { + bindings.lessonTitleText.text = item.title + bindings.sentenceMetaText.text = "句子边界分析中" + bindings.sentenceText.text = "正在匹配服务端句子边界" + bindings.statusText.text = "正在获取本地视频的句子边界..." + } + screenFactory.renderCatalog() sdk.sentenceBoundaryApi.fetchForUri(uri, contentResolver, object : SentenceBoundaryApiCallback { override fun onSuccess(result: SentenceBoundaryApiResult) { - if (activeItemId != item.id) { - return - } - currentSentenceCount = result.sentences.size + if (screenState.activeItemId != item.id) return + screenState.currentSentenceCount = result.sentences.size controller.loadItem(item.copy(sentences = result.sentences)) - statusText.text = "句子边界已加载:${result.sentences.size} 句" + if (bindings.hasPlayerChrome) { + bindings.statusText.text = "句子边界已加载:${result.sentences.size} 句" + } } override fun onError(error: Throwable) { - if (activeItemId != item.id) { - return + if (screenState.activeItemId != item.id) return + if (bindings.hasPlayerChrome) { + bindings.statusText.text = + "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}" + bindings.sentenceMetaText.text = "暂无句子边界" + bindings.sentenceText.text = item.title } - statusText.text = "句子边界获取失败,使用默认快退/快进:${error.message.orEmpty()}" - sentenceMetaText.text = "暂无句子边界" - sentenceText.text = item.title } }) } override fun onDestroy() { - releaseDubbingResources() + authCoordinator.reset() + testSession.release() controller.release() super.onDestroy() } @Suppress("DEPRECATION") private fun configureWindow() { - window.statusBarColor = COLOR_BACKGROUND - window.navigationBarColor = COLOR_BACKGROUND - } - - private fun createContentView(): View { - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - if (activeModule == Module.COURSE) { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_BACKGROUND) - addView(createModuleTabBar()) - addView(createCourseSection()) - } - } - if (activeModule == Module.MINE) { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_BACKGROUND) - addView(createModuleTabBar()) - addView(createMySection()) - } - } - return if (isLandscape) { - createLandscapeContentView() - } else { - createPortraitContentView() - } + window.statusBarColor = SampleTheme.background + window.navigationBarColor = SampleTheme.background } private fun setRootView() { - setContentView( - when { - isLoggedIn() -> createContentView() - showingSsoWebView -> createSsoWebView() - showingPhoneBinding -> createPhoneBindingView() - showingRegisterPage -> createRegisterView() - else -> createAuthView() - } - ) + setContentView(if (isLoggedIn()) screenFactory.createContentView() else authCoordinator.createView()) if (isLoggedIn()) { - renderCatalog() + screenFactory.renderCatalog() refreshCurrentUi() - } - } - - private fun isLoggedIn(): Boolean = authToken.isNotBlank() - - private fun createAuthView(): View { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setBackgroundColor(COLOR_BACKGROUND) - setPadding(28.dp, 28.dp, 28.dp, 28.dp) - - addView(TextView(this@MainActivity).apply { - text = "八哥口语" - textSize = 32f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - }) - addView(TextView(this@MainActivity).apply { - text = "请先注册或登录后开始学习" - textSize = 15f - setTextColor(COLOR_TEXT_MUTED) - setPadding(0, 6.dp, 0, 24.dp) - }) - - authUsernameInput = EditText(this@MainActivity).apply { - hint = "用户名" - setSingleLine(true) + if (screenState.activeModule == Module.MINE) { + screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty()) } - authPasswordInput = EditText(this@MainActivity).apply { - hint = "密码(至少 8 位)" - inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD - setSingleLine(true) + if (screenState.activeModule == Module.TEST) { + testSession.refreshTestUi() } - authStatusText = TextView(this@MainActivity).apply { - setTextColor(COLOR_ACCENT_LIGHT) - textSize = 13f - setPadding(0, 14.dp, 0, 0) - } - - addView(authUsernameInput) - addView(authPasswordInput) - addView(Button(this@MainActivity).apply { - text = "登录" - background = rounded(COLOR_ACCENT, 8f) - setTextColor(Color.WHITE) - setOnClickListener { authenticate(register = false) } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 20.dp, 0, 8.dp) - }) - addView(Button(this@MainActivity).apply { - text = "注册" - setOnClickListener { - showingRegisterPage = true - setRootView() - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) - }) - addView(TextView(this@MainActivity).apply { - text = "── 或 ──" - textSize = 13f - setTextColor(COLOR_TEXT_MUTED) - gravity = Gravity.CENTER - setPadding(0, 12.dp, 0, 12.dp) - }) - addView(Button(this@MainActivity).apply { - text = "第一课堂一键登录" - background = rounded(COLOR_BUTTON, 8f) - setTextColor(Color.WHITE) - setOnClickListener { - showingSsoWebView = true - setRootView() - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) - }) - addView(authStatusText) } } - private fun createRegisterView(): View { - return ScrollView(this).apply { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - setBackgroundColor(COLOR_BACKGROUND) - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(28.dp, 60.dp, 28.dp, 28.dp) - - addView(TextView(this@MainActivity).apply { - text = "注册账号" - textSize = 28f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - }) - addView(TextView(this@MainActivity).apply { - text = "填写以下信息完成注册" - textSize = 14f - setTextColor(COLOR_TEXT_MUTED) - setPadding(0, 6.dp, 0, 24.dp) - }) - - addView(fieldLabel("用户名")) - regUsernameInput = EditText(this@MainActivity).apply { - hint = "3-50 位字母、数字或下划线" - setSingleLine(true) - } - addView(regUsernameInput) - - addView(fieldLabel("密码")) - regPasswordInput = EditText(this@MainActivity).apply { - hint = "至少 8 位" - inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD - setSingleLine(true) - } - addView(regPasswordInput) - - addView(fieldLabel("姓名")) - regNicknameInput = EditText(this@MainActivity).apply { - hint = "你的真实姓名或昵称" - setSingleLine(true) - } - addView(regNicknameInput) - - addView(fieldLabel("手机号")) - regPhoneInput = EditText(this@MainActivity).apply { - hint = "选填" - inputType = android.text.InputType.TYPE_CLASS_PHONE - setSingleLine(true) - } - addView(regPhoneInput) - - regStatusText = TextView(this@MainActivity).apply { - setTextColor(COLOR_ACCENT_LIGHT) - textSize = 13f - setPadding(0, 14.dp, 0, 0) - } - - addView(Button(this@MainActivity).apply { - text = "注册" - background = rounded(COLOR_ACCENT, 8f) - setTextColor(Color.WHITE) - setOnClickListener { submitRegistration() } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 8.dp) - }) - addView(Button(this@MainActivity).apply { - text = "返回登录" - setOnClickListener { - showingRegisterPage = false - regStatusText = null - setRootView() - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) - }) - addView(regStatusText) - }) - } - } - - private fun fieldLabel(label: String): TextView { - return TextView(this).apply { - text = label - textSize = 13f - setTextColor(COLOR_TEXT_MUTED) - setPadding(0, 12.dp, 0, 4.dp) - } - } - - private fun submitRegistration() { - val username = regUsernameInput?.text?.toString()?.trim().orEmpty() - val password = regPasswordInput?.text?.toString().orEmpty() - val nickname = regNicknameInput?.text?.toString()?.trim().orEmpty() - val phone = regPhoneInput?.text?.toString()?.trim().orEmpty() - - if (username.length < 3) { - regStatusText?.text = "用户名至少 3 个字符" - return - } - if (password.length < 8) { - regStatusText?.text = "密码至少 8 位" - return - } - if (nickname.isBlank()) { - regStatusText?.text = "请填写姓名" - return - } - - regStatusText?.text = "注册中..." - sdk.userApi.register(username, password, nickname, phone) { result -> - result.fold( - onSuccess = { session -> - showingRegisterPage = false - saveAuthSession(session) - }, - onFailure = { error -> - regStatusText?.text = "注册失败:${error.message}" - }, - ) - } - } - - private fun authenticate(register: Boolean) { - val username = authUsernameInput?.text?.toString()?.trim().orEmpty() - val password = authPasswordInput?.text?.toString().orEmpty() - authStatusText?.text = if (register) "注册中..." else "登录中..." - val callback = { result: Result -> - result.fold( - onSuccess = ::saveAuthSession, - onFailure = { error -> authStatusText?.text = "失败:${error.message}" }, - ) - } - if (register) sdk.userApi.register(username, password, onComplete = callback) - else sdk.userApi.login(username, password, callback) - } - - // ─── 第一课堂 SSO ──────────────────────────────────────────── - - @Suppress("SetJavaScriptEnabled") - private fun createSsoWebView(): View { - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_BACKGROUND) - } - - // ── 顶部:取消按钮 ── - container.addView(Button(this).apply { - text = "取消登录" - setOnClickListener { - showingSsoWebView = false - ssoWebView = null - setRootView() - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp) - }) - - // ── 中间:WebView ── - ssoWebView = WebView(this).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - loadUrl(SSO_LOGIN_URL) - } - container.addView(ssoWebView, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f - )) - - // ── 底部:登录完成后点继续,读 JWT 发给后端 ── - container.addView(Button(this).apply { - text = "已登录,点击继续" - background = rounded(COLOR_ACCENT, 8f) - setTextColor(Color.WHITE) - setOnClickListener { - readJwtAndLogin() - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(8.dp, 8.dp, 8.dp, 8.dp) - }) - return container - } - - private fun readJwtAndLogin() { - ssoWebView?.evaluateJavascript( - "(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()" - ) { result -> - val jwt = result?.removeSurrounding("\"").orEmpty() - if (jwt.isBlank() || jwt == "null") { - runOnUiThread { - android.widget.Toast.makeText(this, "未找到登录凭证,请先在页面中登录", android.widget.Toast.LENGTH_SHORT).show() - } - return@evaluateJavascript - } - sendJwtToBackend(jwt) - } - } - - private fun sendJwtToBackend(jwt: String) { - sdk.userApi.ssoExchange(jwt) { result -> - result.fold( - onSuccess = { ssoResult -> - runOnUiThread { - showingSsoWebView = false - ssoWebView = null - ssoPendingToken = ssoResult.token - if (!ssoResult.phoneBound) { - showingPhoneBinding = true - setRootView() - } else { - saveAuthSession(AuthSession(ssoResult.token, ssoResult.user)) - } - } - }, - onFailure = { error -> - runOnUiThread { - showingSsoWebView = false - ssoWebView = null - setRootView() - authStatusText?.text = "SSO 登录失败:${error.message}" - } - }, - ) - } - } - - private fun createPhoneBindingView(): View { - return ScrollView(this).apply { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - setBackgroundColor(COLOR_BACKGROUND) - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(28.dp, 60.dp, 28.dp, 28.dp) - - addView(TextView(this@MainActivity).apply { - text = "绑定手机号" - textSize = 28f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - }) - addView(TextView(this@MainActivity).apply { - text = "已通过第一课堂登录,请绑定手机号后继续" - textSize = 14f - setTextColor(COLOR_TEXT_MUTED) - setPadding(0, 6.dp, 0, 24.dp) - }) - - addView(fieldLabel("手机号")) - val phoneInput = EditText(this@MainActivity).apply { - hint = "请输入 11 位手机号" - inputType = android.text.InputType.TYPE_CLASS_PHONE - setSingleLine(true) - } - addView(phoneInput) - - val bindStatusText = TextView(this@MainActivity).apply { - setTextColor(COLOR_ACCENT_LIGHT) - textSize = 13f - setPadding(0, 14.dp, 0, 0) - } - - addView(Button(this@MainActivity).apply { - text = "绑定并继续" - background = rounded(COLOR_ACCENT, 8f) - setTextColor(Color.WHITE) - setOnClickListener { - val phone = phoneInput.text?.toString()?.trim().orEmpty() - if (!Regex("^1[3-9]\\d{9}$").matches(phone)) { - bindStatusText.text = "请输入有效的 11 位手机号" - return@setOnClickListener - } - val token = ssoPendingToken - if (token.isNullOrBlank()) { - bindStatusText.text = "登录状态已过期,请重新登录" - return@setOnClickListener - } - bindStatusText.text = "绑定中..." - sdk.userApi.ssoBindPhone(token, phone) { result -> - result.fold( - onSuccess = { - showingPhoneBinding = false - ssoPendingToken = null - saveAuthSession(AuthSession(token, AuthUser("", "", ""))) - setRootView() - loadCatalog() - }, - onFailure = { error -> - bindStatusText.text = "绑定失败:${error.message}" - }, - ) - } - } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 0) - }) - addView(bindStatusText) - }) - } - } + private fun isLoggedIn(): Boolean = authSessionStore.isLoggedIn private fun saveAuthSession(session: AuthSession) { - authToken = session.token - authNickname = session.user.nickname + authSessionStore.session = session sdk.config.userToken = session.token - getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit() - .putString("token", session.token) - .putString("nickname", session.user.nickname) - .apply() - activeModule = Module.COURSE + screenState.activeModule = Module.COURSE setRootView() - loadCatalog() + catalogController.loadCatalog() } private fun logout() { - getSharedPreferences("oral_trainer_auth", MODE_PRIVATE).edit().clear().apply() - authToken = "" - authNickname = "" + authCoordinator.reset() + authSessionStore.session = null sdk.config.userToken = null - currentCourse = null + screenState.clearCourse() controller.pause() - activeModule = Module.TRAIN + screenState.activeModule = Module.TRAIN setRootView() } - private fun createPortraitContentView(): View { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_BACKGROUND) - addView(createModuleTabBar()) - if (activeModule == Module.TEST) { - addView(createTestContentView()) - } else { - addView(createHeader()) - addView(createPlayerSection()) - addView(createSentenceSection()) - addView(createCatalogSection()) - } - } - } - - private fun createTestContentView(): View { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(COLOR_BACKGROUND) - if (testSubtitleVisible) { - addView(createPlayerSection()) - addView(createSentenceSection()) - } else { - addView(createPlayerSection()) - } - addView(createTestSection()) - } - } - - private fun createModuleTabBar(): View { - return LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(16.dp, 40.dp, 16.dp, 0) - addView(moduleTab("课程", Module.COURSE)) - addView(moduleTab("训练", Module.TRAIN)) - addView(moduleTab("测试", Module.TEST)) - addView(moduleTab("我的", Module.MINE)) - } - } - - private fun moduleTab(label: String, module: Module): TextView { - val selected = activeModule == module - return TextView(this).apply { - text = label - textSize = 15f - typeface = Typeface.DEFAULT_BOLD - gravity = Gravity.CENTER - isClickable = true - setTextColor(if (selected) Color.WHITE else COLOR_TEXT_MUTED) - background = if (selected) { - rounded(COLOR_BUTTON, 8f) - } else { - rounded(COLOR_SURFACE, 8f, COLOR_BORDER) - } - layoutParams = LinearLayout.LayoutParams(0, 44.dp, 1f).withMargins(0, 0, 8.dp, 0) - setOnClickListener { - if (activeModule == module) { - return@setOnClickListener - } - if (module != Module.COURSE && currentCourse == null) { - Toast.makeText(this@MainActivity, "请先选择课程", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - if (module == Module.COURSE || module == Module.MINE) { - loadUserData() - } - activeModule = module - controller.setContinuousPlayback( - if (activeModule == Module.TRAIN) continuousPlaybackEnabled else false - ) - landscapeSubtitleVisible = activeModule == Module.TRAIN || testSubtitleVisible - setContentView(createContentView()) - renderCatalog() - refreshCurrentUi() - if (activeModule == Module.TEST) { - refreshTestUi() - } - } - } - } - - private fun createLandscapeContentView(): View { - return FrameLayout(this).apply { - setBackgroundColor(Color.BLACK) - addView(createPlayerView()) - addView(createPlayerOverlay()) - val subtitleContainer = LinearLayout(this@MainActivity).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) - } - } - - private fun installLandscapeSwipeGesture(subtitleContainer: View) { - playerView.setVerticalSwipeListener { isSwipeUp -> - if (isSwipeUp == landscapeSubtitleVisible) { - return@setVerticalSwipeListener - } - landscapeSubtitleVisible = isSwipeUp - applyLandscapeSubtitleState(subtitleContainer, animate = true) - } - } - - private fun applyLandscapeSubtitleState(container: View, animate: Boolean) { - if (!animate || container.visibility == View.GONE) { - container.clearAnimation() - if (landscapeSubtitleVisible) { - container.visibility = View.VISIBLE - } else { - container.visibility = View.GONE - } - return - } - if (landscapeSubtitleVisible) { - container.visibility = View.VISIBLE - container.post { - if (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 = true - setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) = Unit - override fun onAnimationRepeat(animation: Animation?) = Unit - override fun onAnimationEnd(animation: Animation?) { - view.post(onEnd) - } - }) - } - view.startAnimation(animation) - } - - private fun createLandscapeSentencePanel(): View { - return createSentenceContent(translucent = true).apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ) - } - } - - private fun createHeader(): View { - val titleBlock = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_VERTICAL - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - addView(TextView(this@MainActivity).apply { - text = "八哥口语" - setTextColor(Color.WHITE) - textSize = 32f - typeface = Typeface.DEFAULT_BOLD - includeFontPadding = false - }) - addView(TextView(this@MainActivity).apply { - text = "外语跟读训练神器" - setTextColor(COLOR_TEXT_MUTED) - textSize = 20f - setPadding(1.dp, 4.dp, 0, 0) - }) - } - - return LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(18.dp, 16.dp, 18.dp, 12.dp) - addView(titleBlock) - addView(headerButton("导入") { openLocalVideoPicker() }) - addView(headerButton("刷新") { loadCatalog() }) - } - } - - private fun createPlayerSection(): View { - return FrameLayout(this).apply { - background = rounded(COLOR_SURFACE, 8f, COLOR_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 - } - }) - } - } - - private fun createPlayerView(): OralTrainerPlayerView { - return OralTrainerPlayerView(this).apply { - bind(controller) - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - }.also { playerView = it } - } - - private fun createPlayerOverlay(): View { - val overlay = LinearLayout(this).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, - ) - } - timeText = overlayPill("--:-- / --:--").apply { - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - } - speedText = overlayPill("1x") - overlay.addView(timeText) - overlay.addView(speedText) - return overlay - } - - private fun createRotationButton(): ImageButton { - return ImageButton(this).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 { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } - } - - private fun createSentenceSection(): View { - return createSentenceContent(translucent = false).apply { - background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER) - 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) - } - } - - private fun createSentenceContent(translucent: Boolean): LinearLayout { - lessonTitleText = TextView(this).apply { - setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK) - textSize = 17f - typeface = Typeface.DEFAULT_BOLD - maxLines = 1 - } - sentenceMetaText = TextView(this).apply { - setTextColor(if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT_DEEP) - textSize = 13f - setPadding(0, 8.dp, 0, 0) - } - sentenceText = TextView(this).apply { - setTextColor(if (translucent) Color.WHITE else COLOR_TEXT_DARK) - textSize = 22f - typeface = Typeface.DEFAULT_BOLD - setLineSpacing(2.dp.toFloat(), 1.05f) - setPadding(0, 10.dp, 0, 10.dp) - } - progressBar = SeekBar(this).apply { - max = PROGRESS_MAX - progress = 0 - progressTintList = ColorStateList.valueOf(COLOR_ACCENT) - progressBackgroundTintList = ColorStateList.valueOf( - if (translucent) COLOR_PROGRESS_TRACK_DARK else COLOR_PROGRESS_TRACK - ) - thumbTintList = ColorStateList.valueOf(COLOR_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) { - val duration = controller.snapshot().durationMs - timeText.text = "${formatTime(seekPositionForProgress(progress))} / ${formatTime(duration)}" - } - } - - override fun onStartTrackingTouch(seekBar: SeekBar) { - progressBarDragging = true - } - - override fun onStopTrackingTouch(seekBar: SeekBar) { - progressBarDragging = false - controller.seekTo(seekPositionForProgress(seekBar.progress)) - } - }) - } - statusText = TextView(this).apply { - setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE) - textSize = 13f - setPadding(0, 10.dp, 0, 0) - } - - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - if (translucent) { - background = rounded(COLOR_OVERLAY_FAINT, 0f, null) - setPadding(18.dp, 14.dp, 18.dp, 16.dp) - } - addView(lessonTitleText) - if (activeModule == Module.TRAIN) { - addView(createContinuousPlaybackRow(translucent)) - } - addView(sentenceMetaText) - addView(sentenceText) - addView(progressBar) - addView(statusText) - } - } - - private fun createContinuousPlaybackRow(translucent: Boolean): View { - return LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, 6.dp, 0, 0) - addView(TextView(this@MainActivity).apply { - text = "连续播放" - setTextColor(if (translucent) COLOR_TEXT_OVERLAY else COLOR_TEXT_SUBTLE) - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - }) - addView(Switch(this@MainActivity).apply { - isChecked = continuousPlaybackEnabled - buttonTintList = ColorStateList.valueOf( - if (translucent) COLOR_ACCENT_LIGHT else COLOR_ACCENT - ) - setOnCheckedChangeListener { _, checked -> - continuousPlaybackEnabled = checked - controller.setContinuousPlayback(checked) - statusText.text = if (checked) { - "已开启连续播放,将按句子顺序连续播放" - } else { - "已关闭连续播放,将循环播放当前句子" - } - } - }) - } - } - - private fun createCatalogSection(): View { - catalogStatusText = TextView(this).apply { - setTextColor(COLOR_TEXT_MUTED) - textSize = 13f - gravity = Gravity.END - text = "正在同步" - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - } - catalogList = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(0, 10.dp, 0, 4.dp) - } - // Fresh instances each time — safe to addView. - val header = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(this@MainActivity).apply { - text = "课程" - setTextColor(Color.WHITE) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - }) - addView(catalogStatusText) - } - - val scroller = HorizontalScrollView(this).apply { - isHorizontalScrollBarEnabled = false - addView(catalogList) - } - - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(16.dp, 0, 16.dp, 14.dp) - addView(header) - addView(scroller) - } - } - - private fun createTestSection(): View { - testStatusText = TextView(this).apply { - setTextColor(COLOR_ACCENT_DEEP) - textSize = 13f - setPadding(0, 0, 0, 8.dp) - } - scoreSummaryText = TextView(this).apply { - setTextColor(COLOR_TEXT_DARK) - textSize = 22f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, 8.dp, 0, 0) - text = "尚未评测" - } - scoreDetailText = TextView(this).apply { - setTextColor(COLOR_TEXT_SUBTLE) - textSize = 13f - setLineSpacing(4.dp.toFloat(), 1f) - setPadding(0, 8.dp, 0, 0) - } - dubbingStatusText = TextView(this).apply { - setTextColor(COLOR_ACCENT_DEEP) - textSize = 13f - setPadding(0, 10.dp, 0, 0) - text = "配音:尚未生成" - } - - val sentenceButton = { label: String, action: () -> Unit -> - Button(this).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(COLOR_BUTTON, 8f) - layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 0, 8.dp, 0) - setOnClickListener { action() } - } - } - - val dubButton: (String, () -> Unit) -> Button = { label, action -> - Button(this).apply { - text = label - isAllCaps = false - setTextColor(Color.WHITE) - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - minWidth = 0 - minHeight = 0 - minimumWidth = 0 - minimumHeight = 0 - background = rounded(COLOR_BUTTON, 8f) - layoutParams = LinearLayout.LayoutParams(0, 40.dp, 1f).withMargins(0, 8.dp, 8.dp, 0) - setOnClickListener { action() } - } - } - - recordButton = Button(this).apply { - text = "开始录音" - isAllCaps = false - setTextColor(Color.WHITE) - textSize = 14f - typeface = Typeface.DEFAULT_BOLD - minWidth = 0 - minHeight = 0 - minimumWidth = 0 - minimumHeight = 0 - background = rounded(COLOR_ACCENT, 8f) - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 44.dp, - 1f, - ) - setOnClickListener { - if (mediaRecorder == null) { - startRecording() - } else { - stopRecordingAndAssess() - } - } - } - - val subtitleToggle = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, 10.dp, 0, 0) - addView(TextView(this@MainActivity).apply { - text = "显示字幕" - setTextColor(COLOR_TEXT_DARK) - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - }) - addView(Switch(this@MainActivity).apply { - isChecked = testSubtitleVisible - buttonTintList = ColorStateList.valueOf(COLOR_ACCENT) - setOnCheckedChangeListener { _, checked -> - testSubtitleVisible = checked - landscapeSubtitleVisible = checked - setContentView(createContentView()) - refreshCurrentUi() - refreshTestUi() - } - }) - } - - playDubButton = dubButton("播放配音", {}).apply { isEnabled = false } - mergeDubButton = dubButton("生成配音", {}).apply { isEnabled = false } - shareDubButton = dubButton("分享配音", {}).apply { isEnabled = false } - playDubButton.setOnClickListener { toggleDubPlayback() } - mergeDubButton.setOnClickListener { mergeDubSegments() } - shareDubButton.setOnClickListener { shareMergedDubbing() } - - val controlRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(sentenceButton("上一句") { controller.seekToPreviousSentence() }) - addView(sentenceButton("播放原句") { - controller.playCurrentSentenceAndStop() - statusText.text = "正在播放当前句子,播放完自动停止" - }) - addView(sentenceButton("下一句") { controller.seekToNextSentence() }) - } - - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = rounded(Color.WHITE, 8f, COLOR_LIGHT_BORDER) - 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(this@MainActivity).apply { - text = "朗读评测(测试)" - setTextColor(COLOR_TEXT_DARK) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - }) - addView(subtitleToggle, 0) - addView(testStatusText) - addView(controlRow) - addView(recordButton) - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - addView(playDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) }) - addView(mergeDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 8.dp, 0) }) - addView(shareDubButton.apply { layoutParams = (layoutParams as LinearLayout.LayoutParams).withMargins(0, 8.dp, 0, 0) }) - }) - addView(scoreSummaryText) - addView(scoreDetailText) - addView(dubbingStatusText) - } - } - - private fun setDubbingStatus(message: String) { - dubbingStatusText?.text = message - } - - private fun refreshTestUi() { - if (!::testStatusText.isInitialized) { - return - } - applyTestSubtitleVisibility() - recordButton.text = if (mediaRecorder == null) "开始录音" else "停止并评测" - val item = controller.currentTrainingItem() - val sentence = controller.currentSentence() - val currentRecording = sentence?.let { dubSegments[it.index] } - val segmentLabel = when { - currentRecording != null -> " · 本句已录音,重录将覆盖" - else -> "" - } - testStatusText.text = if (item == null || sentence == null) { - "请先在训练模块选择课程$segmentLabel" - } else { - "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)$segmentLabel" - } - refreshDubButtons() - if (mergedDubFile == null) { - dubbingStatusText?.text = when (dubSegments.size) { - 0 -> "配音:请先录音" - 1 -> "配音:已保存 1 句,可生成配音" - else -> "配音:已保存 ${dubSegments.size} 句,可生成配音" - } - } - } - - private fun applyTestSubtitleVisibility() { - if (activeModule != Module.TEST) { - return - } - landscapeSubtitleVisible = testSubtitleVisible - if (!::sentenceText.isInitialized || !::progressBar.isInitialized) { - return - } - val visibility = if (testSubtitleVisible) View.VISIBLE else View.GONE - sentenceMetaText.visibility = visibility - sentenceText.visibility = visibility - progressBar.visibility = visibility - } - - private fun startRecording() { - controller.pause() - if (!::testStatusText.isInitialized) { - return - } - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RECORD_AUDIO_REQUEST) - return - } - val file = File(cacheDir, "attempt-${System.currentTimeMillis()}.m4a") - val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(this) - } else { - @Suppress("DEPRECATION") - MediaRecorder() - } - try { - recorder.setAudioSource(MediaRecorder.AudioSource.MIC) - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - recorder.setAudioSamplingRate(16_000) - recorder.setAudioEncodingBitRate(96_000) - recorder.setOutputFile(file.absolutePath) - recorder.prepare() - recorder.start() - val sentence = controller.currentSentence() - if (sentence != null) { - controller.seekTo(sentence.startMs) - controller.setVolume(0f) - } - controller.play() - mediaRecorder = recorder - recordingFile = file - recordButton.text = "停止并保存" - testStatusText.text = if (sentence == null) { - "正在录音…读完当前句子后点击“停止并保存”" - } else { - "正在录音…请对照静音视频口形,读完点击“停止并保存”" - } - } catch (error: Throwable) { - testStatusText.text = "录音启动失败:${error.message.orEmpty()}" - runCatching { recorder.release() } - } - } - - private fun stopRecordingAndAssess() { - val recorder = mediaRecorder ?: return - val file = recordingFile ?: return - mediaRecorder = null - recordButton.text = "重新录音" - try { - recorder.stop() - } catch (error: Throwable) { - testStatusText.text = "录音太短或无法保存:${error.message.orEmpty()}" - file.delete() - recordingFile = null - return - } finally { - runCatching { recorder.release() } - } - controller.pause() - controller.setVolume(1f) - val sentenceIndex = controller.currentSentence()?.index - if (sentenceIndex == null) { - testStatusText.text = "没有可归属的句子,本次录音已丢弃" - file.delete() - recordingFile = null - refreshTestUi() - return - } - dubSegments[sentenceIndex]?.delete() - dubSegments[sentenceIndex] = file - mergedDubFile = null - recordingFile = null - submitAssessment(Uri.fromFile(file)) - refreshTestUi() - } - - private fun discardRecording() { - mediaRecorder?.let { recorder -> - runCatching { recorder.stop() } - runCatching { recorder.release() } - } - mediaRecorder = null - recordingFile?.delete() - recordingFile = null - } - - private fun releaseDubbingResources() { - discardRecording() - dubPlayerListener?.let { listener -> dubPlayer?.removeListener(listener) } - dubPlayer?.release() - dubPlayerListener = null - dubPlayer = null - } - - private fun refreshDubButtons() { - playDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback - mergeDubButton.isEnabled = dubSegments.isNotEmpty() && !dubbingPlayback - shareDubButton.isEnabled = mergedDubFile?.exists() == true && !dubbingPlayback - val activeAudio = controller.currentSentence()?.let { dubSegments[it.index] } - playDubButton.text = when { - dubbingPlayback -> "停止配音" - mergedDubFile?.exists() == true -> "播放配音" - activeAudio != null -> "播放本句配音" - else -> "播放录音(${dubSegments.size})" - } - } - - private fun toggleDubPlayback() { - if (dubbingPlayback) { - stopDubPlayback("已停止配音播放") - return - } - startDubPlayback(mergedDubFile ?: activeAudioForPlayback() ?: return) - } - - private fun activeAudioForPlayback(): File? { - if (mergedDubFile != null) { - return mergedDubFile - } - return controller.currentSentence()?.let { sentence -> dubSegments[sentence.index] } - } - - private fun startDubPlayback(audioFile: File) { - val sentence = controller.currentSentence() - val player = dubPlayer ?: ExoPlayer.Builder(this).build().also { dubPlayer = it } - dubPlayerListener?.let(player::removeListener) - val playbackGeneration = ++dubPlaybackGeneration - player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile))) - player.prepare() - player.volume = 1f - if (mergedDubFile == null && sentence != null) { - controller.seekTo(sentence.startMs) - } - controller.setVolume(0f) - controller.play() - player.playWhenReady = true - dubbingPlayback = true - dubPlayerListener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED && playbackGeneration == dubPlaybackGeneration) { - mainHandler.postDelayed({ - if (dubbingPlayback && playbackGeneration == dubPlaybackGeneration) { - stopDubPlayback("配音播放完成") - } - }, 250L) - } - } - } - player.addListener(dubPlayerListener!!) - refreshTestUi() - statusText.text = if (mergedDubFile == null) { - "正在播放当前句配音,原视频画面同步但原声静音" - } else { - "正在播放整段配音,原视频画面同步但原声静音" - } - } - - private fun stopDubPlayback(message: String) { - dubPlayer?.pause() - dubPlayer?.seekTo(0L) - controller.pause() - controller.setVolume(1f) - dubbingPlayback = false - dubPlaybackGeneration++ - refreshTestUi() - statusText.text = message - } - - private fun mergeDubSegments() { - val segments = dubSegments.toSortedMap().values.toList() - if (segments.isEmpty()) { - return - } - val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a") - var muxer: MediaMuxer? = null - try { - muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - var muxerAudioTrack = -1 - var muxerStarted = false - 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 (muxerAudioTrack == -1) { - muxerAudioTrack = muxer.addTrack(format) - muxer.start() - muxerStarted = true - } - 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(muxerAudioTrack, buffer, info) - presentationTimeUs += 23_000L - extractor.advance() - } - val durationUs = segmentDurationUs(segment) - require(durationUs > 0L) { "无法读取录音时长" } - presentationTimeUs = max(presentationTimeUs, durationUs) - } finally { - extractor.release() - } - } - require(muxerStarted && muxerAudioTrack != -1) { "录音没有可用音频轨道" } - muxer.stop() - muxer.release() - muxer = null - mergedDubFile = output - refreshTestUi() - setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”") - } catch (error: Throwable) { - runCatching { muxer?.stop() } - runCatching { muxer?.release() } - output.delete() - Log.e(TAG_DUBBING, "merge failed", error) - setDubbingStatus( - buildString { - append("生成失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}") - append(" · 已保存 ${dubSegments.size} 句") - } - ) - } - } - - private fun segmentDurationUs(segment: File): Long { - val retriever = MediaMetadataRetriever() - return try { - retriever.setDataSource(segment.absolutePath) - val durationMs = retriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_DURATION - )?.toLongOrNull() ?: return 0L - durationMs * 1000L - } catch (error: Throwable) { - 0L - } finally { - runCatching { retriever.release() } - } - } - - private fun shareMergedDubbing() { - val item = controller.currentTrainingItem() - if (item == null || !SHA256_PATTERN.matches(item.id)) { - setDubbingStatus("分享失败:网页分享仅支持云端课程视频") - return - } - try { - val shareUrl = uploadDubShare(item.id, item.title) - 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) - } - startActivity(Intent.createChooser(intent, "分享配音")) - setDubbingStatus("已生成分享链接:$shareUrl") - } catch (error: Throwable) { - Log.e(TAG_DUBBING, "share failed", error) - setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}") - } - } - - private fun uploadDubShare(videoHash: String, videoTitle: String): String { - val policy = StrictMode.getThreadPolicy() - StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build()) - val boundary = "oral-trainer-${System.currentTimeMillis()}" - var connection: HttpURLConnection? = null - try { - connection = URL(URL(SERVER_BASE_URL), "/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 { "我的口语配音" }) - // Build explicitly to avoid fragile string replacement. - val payload = dubSegments.keys.sorted().joinToString(",", "[", "]") { - "{\"sentence_index\":$it}" - } - text("segments", payload) - val scores = dubSegments.keys.sorted().mapNotNull { index -> - latestScores[index] - ?.let { score -> index to score } - }.joinToString(",", "[", "]") { (index, score) -> - buildString { - append("{\"sentence_index\":") - append(index) - if (score.overallScore != null) { - append(",\"overall_score\":") - append(score.overallScore) - } - 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\":") - append(it) - } - } - score.recognizedText?.let { text -> - append(",\"recognized_text\":\"") - append(text.replace("\\", "\\\\").replace("\"", "\\\"")) - append("\"") - } - append("}") - } - } - text("scores", scores) - dubSegments.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 "$SERVER_BASE_URL/dub-shares/$response" - } finally { - connection?.disconnect() - StrictMode.setThreadPolicy(policy) - } - } - - private fun submitAssessment(recordingUri: Uri) { - val item = controller.currentTrainingItem() - val sentence = controller.currentSentence() - if (item == null || sentence == null) { - testStatusText.text = "没有正在学习的句子,无法评测" - return - } - if (!SHA256_PATTERN.matches(item.id)) { - testStatusText.text = "仅云端课程支持朗读评测(本地视频请先上传到服务器)" - return - } - if (ASSESSMENT_API_KEY.isBlank() || ASSESSMENT_API_KEY.startsWith("replace-")) { - testStatusText.text = "未配置评分密钥:请在 MainActivity 填入服务器 .env 的 CLIENT_API_KEY" - return - } - testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…" - scoreSummaryText.text = "评测中…" - 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 (::testStatusText.isInitialized) { - applyAssessmentResult(result) - } - sentence.index.let { index -> - latestScores[index] = result - } - } - - override fun onError(error: Throwable) { - if (::testStatusText.isInitialized) { - testStatusText.text = "评测失败:${error.message.orEmpty()}" - scoreSummaryText.text = "评测失败" - } - } - }, - ) - } - - private fun applyAssessmentResult(result: ImitationAssessmentResult) { - testStatusText.text = if (result.passed == true) "已通过" else "未通过" - scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore) - 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 createControllerListener(): OralTrainerListener { - return object : OralTrainerListener { - override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { - applyPlaybackSnapshot(snapshot) - } - - override fun onMediaChanged(item: TrainingMediaItem?) { - if (!::lessonTitleText.isInitialized) return - lessonTitleText.text = item?.title ?: "未选择课程" - currentSentenceCount = item?.sentences?.size ?: 0 - } - - override fun onSentenceChanged(sentence: SentenceBoundary?) { - applySentence(sentence) - } - - override fun onGesture(event: GestureEvent) { - if (!::statusText.isInitialized) return - statusText.text = when (event.kind) { - GestureKind.SINGLE_TAP -> "播放状态已切换" - GestureKind.SWIPE_LEFT -> "已跳到上一句" - GestureKind.SWIPE_RIGHT -> "已跳到下一句" - GestureKind.LONG_PRESS_SPEED -> "变速播放中" - } - } - - override fun onPlayerError(error: Throwable) { - if (!::statusText.isInitialized) return - statusText.text = "播放失败:${error.message.orEmpty()}" - } - } - } - - private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) { - if (!::timeText.isInitialized || !::progressBar.isInitialized || - !::speedText.isInitialized || !::statusText.isInitialized - ) { - return - } - if (!progressBarDragging) { - timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}" - progressBar.progress = playbackProgress(snapshot) - } - speedText.text = formatSpeed(snapshot.playbackSpeed) - statusText.text = playbackStatus(snapshot) - } - - private fun seekPositionForProgress(progress: Int): Long { - val duration = controller.snapshot().durationMs - if (duration <= 0L) { - return 0L - } - return progress.toLong() * duration / PROGRESS_MAX - } - - private fun applySentence(sentence: SentenceBoundary?) { - if (!::sentenceMetaText.isInitialized || !::sentenceText.isInitialized || - !::lessonTitleText.isInitialized - ) { - return - } - if (sentence == null) { - sentenceMetaText.text = "暂无句子边界" - sentenceText.text = lessonTitleText.text - return - } - val countText = if (currentSentenceCount > 0) { - " / $currentSentenceCount" - } else { - "" - } - sentenceMetaText.text = "第 ${sentence.index + 1}$countText 句 ${formatTime(sentence.startMs)}-${formatTime(sentence.endMs)}" - sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子" - } - - private fun refreshCurrentUi() { - if (activeModule != Module.TRAIN && activeModule != Module.TEST) return - if (!::lessonTitleText.isInitialized) return - val item = controller.currentTrainingItem() - lessonTitleText.text = item?.title ?: "未选择课程" - currentSentenceCount = item?.sentences?.size ?: 0 - applyPlaybackSnapshot(controller.snapshot()) - applySentence(controller.currentSentence()) - if (::catalogStatusText.isInitialized) { - catalogStatusText.text = catalogStatusTextValue - } - } - - private fun loadCatalog() { - if (!isLoggedIn()) return - catalogStatusTextValue = "正在同步" - if (::catalogStatusText.isInitialized) { - catalogStatusText.text = catalogStatusTextValue - } - if (::catalogList.isInitialized) { - catalogList.removeAllViews() - catalogList.addView(infoCard("课程加载中...")) - } - sdk.userApi.courses(authToken) { result -> - result.fold( - onSuccess = { courses -> - catalogVideos = courses.map { it.toVideoSummary() } - courseEnrollment.clear() - courses.forEach { courseEnrollment[it.videoHash] = it.enrolled } - catalogStatusTextValue = "${catalogVideos.size} 个课程" - if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue - renderCatalog() - }, - onFailure = { - catalogStatusTextValue = "课程加载失败:${it.message}" - if (::catalogStatusText.isInitialized) catalogStatusText.text = catalogStatusTextValue - courseEnrollment.clear() - renderCatalog() - }, - ) - } - } - - private fun loadLegacyCatalog() { - sdk.videoCatalogApi.fetch(object : VideoCatalogCallback { - override fun onSuccess(videos: List) { - catalogVideos = videos - catalogStatusTextValue = if (videos.isEmpty()) { - "暂无云端课程" - } else { - "${videos.size} 个云端课程" - } - if (::catalogStatusText.isInitialized) { - catalogStatusText.text = catalogStatusTextValue - } - renderCatalog() - catalogVideos - .firstOrNull { it.videoHash == activeItemId } - ?.let { refreshSentenceBoundaries(it, background = true) } - } - - override fun onError(error: Throwable) { - catalogStatusTextValue = "云端暂不可用:${error.message.orEmpty()}" - if (::catalogStatusText.isInitialized) { - catalogStatusText.text = catalogStatusTextValue - } - renderCatalog() - } - }) - } - - private fun loadUserData() { - when (activeModule) { - Module.COURSE -> loadCatalog() - Module.MINE -> refreshMyContent() - else -> Unit - } - } - - private fun createCourseSection(): View { - catalogStatusText = TextView(this).apply { - setTextColor(COLOR_TEXT_MUTED) - textSize = 13f - text = catalogStatusTextValue - setPadding(16.dp, 0.dp, 16.dp, 8.dp) - } - catalogList = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(16.dp, 8.dp, 16.dp, 16.dp) - } - return ScrollView(this).apply { - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.VERTICAL - setPadding(0.dp, 12.dp, 0.dp, 16.dp) - addView(TextView(this@MainActivity).apply { - text = "课程首页" - textSize = 26f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - setPadding(16.dp, 0.dp, 16.dp, 0.dp) - }) - addView(TextView(this@MainActivity).apply { - text = "选择一门课程开始训练" - textSize = 13f - setTextColor(COLOR_TEXT_MUTED) - setPadding(16.dp, 4.dp, 16.dp, 8.dp) - }) - addView(catalogStatusText) - addView(catalogList) - }) - } - } - - private fun createMySection(): View { - myContentList = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - return ScrollView(this).apply { - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.VERTICAL - setPadding(16.dp, 12.dp, 16.dp, 16.dp) - addView(TextView(this@MainActivity).apply { - text = authNickname.ifBlank { "我的学习" } - textSize = 26f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - }) - addView(Button(this@MainActivity).apply { - text = "退出登录" - setOnClickListener { logout() } - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 12.dp, 0, 20.dp) - }) - addView(TextView(this@MainActivity).apply { - text = "我的测试结果" - textSize = 18f - typeface = Typeface.DEFAULT_BOLD - setTextColor(Color.WHITE) - }) - addView(myContentList) - }) - } - } - - private fun refreshMyContent() { - val list = myContentList ?: return - list.removeAllViews() - val course = currentCourse - if (course == null) { - list.addView(infoCard("请先在「课程」中选择一门课程")) - return - } - list.addView(TextView(this).apply { - text = "当前课程:${course.title}" - setTextColor(Color.WHITE) - textSize = 14f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, 0, 0, 8.dp) - }) - list.addView(infoCard("加载中...")) - sdk.userApi.results(authToken) { result -> - list.removeAllViews() - list.addView(TextView(this).apply { - text = "当前课程:${course.title}" - setTextColor(Color.WHITE) - textSize = 14f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, 0, 0, 8.dp) - }) - result.fold( - onSuccess = { rows -> - val filtered = rows.filter { it.videoHash == course.videoHash } - if (filtered.isEmpty()) list.addView(infoCard("该课程还没有测试记录")) - filtered.forEach { row -> - list.addView(infoCard( - "第 ${row.sentenceIndex + 1} 句\n" + - "得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}" - )) - } - }, - onFailure = { list.addView(infoCard("测试记录加载失败:${it.message}")) }, - ) - sdk.userApi.dubShares(authToken) { shareResult -> - list.post { - list.addView(titleDivider("我的配音分享")) - shareResult.fold( - onSuccess = { shares -> - val filtered = shares.filter { it.videoHash == course.videoHash } - if (filtered.isEmpty()) list.addView(infoCard("该课程还没有分享配音")) - filtered.forEach { row -> - list.addView(infoCard( - "${row.title}\n${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分" - )) - } - }, - onFailure = { list.addView(infoCard("配音分享加载失败:${it.message}")) }, - ) - } - } - } - } - - private fun infoCard(text: String): TextView { - return TextView(this).apply { - this.text = text - setTextColor(COLOR_TEXT_DARK) - textSize = 13f - background = rounded(COLOR_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) - } - } - - private fun titleDivider(text: String): TextView { - return TextView(this).apply { - this.text = text - setTextColor(Color.WHITE) - textSize = 18f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, 20.dp, 0, 8.dp) - } - } - - private fun renderCatalog() { - if (!::catalogList.isInitialized) { - return - } - catalogList.removeAllViews() - if (activeModule == Module.COURSE) { - if (catalogVideos.isEmpty()) { - catalogList.addView(infoCard("暂无课程")) - return - } - catalogVideos.forEach { video -> - catalogList.addView(courseListCard(video) { - selectCourse(video) - }) - } - return - } - catalogVideos.forEach { video -> - val enrolled = courseEnrollment[video.videoHash] == true - catalogList.addView( - videoCard( - id = video.videoHash, - title = video.title, - meta = if (enrolled) { - "已选修 · ${video.sentenceCount} 句" - } else { - "未选修 · 点击选修" - }, - ) { - if (enrolled) loadRemoteVideo(video) else enrollCourse(video) - } - ) - } - } - - private fun courseListCard( - video: TrainingVideoSummary, - onClick: () -> Unit, - ): View { - val enrolled = courseEnrollment[video.videoHash] == true - val isSelected = currentCourse?.videoHash == video.videoHash - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - isClickable = true - background = if (isSelected) { - rounded(COLOR_SELECTED, 10f, COLOR_ACCENT) - } else { - rounded(COLOR_SURFACE, 10f, COLOR_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(this@MainActivity).apply { - text = video.title - setTextColor(if (isSelected) COLOR_TEXT_DARK else Color.WHITE) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - maxLines = 2 - }) - addView(TextView(this@MainActivity).apply { - text = buildString { - append(if (enrolled) "已选修" else "未选修") - append(" · ${video.sentenceCount} 句") - if (isSelected) append(" · 当前课程") - } - setTextColor(if (isSelected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED) - textSize = 13f - setPadding(0, 6.dp, 0, 0) - }) - } - } - - private fun selectCourse(video: TrainingVideoSummary) { - currentCourse = video - if (courseEnrollment[video.videoHash] != true) { - enrollCourse(video) - } - activeModule = Module.TRAIN - landscapeSubtitleVisible = true - setContentView(createContentView()) - renderCatalog() - refreshCurrentUi() - loadRemoteVideo(video) - } - - private fun enrollCourse(video: TrainingVideoSummary) { - sdk.userApi.enroll(authToken, video.videoHash) { result -> - result.fold( - onSuccess = { - courseEnrollment[video.videoHash] = true - renderCatalog() - }, - onFailure = { - if (::catalogStatusText.isInitialized) { - catalogStatusText.text = "选修失败:${it.message}" - } - }, - ) - } - } - - private fun loadRemoteVideo(video: TrainingVideoSummary) { - activeItemId = video.videoHash - if (::lessonTitleText.isInitialized) lessonTitleText.text = video.title - if (::sentenceMetaText.isInitialized) sentenceMetaText.text = "句子边界加载中" - if (::sentenceText.isInitialized) sentenceText.text = video.title - if (::statusText.isInitialized) statusText.text = "正在加载云端课程..." - renderCatalog() - - val loadingItem = video.toTrainingMediaItem() - controller.loadItem(loadingItem) - currentSentenceCount = video.sentenceCount - refreshSentenceBoundaries(video, background = false) - } - - private fun refreshSentenceBoundaries( - video: TrainingVideoSummary, - background: Boolean, - ) { - val requestGeneration = ++sentenceBoundaryRequestGeneration - if (!background && ::statusText.isInitialized) { - statusText.text = "正在加载云端课程..." - } else if (::statusText.isInitialized) { - statusText.text = "正在刷新句子边界..." - } - sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback { - override fun onSuccess(result: SentenceBoundaryApiResult) { - if ( - sentenceBoundaryRequestGeneration != requestGeneration || - activeItemId != video.videoHash - ) { - return - } - currentSentenceCount = result.sentences.size - controller.updateSentences(video.videoHash, result.sentences) - refreshCurrentUi() - statusText.text = "课程已就绪:${result.sentences.size} 句" - } - - override fun onError(error: Throwable) { - if ( - sentenceBoundaryRequestGeneration != requestGeneration || - activeItemId != video.videoHash - ) { - return - } - statusText.text = "句子边界加载失败:${error.message.orEmpty()}" - sentenceMetaText.text = "暂无句子边界" - sentenceText.text = video.title - } - }) - } - - private fun videoCard( - id: String, - title: String, - meta: String, - onClick: () -> Unit, - ): View { - val selected = activeItemId == id - val titleColor = if (selected) COLOR_TEXT_DARK else Color.WHITE - val metaColor = if (selected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_VERTICAL - isClickable = true - background = if (selected) { - rounded(COLOR_SELECTED, 8f, COLOR_ACCENT) - } else { - rounded(COLOR_SURFACE, 8f, COLOR_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(this@MainActivity).apply { - text = title - setTextColor(titleColor) - textSize = 15f - typeface = Typeface.DEFAULT_BOLD - maxLines = 2 - }) - addView(TextView(this@MainActivity).apply { - text = meta - setTextColor(metaColor) - textSize = 12f - setPadding(0, 6.dp, 0, 0) - }) - } - } - - private fun headerButton(label: String, action: () -> Unit): Button { - return Button(this).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(COLOR_BUTTON, 8f) - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - 40.dp, - ).withMargins(8.dp, 0, 0, 0) - setOnClickListener { action() } - } - } - - private fun overlayPill(label: String): TextView { - return TextView(this).apply { - text = label - setTextColor(Color.WHITE) - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - background = rounded(COLOR_OVERLAY_FAINT, 8f) - setPadding(10.dp, 6.dp, 10.dp, 6.dp) - gravity = Gravity.CENTER - } - } - private fun openLocalVideoPicker() { + @Suppress("DEPRECATION") startActivityForResult( Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) @@ -2337,118 +267,232 @@ class MainActivity : Activity() { ) } - private fun playbackStatus(snapshot: PlaybackSnapshot): String { - val state = when { - snapshot.isPlaying -> "播放中" - snapshot.playbackState.name == "BUFFERING" -> "缓冲中" - snapshot.playbackState.name == "ENDED" -> "已结束" - else -> "已暂停" + private fun switchModule(module: Module) { + screenState.activeModule = module + if (module == Module.COURSE || module == Module.MINE) { + catalogController.loadUserData(module) { + screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty()) + } + } + controller.setContinuousPlayback( + if (screenState.activeModule == Module.TRAIN) screenState.continuousPlaybackEnabled else false, + ) + screenState.landscapeSubtitleVisible = + screenState.activeModule == Module.TRAIN || screenState.testSubtitleVisible + setContentView(screenFactory.createContentView()) + screenFactory.renderCatalog() + refreshCurrentUi() + if (screenState.activeModule == Module.TEST) { + testSession.refreshTestUi() + } + if (screenState.activeModule == Module.MINE) { + screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty()) } - val sentence = snapshot.sentenceIndex?.let { " · 第 ${it + 1} 句" }.orEmpty() - return "$state$sentence" } - private fun playbackProgress(snapshot: PlaybackSnapshot): Int { - if (snapshot.durationMs <= 0L) { - return 0 + private fun refreshCurrentUi() { + if (screenState.activeModule != Module.TRAIN && screenState.activeModule != Module.TEST) { + screenFactory.setCatalogStatus(screenState.catalogStatus) + return } - return ((snapshot.positionMs.coerceAtMost(snapshot.durationMs) * PROGRESS_MAX) / snapshot.durationMs).toInt() + if (!bindings.hasPlayerChrome) return + val item = controller.currentTrainingItem() + bindings.lessonTitleText.text = item?.title ?: "未选择课程" + screenState.currentSentenceCount = item?.sentences?.size ?: 0 + applyPlaybackSnapshot(controller.snapshot()) + applySentence(controller.currentSentence()) + screenFactory.setCatalogStatus(screenState.catalogStatus) } - private fun formatTime(ms: Long): String { - if (ms < 0) { - return "--:--" + private fun createControllerListener(): OralTrainerListener = object : OralTrainerListener { + override fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { + applyPlaybackSnapshot(snapshot) } - 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) + + override fun onMediaChanged(item: TrainingMediaItem?) { + if (!bindings.hasPlayerChrome) return + bindings.lessonTitleText.text = item?.title ?: "未选择课程" + screenState.currentSentenceCount = item?.sentences?.size ?: 0 + } + + override fun onSentenceChanged(sentence: SentenceBoundary?) { + applySentence(sentence) + } + + override fun onGesture(event: GestureEvent) { + if (!bindings.hasPlayerChrome) return + bindings.statusText.text = when (event.kind) { + GestureKind.SINGLE_TAP -> "播放状态已切换" + GestureKind.SWIPE_LEFT -> "已跳到上一句" + GestureKind.SWIPE_RIGHT -> "已跳到下一句" + GestureKind.LONG_PRESS_SPEED -> "变速播放中" + } + } + + override fun onPlayerError(error: Throwable) { + if (!bindings.hasPlayerChrome) return + bindings.statusText.text = "播放失败:${error.message.orEmpty()}" + } + } + + private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) { + if (!bindings.hasPlayerChrome) return + if (!progressBarDragging) { + bindings.timeText.text = + "${PlaybackFormat.time(snapshot.positionMs)} / ${PlaybackFormat.time(snapshot.durationMs)}" + bindings.progressBar.progress = PlaybackFormat.progress(snapshot) + } + bindings.speedText.text = PlaybackFormat.speed(snapshot.playbackSpeed) + bindings.statusText.text = PlaybackFormat.status(snapshot) + } + + private fun applySentence(sentence: SentenceBoundary?) { + if (!bindings.hasPlayerChrome) return + if (sentence == null) { + bindings.sentenceMetaText.text = "暂无句子边界" + bindings.sentenceText.text = bindings.lessonTitleText.text + return + } + val countText = if (screenState.currentSentenceCount > 0) { + " / ${screenState.currentSentenceCount}" } else { - "%02d:%02d".format(Locale.US, minutes, seconds) + "" + } + bindings.sentenceMetaText.text = + "第 ${sentence.index + 1}$countText 句 ${PlaybackFormat.time(sentence.startMs)}-${PlaybackFormat.time(sentence.endMs)}" + bindings.sentenceText.text = sentence.text?.takeIf { it.isNotBlank() } ?: "当前句子" + if (screenState.activeModule == Module.TEST) { + testSession.refreshTestUi() } } - private fun formatSpeed(speed: Float): String { - val value = String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.') - return "${value}x" + private fun screenActions() = object : SampleScreenActions { + override fun onModuleSelected(module: Module) = switchModule(module) + + override fun onImportLocalVideo() = openLocalVideoPicker() + + override fun onRefreshCatalog() = catalogController.loadCatalog() + + override fun onSeekProgressPreview(progress: Int) { + if (!bindings.hasPlayerChrome) return + val duration = controller.snapshot().durationMs + bindings.timeText.text = + "${PlaybackFormat.time(PlaybackFormat.seekPosition(progress, duration))} / ${PlaybackFormat.time(duration)}" + } + + override fun onSeekStart() { + progressBarDragging = true + } + + override fun onSeekStop(progress: Int) { + progressBarDragging = false + controller.seekTo( + PlaybackFormat.seekPosition(progress, controller.snapshot().durationMs), + ) + } + + override fun onContinuousPlaybackChanged(enabled: Boolean) { + screenState.continuousPlaybackEnabled = enabled + controller.setContinuousPlayback(enabled) + if (bindings.hasPlayerChrome) { + bindings.statusText.text = if (enabled) { + "已开启连续播放,将按句子顺序连续播放" + } else { + "已关闭连续播放,将循环播放当前句子" + } + } + } + + override fun onTestSubtitleChanged(visible: Boolean) { + testSession.onTestSubtitleChanged(visible) + } + + override fun onPrevSentence() { + controller.seekToPreviousSentence() + } + + override fun onReplaySentence() { + controller.playCurrentSentenceAndStop() + } + + override fun onNextSentence() { + controller.seekToNextSentence() + } + + override fun onToggleRecording() = testSession.toggleRecording() + + override fun onToggleDubPlayback() = testSession.toggleDubPlayback() + + override fun onMergeDub() = testSession.mergeDubSegments() + + override fun onShareDub() = testSession.shareMergedDubbing() + + override fun onSelectCourse(video: TrainingVideoSummary) { + catalogController.selectCourse(video) + } + + override fun onTrainCatalogVideoClick(video: TrainingVideoSummary, enrolled: Boolean) { + if (enrolled) { + catalogController.loadRemoteVideo(video) + } else { + catalogController.enrollCourse(video) + } + } + + override fun onLogout() = logout() + + override fun nickname(): String = + authSessionStore.session?.user?.nickname.orEmpty() } - private fun rounded( - color: Int, - radiusDp: Float, - strokeColor: Int? = null, - ): GradientDrawable { - return GradientDrawable().apply { - setColor(color) - cornerRadius = radiusDp.dp.toFloat() - strokeColor?.let { setStroke(1.dp, it) } + private fun catalogHost() = object : CourseCatalogController.Host { + override fun onCatalogChanged() = screenFactory.renderCatalog() + + override fun onCatalogStatus(text: String) = screenFactory.setCatalogStatus(text) + + override fun onCatalogLoadingPlaceholder() = screenFactory.showCatalogLoading() + + override fun onPlayerLoading(video: TrainingVideoSummary) { + if (!bindings.hasPlayerChrome) return + bindings.lessonTitleText.text = video.title + bindings.sentenceMetaText.text = "句子边界加载中" + bindings.sentenceText.text = video.title + bindings.statusText.text = "正在加载云端课程..." + } + + override fun onStatus(text: String) { + if (bindings.hasPlayerChrome) bindings.statusText.text = text + } + + override fun onSentencePlaceholder(meta: String, text: String) { + if (!bindings.hasPlayerChrome) return + bindings.sentenceMetaText.text = meta + bindings.sentenceText.text = text + } + + override fun refreshPlayerUi() = refreshCurrentUi() + + override fun openTrainingModule() { + setContentView(screenFactory.createContentView()) + screenFactory.renderCatalog() + refreshCurrentUi() } } - private fun LinearLayout.LayoutParams.withMargins( - left: Int, - top: Int, - right: Int, - bottom: Int, - ): LinearLayout.LayoutParams { - setMargins(left, top, right, bottom) - return this - } + private fun testHost() = object : TestSessionController.Host { + override val bindings: SampleBindings + get() = this@MainActivity.bindings - private val Int.dp: Int - get() = (this * resources.displayMetrics.density).toInt() - - private val Float.dp: Int - get() = (this * resources.displayMetrics.density).toInt() - - private companion object { - // 服务端地址:改成你实际部署的域名或 IP。 - // 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80)。 - const val SERVER_BASE_URL = "https://videoservice.d1kt.cn" - // 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。 - const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220" - - // 第一课堂 OAuth2 SSO 配置 - const val SSO_LOGIN_URL = "https://d1kt.cn/login" - const val SSO_AUTHORIZE_URL = "https://d1kt.cn/api/oauth2/authorize" - const val SSO_TOKEN_URL = "https://d1kt.cn/api/oauth2/token" - const val SSO_CLIENT_ID = "oral-trainer-app" - const val SSO_CLIENT_SECRET = "oral-trainer-secret-2024" - const val SSO_REDIRECT_URI = "https://videoservice.d1kt.cn/api/v1/auth/sso/callback" - const val SSO_SCOPE = "openid profile email username" - - const val PICK_VIDEO_REQUEST = 1001 - const val RECORD_AUDIO_REQUEST = 1002 - const val PROGRESS_MAX = 1000 - private const val TAG_DUBBING = "Dubbing" - - private val SHA256_PATTERN = Regex("^[0-9a-fA-F]{64}$") - - enum class Module { - COURSE, - TRAIN, - TEST, - MINE, + override fun setMainStatus(text: String) { + if (this@MainActivity.bindings.hasPlayerChrome) { + this@MainActivity.bindings.statusText.text = text + } } - val COLOR_BACKGROUND: Int = Color.rgb(12, 15, 18) - val COLOR_SURFACE: Int = Color.rgb(28, 34, 40) - val COLOR_BORDER: Int = Color.rgb(50, 59, 67) - val COLOR_BUTTON: Int = Color.rgb(37, 99, 235) - val COLOR_ACCENT: Int = Color.rgb(21, 184, 132) - val COLOR_ACCENT_DEEP: Int = Color.rgb(7, 118, 86) - val COLOR_ACCENT_LIGHT: Int = Color.rgb(94, 234, 182) - val COLOR_SELECTED: Int = Color.rgb(229, 248, 240) - val COLOR_PROGRESS_TRACK: Int = Color.rgb(224, 231, 235) - val COLOR_PROGRESS_TRACK_DARK: Int = Color.rgb(55, 62, 70) - val COLOR_LIGHT_BORDER: Int = Color.rgb(218, 226, 232) - val COLOR_OVERLAY: Int = Color.argb(178, 9, 12, 16) - val COLOR_OVERLAY_FAINT: Int = Color.argb(51, 9, 12, 16) - val COLOR_TEXT_DARK: Int = Color.rgb(18, 24, 31) - val COLOR_TEXT_MUTED: Int = Color.rgb(151, 162, 174) - val COLOR_TEXT_SUBTLE: Int = Color.rgb(88, 98, 108) - val COLOR_TEXT_OVERLAY: Int = Color.rgb(163, 172, 182) + override fun rebuildContent() { + setContentView(screenFactory.createContentView()) + } + + override fun refreshPlayerUi() = refreshCurrentUi() } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/PlaybackFormat.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/PlaybackFormat.kt new file mode 100644 index 0000000..43b6bd0 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/PlaybackFormat.kt @@ -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) +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt new file mode 100644 index 0000000..a805739 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt @@ -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 +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt new file mode 100644 index 0000000..61976e3 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt @@ -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 diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt new file mode 100644 index 0000000..5686bb6 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenFactory.kt @@ -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) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt new file mode 100644 index 0000000..935cdf2 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt @@ -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) +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SdkModule.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SdkModule.kt new file mode 100644 index 0000000..9b9939d --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SdkModule.kt @@ -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) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt new file mode 100644 index 0000000..e5ebce9 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt @@ -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" + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt new file mode 100644 index 0000000..d951c52 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt @@ -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 = emptyList() + val courseEnrollment = mutableMapOf() + 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 + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/UiStyle.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/UiStyle.kt new file mode 100644 index 0000000..29a9f25 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/UiStyle.kt @@ -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) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt new file mode 100644 index 0000000..898e3b6 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt @@ -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 + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt new file mode 100644 index 0000000..02fe48e --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/MineScreen.kt @@ -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) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/PlayerChrome.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/PlayerChrome.kt new file mode 100644 index 0000000..c458ead --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/PlayerChrome.kt @@ -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) + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt new file mode 100644 index 0000000..bf04c43 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/SampleWidgets.kt @@ -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) + }) + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TestScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TestScreen.kt new file mode 100644 index 0000000..477f2b1 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TestScreen.kt @@ -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) + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TrainScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TrainScreen.kt new file mode 100644 index 0000000..f413a3f --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/TrainScreen.kt @@ -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) + } + } +} diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 0cbb5fcee33d180fa97b2b99f05b4d72f60d67ac..4ba8701dceffb0f8005cf344233d2dead6dc6a09 100644 GIT binary patch delta 250 zcmVlwHY#ad+lbbS083Tg@h5#0#1B3z@fdLVKprQgD0epa>fUA-&877PakN_5=1C0ZY z1CN0L6M+E~fdLhx0tN$;1CxNBfdLkQ0T+NJlQ=CQlie&^2hszX01pG2lVB|-1)Ts7 z1D=zP9VC;lEeNydEwLO0D|~%G-IJb59RoA~GPAx)6$d?xEUzrQEWs?qEXFL$EX^#> zEYU3BEbT1xEcYz>Ed4D6Ee0(KEetIVEiWxtEs8CZEuby4EwwGaEz2$5vk+1u25n+f AW&i*H delta 249 zcmVlO`P^1_O`)4+D{tku4?#lmHI{m6MJgB$MDR2pE_Hn*bJ~1DOIDfdLnQprQgD z0epa>fUA-&8MEjuu^a^{Opg6!lb%W)12OE-7N+!I4xN%Vl8hif-QtCjV+ojtu3!Dye+{k#4W}x%Pq|<&n?lj5K