diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt index bfb20f3..57d9fb5 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt @@ -258,9 +258,14 @@ class OralTrainerController internal constructor( fun playCurrentSentenceAndStop() { val sentence = currentSentence() ?: return - player.seekTo(sentence.startMs) + playSentenceAndStop(sentence.startMs, sentence.index) + } + + /** Seek to a known sentence and stop automatically when that sentence ends. */ + fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) { stopAtSentenceEnd = true - stopAtSentenceEndIndex = sentence.index + stopAtSentenceEndIndex = sentenceIndex + player.seekTo(max(0L, startMs)) player.play() refreshSentenceBoundaryGuard() } diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt index d6841fc..9694645 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt @@ -67,6 +67,21 @@ data class UserDubShare( val createdAt: String, ) +data class DubShareSegmentDetail( + val index: Int, + val overallScore: Float, + val recognizedText: String, + val scoreDetails: Map, + val audioUrl: String, +) + +data class DubShareDetail( + val shareId: String, + val videoHash: String, + val title: String, + val segments: List, +) + class UserApiException( val statusCode: Int, responseBody: String, @@ -150,6 +165,13 @@ class UserApi internal constructor( onComplete: (Result>) -> Unit, ): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) } + fun dubShareDetail( + shareId: String, + onComplete: (Result) -> Unit, + ): CancellableRequest = submit(onComplete) { + parseDubShareDetail(JSONObject(request("api/v1/dub-shares/$shareId", method = "GET"))) + } + fun release() { executor.shutdownNow() } @@ -252,6 +274,34 @@ class UserApi internal constructor( ) } + private fun parseDubShareDetail(root: JSONObject): DubShareDetail { + val segmentsJson = root.optJSONArray("segments") ?: JSONArray() + val segments = List(segmentsJson.length()) { index -> + val item = segmentsJson.getJSONObject(index) + val detailsJson = item.optJSONObject("score_details") ?: JSONObject() + val scoreDetails = buildMap { + for (key in detailsJson.keys()) { + if (!detailsJson.isNull(key)) { + put(key, detailsJson.optDouble(key).toFloat()) + } + } + } + DubShareSegmentDetail( + index = item.getInt("index"), + overallScore = item.optDouble("overall_score", 0.0).toFloat(), + recognizedText = item.optString("recognized_text"), + scoreDetails = scoreDetails, + audioUrl = item.getString("audio_url"), + ) + } + return DubShareDetail( + shareId = root.getString("share_id"), + videoHash = root.getString("video_hash"), + title = root.optString("title"), + segments = segments, + ) + } + private fun request( path: String, token: String? = null, diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt index dcd453c..1fe5788 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AssessmentState.kt @@ -9,5 +9,19 @@ internal class AssessmentState { scores[sentenceIndex] = result } + fun get(sentenceIndex: Int): ImitationAssessmentResult? = scores[sentenceIndex] + + fun replaceAll(values: Map) { + scores.clear() + scores.putAll(values) + } + + fun clear() { + scores.clear() + } + fun toMap(): Map = scores.toMap() + + val size: Int + get() = scores.size } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingHistoryStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingHistoryStore.kt new file mode 100644 index 0000000..03fcea4 --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingHistoryStore.kt @@ -0,0 +1,244 @@ +package cn.learningpad.oraltrainer.sample + +import android.content.Context +import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.Locale +import org.json.JSONArray +import org.json.JSONObject + +/** Persists per-course dubbing audio and assessment scores under app filesDir. */ +internal class DubbingHistoryStore(context: Context) { + private val rootDir = File(context.applicationContext.filesDir, "dubbing-history") + + fun loadSnapshot(videoHash: String): Snapshot { + val normalized = normalize(videoHash) ?: return Snapshot() + val dir = videoDir(normalized) + if (!dir.isDirectory) { + return Snapshot() + } + val segments = dir.listFiles() + ?.mapNotNull { file -> + val match = SEGMENT_PATTERN.matchEntire(file.name) ?: return@mapNotNull null + if (!file.isFile || file.length() <= 0L) return@mapNotNull null + match.groupValues[1].toInt() to file + } + ?.toMap() + .orEmpty() + val merged = File(dir, MERGED_FILE_NAME).takeIf { it.isFile && it.length() > 0L } + val scores = readScores(File(dir, SCORES_FILE_NAME)) + return Snapshot(segments = segments, scores = scores, mergedFile = merged) + } + + fun saveSegment(videoHash: String, sentenceIndex: Int, source: File): File { + val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" } + val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a") + if (source.canonicalFile != dest.canonicalFile) { + source.copyTo(dest, overwrite = true) + } + // Any new recording invalidates the previous merged export. + File(videoDir(normalized), MERGED_FILE_NAME).delete() + return dest + } + + fun saveScores(videoHash: String, scores: Map) { + val normalized = normalize(videoHash) ?: return + val file = File(videoDir(normalized), SCORES_FILE_NAME) + val root = JSONObject() + scores.toSortedMap().forEach { (index, result) -> + root.put(index.toString(), encodeScore(result)) + } + file.writeText(root.toString()) + } + + fun saveMerged(videoHash: String, source: File): File { + val normalized = requireNotNull(normalize(videoHash)) { "invalid video hash" } + val dest = File(videoDir(normalized), MERGED_FILE_NAME) + if (source.canonicalFile != dest.canonicalFile) { + source.copyTo(dest, overwrite = true) + } + return dest + } + + fun clear(videoHash: String) { + val normalized = normalize(videoHash) ?: return + videoDir(normalized).deleteRecursively() + } + + /** + * Downloads one public share-audio URL into local history. + * Returns the saved file, or null when the download fails. + */ + fun downloadSegmentAudio( + baseUrl: String, + videoHash: String, + sentenceIndex: Int, + audioUrl: String, + ): File? { + val normalized = normalize(videoHash) ?: return null + val absolute = when { + audioUrl.startsWith("http://") || audioUrl.startsWith("https://") -> audioUrl + audioUrl.startsWith("/") -> baseUrl.trimEnd('/') + audioUrl + else -> baseUrl.trimEnd('/') + "/" + audioUrl + } + var connection: HttpURLConnection? = null + return try { + connection = URL(absolute).openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 60_000 + connection.requestMethod = "GET" + connection.instanceFollowRedirects = true + val code = connection.responseCode + if (code !in 200..299) { + return null + } + val dest = File(videoDir(normalized), "segment-$sentenceIndex.m4a") + connection.inputStream.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } + if (dest.length() <= 0L) { + dest.delete() + null + } else { + dest + } + } catch (_: Throwable) { + null + } finally { + connection?.disconnect() + } + } + + private fun videoDir(videoHash: String): File = + File(rootDir, videoHash).also { it.mkdirs() } + + private fun readScores(file: File): Map { + if (!file.isFile) { + return emptyMap() + } + return runCatching { + val root = JSONObject(file.readText()) + buildMap { + for (key in root.keys()) { + val index = key.toIntOrNull() ?: continue + val value = root.optJSONObject(key) ?: continue + put(index, decodeScore(value)) + } + } + }.getOrDefault(emptyMap()) + } + + data class Snapshot( + val segments: Map = emptyMap(), + val scores: Map = emptyMap(), + val mergedFile: File? = null, + ) + + companion object { + private const val SCORES_FILE_NAME = "scores.json" + private const val MERGED_FILE_NAME = "merged.m4a" + private val SEGMENT_PATTERN = Regex("""segment-(\d+)\.m4a""") + + fun normalize(videoHash: String): String? { + val value = videoHash.trim().lowercase(Locale.US) + return value.takeIf { PlaybackFormat.isVideoHash(it) } + } + + fun encodeScore(result: ImitationAssessmentResult): JSONObject = + JSONObject().apply { + put("overall_score", result.overallScore.toDouble()) + result.pronunciationScore?.let { put("pronunciation_score", it.toDouble()) } + result.fluencyScore?.let { put("fluency_score", it.toDouble()) } + result.completenessScore?.let { put("completeness_score", it.toDouble()) } + result.feedback?.let { put("feedback", it) } + if (result.details.isNotEmpty()) { + put( + "details", + JSONObject().also { details -> + result.details.forEach { (key, value) -> details.put(key, value) } + }, + ) + } + result.passed?.let { put("passed", it) } + result.contentScore?.let { put("content_score", it.toDouble()) } + result.durationScore?.let { put("duration_score", it.toDouble()) } + result.pauseScore?.let { put("pause_score", it.toDouble()) } + result.speechRateScore?.let { put("speech_rate_score", it.toDouble()) } + result.durationRatio?.let { put("duration_ratio", it.toDouble()) } + result.referenceText?.let { put("reference_text", it) } + result.recognizedText?.let { put("recognized_text", it) } + if (result.missingTokens.isNotEmpty()) { + put("missing_tokens", JSONArray(result.missingTokens)) + } + if (result.extraTokens.isNotEmpty()) { + put("extra_tokens", JSONArray(result.extraTokens)) + } + if (result.substitutions.isNotEmpty()) { + put( + "substitutions", + JSONArray().also { array -> + result.substitutions.forEach { (expected, actual) -> + array.put( + JSONObject() + .put("expected", expected) + .put("actual", actual), + ) + } + }, + ) + } + } + + fun decodeScore(root: JSONObject): ImitationAssessmentResult { + val substitutions = buildList { + val array = root.optJSONArray("substitutions") ?: return@buildList + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + add(item.optString("expected") to item.optString("actual")) + } + } + val details = buildMap { + val objectValue = root.optJSONObject("details") ?: return@buildMap + for (key in objectValue.keys()) { + put(key, objectValue.optString(key)) + } + } + return ImitationAssessmentResult( + overallScore = root.optDouble("overall_score", 0.0).toFloat(), + pronunciationScore = root.optNullableFloat("pronunciation_score"), + fluencyScore = root.optNullableFloat("fluency_score"), + completenessScore = root.optNullableFloat("completeness_score"), + feedback = root.optString("feedback").ifBlank { null }, + details = details, + passed = if (root.has("passed")) root.optBoolean("passed") else null, + contentScore = root.optNullableFloat("content_score"), + durationScore = root.optNullableFloat("duration_score"), + pauseScore = root.optNullableFloat("pause_score"), + speechRateScore = root.optNullableFloat("speech_rate_score"), + durationRatio = root.optNullableFloat("duration_ratio"), + referenceText = root.optString("reference_text").ifBlank { null }, + recognizedText = root.optString("recognized_text").ifBlank { null }, + missingTokens = root.optStringList("missing_tokens"), + extraTokens = root.optStringList("extra_tokens"), + substitutions = substitutions, + ) + } + + private fun JSONObject.optNullableFloat(name: String): Float? { + if (!has(name) || isNull(name)) return null + return optDouble(name).toFloat() + } + + private fun JSONObject.optStringList(name: String): List { + val array = optJSONArray(name) ?: return emptyList() + return buildList { + for (index in 0 until array.length()) { + val value = array.optString(index) + if (value.isNotBlank()) add(value) + } + } + } + } +} diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt index 43154b6..8eb7e92 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingPlayback.kt @@ -24,8 +24,8 @@ internal class DubbingPlayback( fun start( audioFile: File, controller: OralTrainerController, - syncToSentence: Boolean, - sentenceStartMs: Long?, + sentenceStartMs: Long, + sentenceIndex: Int, onComplete: () -> Unit, ) { listener?.let(player::removeListener) @@ -45,11 +45,9 @@ internal class DubbingPlayback( } }.also(player::addListener) - if (syncToSentence && sentenceStartMs != null) { - controller.seekTo(sentenceStartMs) - } + // Mute original audio and drive the video for this sentence only. controller.setVolume(0f) - controller.play() + controller.playSentenceAndStop(sentenceStartMs, sentenceIndex) player.setMediaItem(MediaItem.fromUri(Uri.fromFile(audioFile))) player.prepare() player.volume = 1f diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt index 632c215..b72ddbe 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingShareApi.kt @@ -10,11 +10,13 @@ import org.json.JSONObject internal class DubbingShareApi { fun upload( baseUrl: String, + token: String, videoHash: String, videoTitle: String, segments: Map, scores: Map, ): String { + require(token.isNotBlank()) { "Login required." } val previousPolicy = StrictMode.getThreadPolicy() StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build()) val boundary = "oral-trainer-${System.currentTimeMillis()}" @@ -25,6 +27,7 @@ internal class DubbingShareApi { connection.doOutput = true connection.connectTimeout = 15_000 connection.readTimeout = 120_000 + connection.setRequestProperty("Authorization", "Bearer $token") connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") DataOutputStream(connection.outputStream).use { output -> @@ -83,7 +86,9 @@ internal class DubbingShareApi { 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" } + if (code !in 200..299) { + throw IllegalStateException(friendlyShareError(code, body)) + } val response = JSONObject(body).getString("share_id") return "$baseUrl/dub-shares/$response" } finally { @@ -91,4 +96,19 @@ internal class DubbingShareApi { StrictMode.setThreadPolicy(previousPolicy) } } + + private fun friendlyShareError(code: Int, body: String): String { + val detail = runCatching { JSONObject(body).optString("detail") }.getOrNull() + ?.takeIf { it.isNotBlank() } + ?: body + return when { + code == 401 || + detail.contains("Login required", ignoreCase = true) || + detail.contains("sign in", ignoreCase = true) || + detail.contains("Session expired", ignoreCase = true) -> + "还未登录或登录已过期,请重新登录后再分享" + detail.isNotBlank() -> "HTTP $code: $detail" + else -> "HTTP $code" + } + } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt index e1f7fb8..fab15dd 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/DubbingState.kt @@ -8,12 +8,29 @@ internal class DubbingState { private set fun storeSegment(sentenceIndex: Int, file: File) { - segments[sentenceIndex]?.delete() - segments[sentenceIndex] = file + val previous = segments.put(sentenceIndex, file) + if (previous != null && previous.absolutePath != file.absolutePath) { + previous.delete() + } mergedFile = null } - fun setMergedFile(file: File) { + fun setMergedFile(file: File?) { mergedFile = file } + + fun replaceAll(newSegments: Map, merged: File?) { + val keep = newSegments.values.map { it.absolutePath }.toHashSet() + segments.values + .filter { it.absolutePath !in keep } + .forEach { /* keep history files on disk; only drop memory refs */ } + segments.clear() + segments.putAll(newSegments) + mergedFile = merged?.takeIf { it.exists() } + } + + fun clearMemory() { + segments.clear() + mergedFile = null + } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index fdf0039..6589f2e 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -32,6 +32,7 @@ class MainActivity : Activity() { private val dubbingState = DubbingState() private val assessmentState = AssessmentState() private val dubbingShareApi = DubbingShareApi() + private lateinit var dubbingHistoryStore: DubbingHistoryStore private lateinit var attemptRecorder: AttemptRecorder private lateinit var dubbingMerger: DubbingMerger @@ -57,6 +58,7 @@ class MainActivity : Activity() { attemptRecorder = AttemptRecorder(this) dubbingMerger = DubbingMerger(cacheDir) dubbingPlaybackController = DubbingPlayback(this) + dubbingHistoryStore = DubbingHistoryStore(this) configureWindow() authSessionStore = AuthSessionStore(this) @@ -89,6 +91,9 @@ class MainActivity : Activity() { dubbingShareApi = dubbingShareApi, dubbingPlayback = dubbingPlaybackController, assessmentState = assessmentState, + historyStore = dubbingHistoryStore, + userApi = sdk.userApi, + tokenProvider = { authSessionStore.session?.token.orEmpty() }, host = testHost(), ) @@ -109,6 +114,7 @@ class MainActivity : Activity() { screenFactory.renderCatalog() refreshCurrentUi() if (screenState.activeModule == Module.TEST) { + testSession.ensureHistoryForCurrentCourse() testSession.refreshTestUi() } } @@ -268,6 +274,9 @@ class MainActivity : Activity() { } private fun switchModule(module: Module) { + if (module != Module.TEST) { + testSession.stopDubbingIfNeeded() + } screenState.activeModule = module if (module == Module.COURSE || module == Module.MINE) { catalogController.loadUserData(module) { @@ -310,9 +319,14 @@ class MainActivity : Activity() { } override fun onMediaChanged(item: TrainingMediaItem?) { - if (!bindings.hasPlayerChrome) return - bindings.lessonTitleText.text = item?.title ?: "未选择课程" - screenState.currentSentenceCount = item?.sentences?.size ?: 0 + if (bindings.hasPlayerChrome) { + bindings.lessonTitleText.text = item?.title ?: "未选择课程" + screenState.currentSentenceCount = item?.sentences?.size ?: 0 + } + testSession.ensureHistoryForCurrentCourse() + if (screenState.activeModule == Module.TEST) { + testSession.refreshTestUi() + } } override fun onSentenceChanged(sentence: SentenceBoundary?) { @@ -408,15 +422,21 @@ class MainActivity : Activity() { } override fun onPrevSentence() { + testSession.stopDubbingIfNeeded() controller.seekToPreviousSentence() + testSession.refreshTestUi() } override fun onReplaySentence() { + testSession.stopDubbingIfNeeded() + testSession.refreshTestUi() controller.playCurrentSentenceAndStop() } override fun onNextSentence() { + testSession.stopDubbingIfNeeded() controller.seekToNextSentence() + testSession.refreshTestUi() } override fun onToggleRecording() = testSession.toggleRecording() diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt index 935cdf2..a78d97f 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleTheme.kt @@ -19,5 +19,6 @@ internal object SampleTheme { val textDark = Color.rgb(18, 24, 31) val textMuted = Color.rgb(151, 162, 174) val textSubtle = Color.rgb(88, 98, 108) + val danger = Color.rgb(220, 38, 38) val textOverlay = Color.rgb(163, 172, 182) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt index e5ebce9..f5b5e11 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TestSessionController.kt @@ -5,13 +5,18 @@ import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Handler +import android.os.Looper 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 cn.learningpad.oraltrainer.sdk.UserApi import java.io.File import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger /** Recording, assessment, and dubbing flow for the TEST module. */ internal class TestSessionController( @@ -24,6 +29,9 @@ internal class TestSessionController( private val dubbingShareApi: DubbingShareApi, private val dubbingPlayback: DubbingPlayback, private val assessmentState: AssessmentState, + private val historyStore: DubbingHistoryStore, + private val userApi: UserApi, + private val tokenProvider: () -> String, private val host: Host, ) { interface Host { @@ -33,6 +41,15 @@ internal class TestSessionController( fun refreshPlayerUi() } + private val mainHandler = Handler(Looper.getMainLooper()) + private val background = Executors.newSingleThreadExecutor() + private val remoteLoadGeneration = AtomicInteger(0) + + private var loadedVideoHash: String? = null + private var assessing = false + private var sharing = false + private var historyStatusMessage: String? = null + val isDubbingActive: Boolean get() = dubbingPlayback.isActive @@ -47,8 +64,10 @@ internal class TestSessionController( } fun release() { + remoteLoadGeneration.incrementAndGet() attemptRecorder.discard() dubbingPlayback.release() + background.shutdownNow() } fun stopDubbingIfNeeded() { @@ -57,7 +76,29 @@ internal class TestSessionController( } } + /** Loads local (and if needed remote) history for the active cloud course. */ + fun ensureHistoryForCurrentCourse(forceRemote: Boolean = false) { + val videoHash = currentVideoHash() + if (videoHash == null) { + if (loadedVideoHash != null) { + loadedVideoHash = null + dubbingState.clearMemory() + assessmentState.clear() + historyStatusMessage = null + } + return + } + if (loadedVideoHash == videoHash && !forceRemote) { + return + } + loadLocalHistory(videoHash) + if (forceRemote || dubbingState.segments.isEmpty()) { + restoreRemoteHistory(videoHash) + } + } + fun refreshTestUi() { + ensureHistoryForCurrentCourse() val ui = host.bindings if (!ui.hasTestChrome) { return @@ -68,22 +109,40 @@ internal class TestSessionController( val sentence = controller.currentSentence() val currentRecording = sentence?.let { dubbingState.segments[it.index] } val segmentLabel = when { - currentRecording != null -> " · 本句已录音,重录将覆盖" - else -> "" + currentRecording != null -> "本句已有配音,重录将覆盖" + else -> null } - 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} 句,可生成配音" + + if (!assessing && !attemptRecorder.isRecording) { + val score = sentence?.let { assessmentState.get(it.index) } + if (score != null) { + applyAssessmentResult(score, segmentLabel) + } else { + ui.testStatusText.text = when { + item == null || sentence == null -> "请先在训练模块选择课程" + segmentLabel != null -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句) · $segmentLabel" + else -> "评测对象:第 ${sentence.index + 1} 句(共 ${item.sentences.size} 句)" + } + ui.scoreSummaryText.text = "尚未评测" + ui.scoreDetailText.text = "" } } + + refreshDubButtons() + if (!sharing) { + val historyNote = historyStatusMessage + if (dubbingState.mergedFile?.exists() == true) { + ui.dubbingStatusText?.text = historyNote + ?: "已合成 ${dubbingState.segments.size} 句配音,可点击“分享配音”" + } else { + ui.dubbingStatusText?.text = historyNote ?: when (dubbingState.segments.size) { + 0 -> "配音:请先录音(进入本页会自动加载历史配音)" + 1 -> "配音:已保存 1 句,可生成配音" + else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音" + } + } + ui.dubbingStatusText?.setTextColor(SampleTheme.accentDeep) + } } fun toggleRecording() { @@ -95,6 +154,7 @@ internal class TestSessionController( } fun startRecording() { + stopDubbingIfNeeded() controller.pause() val ui = host.bindings if (!ui.hasTestChrome) { @@ -136,18 +196,41 @@ internal class TestSessionController( host.setMainStatus("已停止配音播放") return } - val audio = dubbingState.mergedFile ?: activeAudioForPlayback() ?: return - startDubPlayback(audio) + val sentence = controller.currentSentence() + if (sentence == null) { + host.setMainStatus("请先选择要播放的句子") + return + } + val audio = dubbingState.segments[sentence.index] + if (audio == null || !audio.exists()) { + host.setMainStatus("当前句还没有录音,请先录音后再播放") + refreshTestUi() + return + } + startDubPlayback(audio, sentence.startMs, sentence.index) } fun mergeDubSegments() { + stopDubbingIfNeeded() + val videoHash = currentVideoHash() val segments = dubbingState.segments.toSortedMap().map { (index, file) -> index to file } if (segments.isEmpty()) { + setDubbingStatus("没有可合成的配音") return } try { val output = dubbingMerger.merge(segments) - dubbingState.setMergedFile(output) + val persisted = if (videoHash != null) { + historyStore.saveMerged(videoHash, output).also { + if (it.absolutePath != output.absolutePath) { + output.delete() + } + } + } else { + output + } + dubbingState.setMergedFile(persisted) + historyStatusMessage = null refreshTestUi() setDubbingStatus("已合成 ${segments.size} 句配音,可点击“分享配音”") } catch (error: Throwable) { @@ -160,30 +243,62 @@ internal class TestSessionController( } fun shareMergedDubbing() { + stopDubbingIfNeeded() + if (sharing) { + return + } 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) + val token = tokenProvider().trim() + if (token.isEmpty()) { + setDubbingStatus("分享失败:请先登录后再分享配音") + return + } + val segmentsSnapshot = dubbingState.segments.toMap() + val scoresSnapshot = assessmentState.toMap() + val videoHash = item.id + val videoTitle = item.title + sharing = true + setDubbingStatus("正在生成分享链接,请稍候...", alert = true) + refreshDubButtons() + background.execute { + try { + val shareUrl = dubbingShareApi.upload( + baseUrl = SERVER_BASE_URL, + token = token, + videoHash = videoHash, + videoTitle = videoTitle, + segments = segmentsSnapshot, + scores = scoresSnapshot, + ) + mainHandler.post { + sharing = false + if (!host.bindings.hasTestChrome) { + return@post + } + 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") + refreshDubButtons() + } + } catch (error: Throwable) { + Log.e(TAG, "share failed", error) + val detail = error.message?.takeIf { it.isNotBlank() } + ?: error.javaClass.simpleName + mainHandler.post { + sharing = false + setDubbingStatus("分享失败:$detail", alert = true) + refreshDubButtons() + } } - activity.startActivity(Intent.createChooser(intent, "分享配音")) - setDubbingStatus("已生成分享链接:$shareUrl") - } catch (error: Throwable) { - Log.e(TAG, "share failed", error) - setDubbingStatus("分享失败:${error.javaClass.simpleName}: ${error.message.orEmpty()}") } } @@ -195,6 +310,160 @@ internal class TestSessionController( refreshTestUi() } + private fun loadLocalHistory(videoHash: String) { + val snapshot = historyStore.loadSnapshot(videoHash) + dubbingState.replaceAll(snapshot.segments, snapshot.mergedFile) + assessmentState.replaceAll(snapshot.scores) + loadedVideoHash = videoHash + historyStatusMessage = when { + snapshot.segments.isNotEmpty() && snapshot.scores.isNotEmpty() -> + "已加载历史配音 ${snapshot.segments.size} 句、评分 ${snapshot.scores.size} 句" + snapshot.segments.isNotEmpty() -> + "已加载历史配音 ${snapshot.segments.size} 句" + snapshot.scores.isNotEmpty() -> + "已加载历史评分 ${snapshot.scores.size} 句(暂无本地录音)" + else -> null + } + Log.i( + TAG, + "loaded local history video=$videoHash segments=${snapshot.segments.size} scores=${snapshot.scores.size}", + ) + } + + private fun restoreRemoteHistory(videoHash: String) { + val token = tokenProvider().trim() + if (token.isEmpty()) { + return + } + val generation = remoteLoadGeneration.incrementAndGet() + historyStatusMessage = when { + dubbingState.segments.isEmpty() && assessmentState.size == 0 -> "正在同步历史配音…" + else -> historyStatusMessage + } + if (host.bindings.hasTestChrome) { + host.bindings.dubbingStatusText?.text = historyStatusMessage + } + + // Latest score summary per sentence. + userApi.results(token) { result -> + if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) { + return@results + } + result.onSuccess { rows -> + val latestBySentence = linkedMapOf() + rows.filter { it.videoHash.equals(videoHash, ignoreCase = true) } + .sortedBy { it.createdAt } + .forEach { latestBySentence[it.sentenceIndex] = it } + var added = 0 + latestBySentence.forEach { (index, row) -> + if (assessmentState.get(index) == null) { + assessmentState.store( + index, + ImitationAssessmentResult( + overallScore = row.overallScore, + passed = row.passed, + referenceText = row.sentenceText.takeIf { it.isNotBlank() }, + feedback = "来自历史测试记录", + ), + ) + added += 1 + } + } + if (added > 0) { + historyStore.saveScores(videoHash, assessmentState.toMap()) + historyStatusMessage = "已同步历史评分 ${assessmentState.size} 句" + refreshTestUi() + } + } + } + + // Latest shared dubbing audio for this course. + userApi.dubShares(token) { shareResult -> + if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) { + return@dubShares + } + val latestShare = shareResult.getOrNull() + ?.filter { it.videoHash.equals(videoHash, ignoreCase = true) } + ?.maxByOrNull { it.createdAt } + if (latestShare == null) { + if (dubbingState.segments.isEmpty() && assessmentState.size == 0) { + historyStatusMessage = "暂无历史配音,请开始录音" + if (host.bindings.hasTestChrome) { + host.bindings.dubbingStatusText?.text = historyStatusMessage + } + } + return@dubShares + } + if (dubbingState.segments.isNotEmpty()) { + return@dubShares + } + userApi.dubShareDetail(latestShare.shareId) { detailResult -> + if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) { + return@dubShareDetail + } + val detail = detailResult.getOrNull() ?: run { + Log.w(TAG, "dub share detail failed: ${detailResult.exceptionOrNull()?.message}") + return@dubShareDetail + } + background.execute { + val downloaded = linkedMapOf() + val remoteScores = linkedMapOf() + detail.segments.forEach { segment -> + val file = historyStore.downloadSegmentAudio( + baseUrl = SERVER_BASE_URL, + videoHash = videoHash, + sentenceIndex = segment.index, + audioUrl = segment.audioUrl, + ) + if (file != null) { + downloaded[segment.index] = file + } + if (assessmentState.get(segment.index) == null) { + remoteScores[segment.index] = ImitationAssessmentResult( + overallScore = segment.overallScore, + contentScore = segment.scoreDetails["content_score"], + fluencyScore = segment.scoreDetails["fluency_score"], + durationScore = segment.scoreDetails["duration_score"], + pauseScore = segment.scoreDetails["pause_score"], + speechRateScore = segment.scoreDetails["speech_rate_score"], + recognizedText = segment.recognizedText.takeIf { it.isNotBlank() }, + feedback = "来自历史配音分享", + ) + } + } + mainHandler.post { + if (generation != remoteLoadGeneration.get() || loadedVideoHash != videoHash) { + return@post + } + if (downloaded.isNotEmpty()) { + val mergedSegments = dubbingState.segments.toMutableMap() + mergedSegments.putAll(downloaded) + dubbingState.replaceAll(mergedSegments, dubbingState.mergedFile) + } + if (remoteScores.isNotEmpty()) { + val mergedScores = assessmentState.toMap().toMutableMap() + remoteScores.forEach { (index, score) -> + if (mergedScores[index] == null) { + mergedScores[index] = score + } + } + assessmentState.replaceAll(mergedScores) + historyStore.saveScores(videoHash, assessmentState.toMap()) + } + historyStatusMessage = when { + downloaded.isNotEmpty() -> + "已从云端恢复配音 ${downloaded.size} 句" + assessmentState.size > 0 -> + "已同步历史评分 ${assessmentState.size} 句" + else -> "暂无可用的历史配音音频" + } + refreshTestUi() + } + } + } + } + } + private fun stopRecordingAndAssess() { if (!attemptRecorder.isRecording) return val ui = host.bindings @@ -215,12 +484,28 @@ internal class TestSessionController( refreshTestUi() return } - dubbingState.storeSegment(sentenceIndex, file) - submitAssessment(Uri.fromFile(file)) + val videoHash = currentVideoHash() + val persisted = if (videoHash != null) { + try { + historyStore.saveSegment(videoHash, sentenceIndex, file).also { + if (it.absolutePath != file.absolutePath) { + file.delete() + } + } + } catch (error: Throwable) { + Log.e(TAG, "persist segment failed", error) + file + } + } else { + file + } + dubbingState.storeSegment(sentenceIndex, persisted) + historyStatusMessage = null + submitAssessment(Uri.fromFile(persisted), sentenceIndex) refreshTestUi() } - private fun submitAssessment(recordingUri: Uri) { + private fun submitAssessment(recordingUri: Uri, sentenceIndex: Int) { val ui = host.bindings val item = controller.currentTrainingItem() val sentence = controller.currentSentence() @@ -236,6 +521,7 @@ internal class TestSessionController( ui.testStatusText.text = "未配置评分密钥:请在 AppConfig 填入服务器 .env 的 CLIENT_API_KEY" return } + assessing = true ui.testStatusText.text = "正在评测第 ${sentence.index + 1} 句,请稍候…" ui.scoreSummaryText.text = "评测中…" ui.scoreDetailText.text = "" @@ -247,13 +533,24 @@ internal class TestSessionController( metadata = mapOf("video_hash" to item.id), callback = object : ImitationAssessmentCallback { override fun onResult(result: ImitationAssessmentResult) { + assessing = false + assessmentState.store(sentenceIndex, result) + currentVideoHash()?.let { hash -> + historyStore.saveScores(hash, assessmentState.toMap()) + } if (host.bindings.hasTestChrome) { applyAssessmentResult(result) + refreshDubButtons() + host.bindings.dubbingStatusText?.text = when (dubbingState.segments.size) { + 0 -> "配音:请先录音" + 1 -> "配音:已保存 1 句,可生成配音" + else -> "配音:已保存 ${dubbingState.segments.size} 句,可生成配音" + } } - assessmentState.store(sentence.index, result) } override fun onError(error: Throwable) { + assessing = false if (host.bindings.hasTestChrome) { host.bindings.testStatusText.text = "评测失败:${error.message.orEmpty()}" host.bindings.scoreSummaryText.text = "评测失败" @@ -263,9 +560,17 @@ internal class TestSessionController( ) } - private fun applyAssessmentResult(result: ImitationAssessmentResult) { + private fun applyAssessmentResult( + result: ImitationAssessmentResult, + segmentLabel: String? = null, + ) { val ui = host.bindings - ui.testStatusText.text = if (result.passed == true) "已通过" else "未通过" + val passLabel = when (result.passed) { + true -> "已通过" + false -> "未通过" + null -> "已评测" + } + ui.testStatusText.text = listOfNotNull(passLabel, segmentLabel).joinToString(" · ") ui.scoreSummaryText.text = String.format(Locale.US, "总分 %.1f", result.overallScore) ui.scoreDetailText.text = buildString { result.contentScore?.let { appendLine(String.format(Locale.US, "内容分 %.1f", it)) } @@ -302,49 +607,51 @@ internal class TestSessionController( private fun refreshDubButtons() { val ui = host.bindings + if (!ui.hasTestChrome) { + return + } val dubbingActive = dubbingPlayback.isActive - ui.playDubButton.isEnabled = dubbingState.segments.isNotEmpty() && !dubbingActive + val currentSentenceAudio = controller.currentSentence()?.let { dubbingState.segments[it.index] } + // Keep the play button tappable while active so the user can stop playback. + ui.playDubButton.isEnabled = dubbingActive || currentSentenceAudio != null 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.shareDubButton.isEnabled = + dubbingState.mergedFile?.exists() == true && !dubbingActive && !sharing ui.playDubButton.text = when { - dubbingActive -> "停止配音" - dubbingState.mergedFile?.exists() == true -> "播放配音" - activeAudio != null -> "播放本句配音" - else -> "播放录音(${dubbingState.segments.size})" + dubbingActive -> "停止播放" + currentSentenceAudio != null -> "播放本句录音" + dubbingState.segments.isNotEmpty() -> "播放本句录音" + else -> "播放录音" } } - 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() + private fun startDubPlayback(audioFile: File, sentenceStartMs: Long, sentenceIndex: Int) { dubbingPlayback.start( audioFile = audioFile, controller = controller, - syncToSentence = dubbingState.mergedFile == null, - sentenceStartMs = sentence?.startMs, + sentenceStartMs = sentenceStartMs, + sentenceIndex = sentenceIndex, ) { refreshTestUi() - host.setMainStatus("配音播放完成") + host.setMainStatus("本句录音播放完成") } refreshTestUi() - host.setMainStatus( - if (dubbingState.mergedFile == null) { - "正在播放当前句配音,原视频画面同步但原声静音" - } else { - "正在播放整段配音,原视频画面同步但原声静音" - }, - ) + host.setMainStatus("正在播放当前句录音,原视频画面同步但原声静音") } - private fun setDubbingStatus(message: String) { - host.bindings.dubbingStatusText?.text = message + private fun setDubbingStatus(message: String, alert: Boolean = false) { + historyStatusMessage = null + host.bindings.dubbingStatusText?.apply { + text = message + setTextColor(if (alert) SampleTheme.danger else SampleTheme.accentDeep) + } + } + + private fun currentVideoHash(): String? { + val id = controller.currentTrainingItem()?.id + ?: screenState.currentCourse?.videoHash + ?: screenState.activeItemId + return DubbingHistoryStore.normalize(id.orEmpty()) } private companion object { diff --git a/ios/OralTrainer/Services/ImitationAssessor.swift b/ios/OralTrainer/Services/ImitationAssessor.swift index ff34376..464e2d8 100644 --- a/ios/OralTrainer/Services/ImitationAssessor.swift +++ b/ios/OralTrainer/Services/ImitationAssessor.swift @@ -171,15 +171,19 @@ enum ImitationAssessor { } body.append(Data("--\(boundary)--\r\n".utf8)) + var headers = [ + "Accept": "application/json", + "Content-Type": "multipart/form-data; boundary=\(boundary)", + ] + if !AppConfig.authToken.isEmpty { + headers["Authorization"] = "Bearer \(AppConfig.authToken)" + } let result = try await HTTPClient.execute( baseURL: AppConfig.serverBaseURL, path: "api/v1/dub-shares", method: "POST", timeout: 120, - headers: [ - "Accept": "application/json", - "Content-Type": "multipart/form-data; boundary=\(boundary)", - ], + headers: headers, body: body ) guard (200...299).contains(result.statusCode) else { diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift index b12d4b5..cdba569 100644 --- a/ios/OralTrainer/Views/TestView.swift +++ b/ios/OralTrainer/Views/TestView.swift @@ -58,13 +58,16 @@ struct TestView: View { } HStack(spacing: 8) { sentenceButton("上一句") { + stopDubPlayback(message: nil) controller.seekToPreviousSentence() } sentenceButton("播放原句") { + stopDubPlayback(message: nil) controller.playCurrentSentenceAndStop() controller.showTransientMessage("正在播放当前句子,播放完自动停止") } sentenceButton("下一句") { + stopDubPlayback(message: nil) controller.seekToNextSentence() } } @@ -80,19 +83,24 @@ struct TestView: View { .buttonStyle(.plain) .disabled(isAssessing) HStack(spacing: 8) { - dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { + dubButton( + dubbingPlayback ? "停止播放" : "播放本句录音", + enabled: dubbingPlayback || (controller.currentSentence.flatMap { dubSegments[$0.index] } != nil) + ) { toggleDubPlayback() } dubButton("生成配音", enabled: !dubRawSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) { + stopDubPlayback(message: nil) generateDubShareAudio() } dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) { + stopDubPlayback(message: nil) shareMergedDub() } } Text(dubbingStatus) .font(.system(size: 13)) - .foregroundColor(Theme.accentDeep) + .foregroundColor(isSharingDub ? Theme.danger : Theme.accentDeep) .frame(maxWidth: .infinity, alignment: .leading) Text(scoreSummary) .font(.system(size: 22, weight: .bold)) @@ -159,6 +167,7 @@ struct TestView: View { // MARK: - 录音 private func toggleRecording() { + stopDubPlayback(message: nil) if isRecording { stopRecordingAndAssess() } else { @@ -394,32 +403,40 @@ struct TestView: View { private func toggleDubPlayback() { if dubbingPlayback { - stopDubPlayback(message: "已停止配音播放") + stopDubPlayback(message: "已停止录音播放") return } - let sentence = controller.currentSentence - let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] } - guard let audioURL else { return } - if mergedDubURL == nil, let sentence = sentence { - controller.seek(to: sentence.startMs) + guard let sentence = controller.currentSentence else { + dubbingStatus = "请先选择要播放的句子" + return } + guard let audioURL = dubSegments[sentence.index] else { + dubbingStatus = "当前句还没有录音,请先录音后再播放" + return + } + // Always play the current sentence recording only (never the merged full track). + stopDubPlayback(message: nil) + controller.seek(to: sentence.startMs) controller.pause() controller.setVolume(0) let player = dubPlayer ?? AVPlayer() dubPlayer = player - player.replaceCurrentItem(with: AVPlayerItem(url: audioURL)) + let item = AVPlayerItem(url: audioURL) + player.replaceCurrentItem(with: item) dubbingPlayback = true - dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音" + dubbingStatus = "正在播放当前句录音" player.play() - if dubPlaybackObserver == nil { - dubPlaybackObserver = NotificationCenter.default.addObserver( - forName: AVPlayerItem.didPlayToEndTimeNotification, - object: player.currentItem, - queue: .main - ) { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - stopDubPlayback(message: "配音播放完成") - } + if let dubPlaybackObserver { + NotificationCenter.default.removeObserver(dubPlaybackObserver) + self.dubPlaybackObserver = nil + } + dubPlaybackObserver = NotificationCenter.default.addObserver( + forName: AVPlayerItem.didPlayToEndTimeNotification, + object: item, + queue: .main + ) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + stopDubPlayback(message: "本句录音播放完成") } } } @@ -468,7 +485,7 @@ struct TestView: View { return } isSharingDub = true - dubbingStatus = "正在生成分享链接…" + dubbingStatus = "正在生成分享链接,请稍候..." let request = DubShareUploadRequest( videoHash: item.id, title: item.title, diff --git a/ios/OralTrainer/Views/Theme.swift b/ios/OralTrainer/Views/Theme.swift index fd53041..799cc49 100644 --- a/ios/OralTrainer/Views/Theme.swift +++ b/ios/OralTrainer/Views/Theme.swift @@ -17,6 +17,7 @@ enum Theme { static let textDark = Color(hex: 0x12181F) static let textMuted = Color(hex: 0x97A2AE) static let textSubtle = Color(hex: 0x58626C) + static let danger = Color(hex: 0xDC2626) static let textOverlay = Color(hex: 0xA3ACB6) static func formatTime(_ ms: Int64) -> String {