Compare commits

...

4 Commits

35 changed files with 3872 additions and 2302 deletions

View File

@@ -19,7 +19,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.2"
versionName = "0.1.3"
}
compileOptions {

View File

@@ -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}$")

View File

@@ -0,0 +1,13 @@
package cn.learningpad.oraltrainer.sample
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
internal class AssessmentState {
private val scores = mutableMapOf<Int, ImitationAssessmentResult>()
fun store(sentenceIndex: Int, result: ImitationAssessmentResult) {
scores[sentenceIndex] = result
}
fun toMap(): Map<Int, ImitationAssessmentResult> = scores.toMap()
}

View File

@@ -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()
}
}

View File

@@ -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 ''; } })()"
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -0,0 +1,83 @@
package cn.learningpad.oraltrainer.sample
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.max
internal class DubbingMerger(private val cacheDir: File) {
fun merge(segments: List<Pair<Int, File>>): File {
require(segments.isNotEmpty()) { "没有可合成的配音" }
val output = File(cacheDir, "dubbing-${System.currentTimeMillis()}.m4a")
var muxer: MediaMuxer? = null
try {
muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var audioTrack = -1
var presentationTimeUs = 0L
segments.forEach { (_, segment) ->
val extractor = MediaExtractor().apply { setDataSource(segment.absolutePath) }
try {
val trackIndex = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME)
?.startsWith("audio/") == true
} ?: throw IllegalStateException("录音没有音频轨道")
extractor.selectTrack(trackIndex)
val format = extractor.getTrackFormat(trackIndex)
if (audioTrack == -1) {
audioTrack = muxer.addTrack(format)
muxer.start()
}
val buffer = ByteBuffer.allocateDirect(
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
} else {
64 * 1024
}
)
val info = MediaCodec.BufferInfo()
while (true) {
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
info.set(0, sampleSize, presentationTimeUs, extractor.sampleFlags)
muxer.writeSampleData(audioTrack, buffer, info)
presentationTimeUs += 23_000L
extractor.advance()
}
presentationTimeUs = max(presentationTimeUs, segmentDurationUs(segment))
} finally {
extractor.release()
}
}
require(audioTrack != -1) { "录音没有可用音频轨道" }
muxer.stop()
muxer.release()
muxer = null
return output
} catch (error: Throwable) {
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
output.delete()
throw error
}
}
private fun segmentDurationUs(segment: File): Long {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(segment.absolutePath)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull()?.times(1000L) ?: 0L
} catch (error: Throwable) {
0L
} finally {
runCatching { retriever.release() }
}
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,94 @@
package cn.learningpad.oraltrainer.sample
import android.os.StrictMode
import java.io.DataOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
import org.json.JSONObject
internal class DubbingShareApi {
fun upload(
baseUrl: String,
videoHash: String,
videoTitle: String,
segments: Map<Int, java.io.File>,
scores: Map<Int, cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult>,
): String {
val previousPolicy = StrictMode.getThreadPolicy()
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())
val boundary = "oral-trainer-${System.currentTimeMillis()}"
var connection: HttpURLConnection? = null
try {
connection = URL(URL(baseUrl), "/api/v1/dub-shares").openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
DataOutputStream(connection.outputStream).use { output ->
fun text(name: String, value: String) {
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes(value.replace("\n", "\\n") + "\r\n")
}
text("video_hash", videoHash.lowercase(Locale.US))
text("title", videoTitle.ifBlank { "我的口语配音" })
text(
"segments",
segments.keys.sorted().joinToString(",", "[", "]") {
"{\"sentence_index\":$it}"
},
)
text(
"scores",
segments.keys.sorted().mapNotNull { index ->
scores[index]?.let { score -> index to score }
}.joinToString(",", "[", "]") { (index, score) ->
buildString {
append("{\"sentence_index\":")
append(index)
score.overallScore?.let { append(",\"overall_score\":$it") }
listOf(
"content_score" to score.contentScore,
"fluency_score" to score.fluencyScore,
"duration_score" to score.durationScore,
"pause_score" to score.pauseScore,
"speech_rate_score" to score.speechRateScore,
).forEach { (name, value) ->
value?.let { append(",\"$name\":$it") }
}
score.recognizedText?.let {
append(",\"recognized_text\":\"")
append(it.replace("\\", "\\\\").replace("\"", "\\\""))
append("\"")
}
append("}")
}
},
)
segments.toSortedMap().forEach { (index, file) ->
output.writeBytes("--$boundary\r\n")
output.writeBytes(
"Content-Disposition: form-data; name=\"files\"; filename=\"dub-$index.m4a\"\r\n"
)
output.writeBytes("Content-Type: audio/mp4\r\n\r\n")
file.inputStream().use { input -> input.copyTo(output) }
output.writeBytes("\r\n")
}
output.writeBytes("--$boundary--\r\n")
}
val code = connection.responseCode
val body = (if (code in 200..299) connection.inputStream else connection.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
require(code in 200..299) { "HTTP $code: $body" }
val response = JSONObject(body).getString("share_id")
return "$baseUrl/dub-shares/$response"
} finally {
connection?.disconnect()
StrictMode.setThreadPolicy(previousPolicy)
}
}
}

View File

@@ -0,0 +1,19 @@
package cn.learningpad.oraltrainer.sample
import java.io.File
internal class DubbingState {
val segments = mutableMapOf<Int, File>()
var mergedFile: File? = null
private set
fun storeSegment(sentenceIndex: Int, file: File) {
segments[sentenceIndex]?.delete()
segments[sentenceIndex] = file
mergedFile = null
}
fun setMergedFile(file: File) {
mergedFile = file
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,29 @@
package cn.learningpad.oraltrainer.sample
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
internal class TrainingScreenState {
var activeItemId = ""
var currentSentenceCount = 0
var catalogVideos: List<TrainingVideoSummary> = emptyList()
val courseEnrollment = mutableMapOf<String, Boolean>()
var catalogStatus = "正在同步"
var activeModule = Module.TRAIN
var currentCourse: TrainingVideoSummary? = null
var continuousPlaybackEnabled = false
var testSubtitleVisible = false
var landscapeSubtitleVisible = true
private var boundaryRequestGeneration = 0
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
boundaryRequestGeneration == generation && activeItemId == itemId
fun clearCourse() {
currentCourse = null
activeItemId = ""
currentSentenceCount = 0
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
})
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
@@ -33,6 +35,8 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -117,6 +121,7 @@
children = (
AA0000000000000000000028 /* HTTPClient.swift */,
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
AA0000000000000000000091 /* AuthAPI.swift */,
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
AA000000000000000000002B /* ImitationAssessor.swift */,
AA000000000000000000007E /* DubbingCompositor.swift */,
@@ -139,6 +144,7 @@
children = (
AA000000000000000000002E /* Theme.swift */,
AA000000000000000000002F /* RootView.swift */,
AA0000000000000000000093 /* AuthView.swift */,
AA0000000000000000000030 /* TrainingView.swift */,
AA0000000000000000000031 /* PlayerSectionView.swift */,
AA0000000000000000000032 /* CatalogView.swift */,
@@ -248,6 +254,8 @@
AA0000000000000000000050 /* TrainingView.swift in Sources */,
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
AA0000000000000000000052 /* CatalogView.swift in Sources */,
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
AA0000000000000000000092 /* AuthView.swift in Sources */,
AA0000000000000000000053 /* TestView.swift in Sources */,
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
);

View File

@@ -17,4 +17,7 @@ enum AppConfig {
static let assessmentTimeout: TimeInterval = 180
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
static var authToken: String = ""
}

View File

@@ -0,0 +1,172 @@
import Foundation
// MARK: - Models
struct AuthUser: Codable {
let id: String
let username: String
let nickname: String
}
struct AuthSession: Codable {
let token: String
let user: AuthUser
}
struct SsoExchangeResult: Codable {
let token: String
let user: AuthUser
let phoneBound: Bool
let isNewUser: Bool
private enum CodingKeys: String, CodingKey {
case token, user
case phoneBound = "phone_bound"
case isNewUser = "is_new_user"
}
}
struct Course: Identifiable, Codable {
let videoHash: String
let title: String
let durationMs: Int64?
let language: String?
let sentenceCount: Int
let streamUrl: String
let enrolled: Bool
var id: String { videoHash }
private enum CodingKeys: String, CodingKey {
case title, language, enrolled
case videoHash = "video_hash"
case durationMs = "duration_ms"
case sentenceCount = "sentence_count"
case streamUrl = "stream_url"
}
func toVideoSummary() -> TrainingVideoSummary {
TrainingVideoSummary(
videoHash: videoHash,
title: title,
streamUrl: streamUrl,
durationMs: durationMs,
sizeBytes: 0,
language: language,
sentenceCount: sentenceCount,
status: "ready"
)
}
}
// MARK: - Auth Error
enum AuthError: LocalizedError {
case badStatus(Int, String)
var errorDescription: String? {
switch self {
case .badStatus(let code, let body):
return "HTTP \(code): \(body.prefix(200))"
}
}
}
// MARK: - Auth API
enum AuthAPI {
private static var baseURL: String { AppConfig.serverBaseURL }
// MARK: Login / Register
static func login(username: String, password: String) async throws -> AuthSession {
try await authRequest(path: "/api/v1/auth/login", body: [
"username": username,
"password": password,
])
}
static func register(
username: String,
password: String,
nickname: String,
phone: String
) async throws -> AuthSession {
try await authRequest(path: "/api/v1/auth/register", body: [
"username": username,
"password": password,
"nickname": nickname,
"phone": phone,
])
}
// MARK: SSO
static func ssoExchange(jwtToken: String) async throws -> SsoExchangeResult {
let url = URL(string: "\(baseURL)/api/v1/auth/sso/exchange")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["access_token": jwtToken])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthError.badStatus(status, body)
}
return try JSONDecoder().decode(SsoExchangeResult.self, from: data)
}
static func ssoBindPhone(token: String, phone: String) async throws {
let url = URL(string: "\(baseURL)/api/v1/auth/sso/bind-phone")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONSerialization.data(withJSONObject: ["phone": phone])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthError.badStatus(status, body)
}
}
// MARK: Courses
static func courses(token: String) async throws -> [Course] {
let url = URL(string: "\(baseURL)/api/v1/courses")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthError.badStatus(status, body)
}
return try JSONDecoder().decode([Course].self, from: data)
}
// MARK: Private
private static func authRequest(path: String, body: [String: Any]) async throws -> AuthSession {
let url = URL(string: "\(baseURL)\(path)")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthError.badStatus(status, body)
}
return try JSONDecoder().decode(AuthSession.self, from: data)
}
}

View File

@@ -25,14 +25,17 @@ enum HTTPClient {
_ type: T.Type,
baseURL: String,
path: String,
timeout: TimeInterval
timeout: TimeInterval,
additionalHeaders: [String: String] = [:]
) async throws -> (value: T, effectiveBaseURL: String) {
var headers = ["Accept": "application/json"]
headers.merge(additionalHeaders) { _, new in new }
let result = try await execute(
baseURL: baseURL,
path: path,
method: "GET",
timeout: timeout,
headers: ["Accept": "application/json"]
headers: headers
)
guard (200...299).contains(result.statusCode) else {
let body = String(data: result.body, encoding: .utf8) ?? ""

View File

@@ -20,11 +20,16 @@ enum SentenceBoundaryAPI {
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
var headers: [String: String] = [:]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.getJSON(
SentenceBoundaryDocument.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/sentence-boundaries",
timeout: AppConfig.readTimeout
timeout: AppConfig.readTimeout,
additionalHeaders: headers
)
guard result.value.videoHash.lowercased() == hash else {
throw SentenceBoundaryAPIError.hashMismatch

View File

@@ -0,0 +1,509 @@
import SwiftUI
// MARK: - Auth Manager (shared state)
@MainActor
final class AuthManager: ObservableObject {
@Published var token: String
@Published var nickname: String
@Published var showingRegister = false
@Published var showingSSO = false
@Published var showingPhoneBinding = false
@Published var ssoPendingToken: String?
init() {
let defaults = UserDefaults.standard
token = defaults.string(forKey: "oral_trainer_token") ?? ""
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
AppConfig.authToken = token
}
var isLoggedIn: Bool { !token.isEmpty }
func saveSession(_ session: AuthSession) {
token = session.token
nickname = session.user.nickname
AppConfig.authToken = session.token
let defaults = UserDefaults.standard
defaults.set(session.token, forKey: "oral_trainer_token")
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
}
func logout() {
token = ""
nickname = ""
AppConfig.authToken = ""
showingRegister = false
showingSSO = false
showingPhoneBinding = false
ssoPendingToken = nil
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "oral_trainer_token")
defaults.removeObject(forKey: "oral_trainer_nickname")
}
}
// MARK: - Auth View (root auth screen)
struct AuthView: View {
@ObservedObject var auth: AuthManager
@State private var username = ""
@State private var password = ""
@State private var statusText = ""
@State private var isLoading = false
var onLoginSuccess: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer()
Text("八哥口语")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
Text("请先注册或登录后开始学习")
.font(.system(size: 15))
.foregroundColor(Theme.textMuted)
.padding(.top, 6)
.padding(.bottom, 24)
TextField("用户名", text: $username)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
SecureField("密码(至少 8 位)", text: $password)
.textFieldStyle(.plain)
.padding(12)
.padding(.top, 8)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
Button {
Task { await login() }
} label: {
Text(isLoading ? "登录中..." : "登录")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Theme.accent)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 20)
.disabled(isLoading)
Button {
auth.showingRegister = true
} label: {
Text("注册")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Theme.surface)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 8)
Text("── 或 ──")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.frame(maxWidth: .infinity)
.padding(.top, 12)
Button {
auth.showingSSO = true
} label: {
Text("第一课堂一键登录")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Theme.buttonBlue)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 8)
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.accentLight)
.padding(.top, 14)
}
Spacer()
}
.padding(.horizontal, 28)
.background(Theme.background.ignoresSafeArea())
.fullScreenCover(isPresented: $auth.showingRegister) {
RegisterView(auth: auth) {
onLoginSuccess()
}
}
.fullScreenCover(isPresented: $auth.showingSSO) {
SSOWebView(auth: auth) {
onLoginSuccess()
}
}
.fullScreenCover(isPresented: $auth.showingPhoneBinding) {
PhoneBindingView(auth: auth) {
onLoginSuccess()
}
}
}
private func login() async {
guard !username.isEmpty, !password.isEmpty else {
statusText = "请填写用户名和密码"
return
}
isLoading = true
statusText = "登录中..."
defer { isLoading = false }
do {
let session = try await AuthAPI.login(username: username, password: password)
auth.saveSession(session)
onLoginSuccess()
} catch {
statusText = "失败:\(error.localizedDescription)"
}
}
}
// MARK: - Register View
struct RegisterView: View {
@ObservedObject var auth: AuthManager
@Environment(\.dismiss) private var dismiss
@State private var username = ""
@State private var password = ""
@State private var nickname = ""
@State private var phone = ""
@State private var statusText = ""
@State private var isLoading = false
var onLoginSuccess: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("注册账号")
.font(.system(size: 28, weight: .bold))
.foregroundColor(.white)
Text("填写以下信息完成注册")
.font(.system(size: 14))
.foregroundColor(Theme.textMuted)
.padding(.top, 6)
.padding(.bottom, 24)
fieldLabel("用户名")
TextField("3-50 位字母、数字或下划线", text: $username)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
fieldLabel("密码")
SecureField("至少 8 位", text: $password)
.textFieldStyle(.plain)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
fieldLabel("姓名")
TextField("你的真实姓名或昵称", text: $nickname)
.textFieldStyle(.plain)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
fieldLabel("手机号")
TextField("选填", text: $phone)
.textFieldStyle(.plain)
.keyboardType(.phonePad)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
Button {
Task { await register() }
} label: {
Text(isLoading ? "注册中..." : "注册")
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Theme.accent)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 20)
.disabled(isLoading)
Button {
dismiss()
} label: {
Text("返回登录")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Theme.surface)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 8)
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.accentLight)
.padding(.top, 14)
}
}
.padding(.horizontal, 28)
.padding(.top, 60)
}
.background(Theme.background.ignoresSafeArea())
}
private func fieldLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.top, 12)
.padding(.bottom, 4)
}
private func register() async {
guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return }
guard password.count >= 8 else { statusText = "密码至少 8 位"; return }
guard !nickname.isEmpty else { statusText = "请填写姓名"; return }
isLoading = true
statusText = "注册中..."
defer { isLoading = false }
do {
let session = try await AuthAPI.register(
username: username, password: password, nickname: nickname, phone: phone
)
auth.saveSession(session)
dismiss()
onLoginSuccess()
} catch {
statusText = "注册失败:\(error.localizedDescription)"
}
}
}
// MARK: - SSO WebView
import WebKit
struct SSOWebView: View {
@ObservedObject var auth: AuthManager
@Environment(\.dismiss) private var dismiss
var onLoginSuccess: () -> Void
var body: some View {
VStack(spacing: 0) {
Button {
dismiss()
} label: {
Text("取消登录")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Theme.surface)
.foregroundColor(.white)
}
SSOWebViewRepresentable()
Button {
readJwtAndLogin()
} label: {
Text("已登录,点击继续")
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Theme.accent)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(8)
}
.background(Theme.background.ignoresSafeArea())
}
private func readJwtAndLogin() {
// Read JWT from WKWebView's localStorage via JavaScript
SSOWebViewHolder.shared.readToken { jwt in
guard let jwt = jwt, !jwt.isEmpty, jwt != "null" else {
DispatchQueue.main.async {
// Toast
}
return
}
Task {
await exchangeJwt(jwt)
}
}
}
private func exchangeJwt(_ jwt: String) async {
do {
let result = try await AuthAPI.ssoExchange(jwtToken: jwt)
await MainActor.run {
dismiss()
if !result.phoneBound {
auth.ssoPendingToken = result.token
auth.showingPhoneBinding = true
} else {
auth.saveSession(AuthSession(token: result.token, user: result.user))
onLoginSuccess()
}
}
} catch {
await MainActor.run {
dismiss()
}
}
}
}
// MARK: - SSO WebView Representable
struct SSOWebViewRepresentable: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
SSOWebViewHolder.shared.webView = webView
if let url = URL(string: "https://d1kt.cn/login") {
webView.load(URLRequest(url: url))
}
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
decisionHandler(.allow)
}
}
}
// MARK: - Holder for WebView reference
final class SSOWebViewHolder {
static let shared = SSOWebViewHolder()
weak var webView: WKWebView?
func readToken(completion: @escaping (String?) -> Void) {
guard let webView = webView else {
completion(nil)
return
}
webView.evaluateJavaScript(
"(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
) { result, _ in
let token = (result as? String) ?? ""
completion(token.isEmpty || token == "null" ? nil : token)
}
}
}
// MARK: - Phone Binding View
struct PhoneBindingView: View {
@ObservedObject var auth: AuthManager
@Environment(\.dismiss) private var dismiss
@State private var phone = ""
@State private var statusText = ""
@State private var isLoading = false
var onLoginSuccess: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("绑定手机号")
.font(.system(size: 28, weight: .bold))
.foregroundColor(.white)
Text("已通过第一课堂登录,请绑定手机号后继续")
.font(.system(size: 14))
.foregroundColor(Theme.textMuted)
.padding(.top, 6)
.padding(.bottom, 24)
fieldLabel("手机号")
TextField("请输入 11 位手机号", text: $phone)
.textFieldStyle(.plain)
.keyboardType(.phonePad)
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
Button {
Task { await bindPhone() }
} label: {
Text(isLoading ? "绑定中..." : "绑定并继续")
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Theme.accent)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.top, 20)
.disabled(isLoading)
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 13))
.foregroundColor(Theme.accentLight)
.padding(.top, 14)
}
Spacer()
}
.padding(.horizontal, 28)
.padding(.top, 60)
.background(Theme.background.ignoresSafeArea())
}
private func fieldLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.bottom, 4)
}
private func bindPhone() async {
let cleaned = phone.trimmingCharacters(in: .whitespaces)
guard cleaned.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else {
statusText = "请输入有效的 11 位手机号"
return
}
guard let token = auth.ssoPendingToken else {
statusText = "登录状态已过期,请重新登录"
return
}
isLoading = true
statusText = "绑定中..."
defer { isLoading = false }
do {
try await AuthAPI.ssoBindPhone(token: token, phone: cleaned)
auth.showingPhoneBinding = false
auth.ssoPendingToken = nil
auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: "")))
dismiss()
onLoginSuccess()
} catch {
statusText = "绑定失败:\(error.localizedDescription)"
}
}
}

View File

@@ -3,33 +3,69 @@ import UIKit
import UniformTypeIdentifiers
struct RootView: View {
@StateObject private var auth = AuthManager()
@StateObject private var controller = PlayerController()
@StateObject private var catalogVM = CatalogViewModel()
@State private var activeModule: Module = .train
@State private var activeModule: Module = .course
@State private var continuousPlayback = false
@State private var showImporter = false
@State private var showFullscreenPlayer = false
@State private var courses: [Course] = []
@State private var currentCourse: Course?
@State private var courseStatusText = "正在同步"
@State private var showCourseGuardToast = false
enum Module {
case course
case train
case test
case mine
}
var body: some View {
if auth.isLoggedIn {
mainContent
} else {
AuthView(auth: auth) {
Task { await loadCourses() }
}
}
}
private var mainContent: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 0) {
header
moduleTabs
if activeModule == .train {
switch activeModule {
case .course:
coursePage
case .train:
TrainingView(
controller: controller,
catalogVM: catalogVM,
continuousPlayback: $continuousPlayback,
onFullscreen: enterFullscreen
)
} else {
case .test:
TestView(controller: controller, onFullscreen: enterFullscreen)
case .mine:
minePage
}
}
if showCourseGuardToast {
VStack {
Spacer()
Text("请先选择课程")
.font(.system(size: 14))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.overlay)
.clipShape(RoundedRectangle(cornerRadius: 8))
Spacer().frame(height: 80)
}
}
}
@@ -37,6 +73,9 @@ struct RootView: View {
.onAppear {
catalogVM.controller = controller
controller.setContinuousPlayback(continuousPlayback)
if auth.isLoggedIn && courses.isEmpty {
Task { await loadCourses() }
}
}
.onChange(of: activeModule) { module in
if module == .train {
@@ -70,18 +109,24 @@ struct RootView: View {
Text("八哥口语")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
Text("外语跟读训练神器")
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
.font(.system(size: 15))
.foregroundColor(Theme.textMuted)
}
Spacer()
headerButton("导入") {
showImporter = true
if activeModule == .train {
headerButton("导入") {
showImporter = true
}
}
headerButton("刷新") {
Task {
await catalogVM.load()
}
Task { await loadCourses() }
}
headerButton("退出") {
auth.logout()
currentCourse = nil
courses = []
activeModule = .course
}
}
.padding(.horizontal, 18)
@@ -105,30 +150,150 @@ struct RootView: View {
private var moduleTabs: some View {
HStack(spacing: 0) {
moduleTab("课程", module: .course)
moduleTab("训练", module: .train)
moduleTab("测试", module: .test)
moduleTab("我的", module: .mine)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func moduleTab(_ label: String, module: Module) -> some View {
let selected = activeModule == module
return Button {
activeModule = module
Button {
if module != .course && currentCourse == nil {
showCourseGuardToast = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
showCourseGuardToast = false
}
} else {
activeModule = module
}
} label: {
Text(label)
.font(.system(size: 15, weight: .bold))
.foregroundColor(selected ? .white : Theme.textMuted)
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(selected ? Theme.buttonBlue : Theme.surface)
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.padding(2)
}
// MARK: - Course Page
private var coursePage: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("课程首页")
.font(.system(size: 26, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
Text("选择一门课程开始训练")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
.padding(.top, 4)
Text(courseStatusText)
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
.padding(.top, 8)
if courses.isEmpty {
Text("暂无课程")
.font(.system(size: 14))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
.padding(.top, 20)
} else {
LazyVStack(spacing: 10) {
ForEach(courses) { course in
courseCard(course)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
}
.padding(.top, 12)
}
}
private func courseCard(_ course: Course) -> some View {
let isSelected = currentCourse?.videoHash == course.videoHash
return Button {
selectCourse(course)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(course.title)
.font(.system(size: 16, weight: .bold))
.foregroundColor(isSelected ? Theme.textDark : .white)
.lineLimit(2)
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
.font(.system(size: 13))
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(isSelected ? Theme.selected : Theme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
private func selectCourse(_ course: Course) {
currentCourse = course
let video = course.toVideoSummary()
catalogVM.select(video)
activeModule = .train
}
// MARK: - Mine Page
private var minePage: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
.font(.system(size: 26, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.top, 12)
if let course = currentCourse {
Text("当前课程:\(course.title)")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
}
Text("此页面显示当前课程的测试记录和配音分享")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.padding(.horizontal, 16)
}
}
}
// MARK: - Load Courses
private func loadCourses() async {
courseStatusText = "正在同步"
do {
courses = try await AuthAPI.courses(token: auth.token)
courseStatusText = "\(courses.count) 个课程"
} catch {
courseStatusText = "课程加载失败:\(error.localizedDescription)"
}
}
// MARK: - Fullscreen
private func enterFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
updateSupportedInterfaceOrientations()