From 55fad2c0cd1b852b26d0e3c64b3d97101d72e6bc Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sat, 26 Sep 2026 10:05:48 +0800 Subject: [PATCH] add a refresh action --- .../sample/CourseCatalogController.kt | 103 +++++++++++++++++- .../oraltrainer/sample/MainActivity.kt | 2 + .../oraltrainer/sample/SampleBindings.kt | 1 + .../oraltrainer/sample/SampleScreenActions.kt | 1 + .../oraltrainer/sample/TrainingScreenState.kt | 14 +++ .../oraltrainer/sample/ui/CourseScreen.kt | 33 ++++-- ios/OralTrainer/Models/TrainingModels.swift | 13 +++ ios/OralTrainer/Views/CatalogView.swift | 47 ++++++++ ios/OralTrainer/Views/RootView.swift | 73 +++++++++++-- 9 files changed, 267 insertions(+), 20 deletions(-) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt index 093ddef..b267afd 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt @@ -29,20 +29,41 @@ internal class CourseCatalogController( } fun loadCatalog() { + loadCatalog(refreshBoundaries = false) + } + + fun refreshCourses() { + if (screenState.catalogIsRefreshing) return + loadCatalog(refreshBoundaries = true) + } + + private fun loadCatalog(refreshBoundaries: Boolean) { + val generation = screenState.beginCatalogRequest() + screenState.catalogIsRefreshing = refreshBoundaries screenState.catalogStatus = "正在同步" host.onCatalogStatus(screenState.catalogStatus) host.onCatalogLoadingPlaceholder() sdk.userApi.courses(tokenProvider()) { result -> + if (!screenState.isCurrentCatalogRequest(generation)) return@courses 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() + if (refreshBoundaries) { + screenState.catalogStatus = "${courses.size} 个课程,正在刷新句界" + host.onCatalogStatus(screenState.catalogStatus) + host.onCatalogChanged() + refreshAllCourseBoundaries(generation, index = 0, succeeded = 0, failed = 0) + } else { + screenState.catalogIsRefreshing = false + screenState.catalogStatus = "${screenState.catalogVideos.size} 个课程" + host.onCatalogStatus(screenState.catalogStatus) + host.onCatalogChanged() + } }, onFailure = { + screenState.catalogIsRefreshing = false screenState.catalogStatus = "课程加载失败:${it.message}" host.onCatalogStatus(screenState.catalogStatus) screenState.courseEnrollment.clear() @@ -52,6 +73,67 @@ internal class CourseCatalogController( } } + private fun refreshAllCourseBoundaries( + generation: Int, + index: Int, + succeeded: Int, + failed: Int, + ) { + if (!screenState.isCurrentCatalogRequest(generation)) return + val videos = screenState.catalogVideos + if (index >= videos.size) { + screenState.catalogIsRefreshing = false + screenState.catalogStatus = when { + failed == 0 -> "${videos.size} 个课程 · 句界已刷新" + succeeded == 0 -> "${videos.size} 个课程 · 句界刷新失败" + else -> "${videos.size} 个课程 · 句界部分刷新(失败 $failed)" + } + host.onCatalogStatus(screenState.catalogStatus) + host.onCatalogChanged() + return + } + + val video = videos[index] + screenState.catalogStatus = + "正在刷新句界 ${index + 1}/${videos.size}" + host.onCatalogStatus(screenState.catalogStatus) + sdk.sentenceBoundaryApi.fetch(video.videoHash, object : SentenceBoundaryApiCallback { + override fun onSuccess(result: SentenceBoundaryApiResult) { + if (!screenState.isCurrentCatalogRequest(generation)) return + screenState.courseBoundaries[result.videoHash] = result + updateCatalogVideo(video.videoHash, result) + if (screenState.activeItemId == result.videoHash) { + controller.updateSentences(result.videoHash, result.sentences) + screenState.currentSentenceCount = result.sentences.size + host.refreshPlayerUi() + } + host.onCatalogChanged() + refreshAllCourseBoundaries(generation, index + 1, succeeded + 1, failed) + } + + override fun onError(error: Throwable) { + if (!screenState.isCurrentCatalogRequest(generation)) return + refreshAllCourseBoundaries(generation, index + 1, succeeded, failed + 1) + } + }) + } + + private fun updateCatalogVideo(videoHash: String, result: SentenceBoundaryApiResult) { + screenState.catalogVideos = screenState.catalogVideos.map { video -> + if (video.videoHash != videoHash) { + video + } else { + video.copy( + durationMs = result.durationMs, + sentenceCount = result.sentences.size, + ) + } + } + if (screenState.currentCourse?.videoHash == videoHash) { + screenState.currentCourse = screenState.catalogVideos.firstOrNull { it.videoHash == videoHash } + } + } + fun loadUserData(target: Module, onMine: () -> Unit) { when (target) { Module.COURSE -> loadCatalog() @@ -99,9 +181,15 @@ internal class CourseCatalogController( host.onPlayerLoading(video) host.onCatalogChanged() - controller.loadItem(video.toTrainingMediaItem()) - screenState.currentSentenceCount = video.sentenceCount - refreshSentenceBoundaries(video, background = false) + val cachedBoundary = screenState.courseBoundaries[video.videoHash] + controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty())) + screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount + if (cachedBoundary == null) { + refreshSentenceBoundaries(video, background = false) + } else { + host.refreshPlayerUi() + host.onStatus("课程已就绪:${cachedBoundary.sentences.size} 句") + } } fun refreshSentenceBoundaries( @@ -117,8 +205,11 @@ internal class CourseCatalogController( if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) { return } + screenState.courseBoundaries[result.videoHash] = result + updateCatalogVideo(video.videoHash, result) screenState.currentSentenceCount = result.sentences.size controller.updateSentences(video.videoHash, result.sentences) + host.onCatalogChanged() host.refreshPlayerUi() host.onStatus("课程已就绪:${result.sentences.size} 句") } 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 40191a8..efb6f50 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 @@ -472,6 +472,8 @@ class MainActivity : Activity() { catalogController.selectCourse(video) } + override fun onRefreshCourses() = catalogController.refreshCourses() + override fun onTrainCatalogVideoClick(video: TrainingVideoSummary) { catalogController.useCourseInPlace(video) } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt index 46fa86a..c9dbc1d 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleBindings.kt @@ -19,6 +19,7 @@ internal class SampleBindings { lateinit var progressBar: SeekBar lateinit var catalogList: LinearLayout lateinit var catalogStatusText: TextView + var catalogRefreshButton: Button? = null lateinit var testStatusText: TextView lateinit var scoreSummaryText: TextView lateinit var scoreDetailText: TextView diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt index 276f874..4109086 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SampleScreenActions.kt @@ -8,6 +8,7 @@ internal interface ModuleTabActions { internal interface CourseScreenActions { fun onSelectCourse(video: TrainingVideoSummary) + fun onRefreshCourses() } internal interface SentenceExplainActions { diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt index a76b373..05a1bcc 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/TrainingScreenState.kt @@ -1,5 +1,6 @@ package cn.learningpad.oraltrainer.sample +import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary internal class TrainingScreenState { @@ -7,7 +8,9 @@ internal class TrainingScreenState { var currentSentenceCount = 0 var catalogVideos: List = emptyList() val courseEnrollment = mutableMapOf() + val courseBoundaries = mutableMapOf() var catalogStatus = "正在同步" + var catalogIsRefreshing = false var activeModule = Module.COURSE var currentCourse: TrainingVideoSummary? = null var continuousPlaybackEnabled = false @@ -15,15 +18,26 @@ internal class TrainingScreenState { var landscapeSubtitleVisible = true private var boundaryRequestGeneration = 0 + private var catalogRequestGeneration = 0 fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean = boundaryRequestGeneration == generation && activeItemId == itemId + fun beginCatalogRequest(): Int = ++catalogRequestGeneration + + fun isCurrentCatalogRequest(generation: Int): Boolean = + catalogRequestGeneration == generation + fun clearCourse() { currentCourse = null activeItemId = "" currentSentenceCount = 0 + catalogVideos = emptyList() + courseEnrollment.clear() + courseBoundaries.clear() + catalogStatus = "正在同步" + catalogIsRefreshing = false } } diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt index 898e3b6..568ebaa 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/ui/CourseScreen.kt @@ -3,6 +3,7 @@ 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.LinearLayout @@ -34,18 +35,31 @@ internal class CourseScreen( orientation = LinearLayout.VERTICAL setPadding(16.dp, 8.dp, 16.dp, 16.dp) } + val header = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(16.dp, 0.dp, 16.dp, 0.dp) + addView(TextView(activity).apply { + text = "课程首页" + textSize = 26f + typeface = Typeface.DEFAULT_BOLD + setTextColor(Color.WHITE) + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) + }) + bindings.catalogRefreshButton = widgets.headerButton("刷新课程") { + actions.onRefreshCourses() + }.also { button -> + button.isEnabled = !screenState.catalogIsRefreshing + button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f + addView(button) + } + } 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(header) addView(TextView(activity).apply { text = "选择一门课程开始训练" textSize = 13f @@ -83,5 +97,10 @@ internal class CourseScreen( if (bindings.hasCatalogChrome) { bindings.catalogStatusText.text = text } + bindings.catalogRefreshButton?.let { button -> + button.isEnabled = !screenState.catalogIsRefreshing + button.alpha = if (screenState.catalogIsRefreshing) 0.55f else 1f + button.text = if (screenState.catalogIsRefreshing) "刷新中" else "刷新课程" + } } } diff --git a/ios/OralTrainer/Models/TrainingModels.swift b/ios/OralTrainer/Models/TrainingModels.swift index 7a5d0c5..99da78d 100644 --- a/ios/OralTrainer/Models/TrainingModels.swift +++ b/ios/OralTrainer/Models/TrainingModels.swift @@ -66,6 +66,19 @@ struct TrainingVideoSummary: Identifiable, Decodable { } return URL(string: absolute) } + + func copy(durationMs: Int64? = self.durationMs, sentenceCount: Int = self.sentenceCount) -> Self { + TrainingVideoSummary( + videoHash: videoHash, + title: title, + streamUrl: streamUrl, + durationMs: durationMs, + sizeBytes: sizeBytes, + language: language, + sentenceCount: sentenceCount, + status: status + ) + } } struct TrainingMediaItem: Identifiable, Equatable { diff --git a/ios/OralTrainer/Views/CatalogView.swift b/ios/OralTrainer/Views/CatalogView.swift index 542b654..0d413b6 100644 --- a/ios/OralTrainer/Views/CatalogView.swift +++ b/ios/OralTrainer/Views/CatalogView.swift @@ -10,6 +10,7 @@ final class CatalogViewModel: ObservableObject { private var baseURL = AppConfig.serverBaseURL private var hasLoaded = false + private var boundaryDocuments: [String: SentenceBoundaryDocument] = [:] func loadIfNeeded() async { guard !hasLoaded else { @@ -31,6 +32,38 @@ final class CatalogViewModel: ObservableObject { } } + func preloadBoundaries(for videos: [TrainingVideoSummary]) async -> ( + videos: [TrainingVideoSummary], + succeeded: Int, + failed: Int + ) { + var updatedVideos = videos + var succeeded = 0 + var failed = 0 + + for (index, video) in videos.enumerated() { + do { + let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash) + boundaryDocuments[document.videoHash] = document + updatedVideos[index] = video.copy( + durationMs: document.durationMs, + sentenceCount: document.sentences.count + ) + if activeVideoHash == video.videoHash { + controller?.updateBoundaries(document.sentences) + controller?.boundaryState = .loaded(count: document.sentences.count) + controller?.showTransientMessage("课程已就绪:\(document.sentences.count) 句") + } + succeeded += 1 + } catch { + failed += 1 + } + } + + videos = updatedVideos + return (updatedVideos, succeeded, failed) + } + /// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。 func select(_ video: TrainingVideoSummary) { guard let controller = controller else { @@ -41,6 +74,19 @@ final class CatalogViewModel: ObservableObject { controller.boundaryState = .failed(message: "无法解析视频流地址") return } + if let document = boundaryDocuments[video.videoHash] { + let item = TrainingMediaItem( + id: video.videoHash, + title: video.title, + url: url, + sentences: document.sentences + ) + controller.load(item) + controller.boundaryState = .loaded(count: document.sentences.count) + controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") + return + } + let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url) controller.load(loadingItem) controller.boundaryState = .loading(isLocal: false) @@ -51,6 +97,7 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == video.videoHash else { return } + boundaryDocuments[document.videoHash] = document controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index ef48f4c..70f36e3 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -14,6 +14,7 @@ struct RootView: View { @State private var courses: [Course] = [] @State private var currentCourse: Course? @State private var courseStatusText = "正在同步" + @State private var isRefreshingCourses = false @State private var showCourseGuardToast = false @State private var managedResults: [UserResultSummary] = [] @State private var managedDubShares: [UserDubShareSummary] = [] @@ -208,11 +209,29 @@ struct RootView: View { .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) + HStack(spacing: 10) { + Text("选择一门课程开始训练") + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + Spacer() + Button { + Task { await loadCourses() } + } label: { + Text(isRefreshingCourses ? "刷新中" : "刷新课程") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .frame(height: 32) + .background(Theme.buttonBlue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(isRefreshingCourses) + .opacity(isRefreshingCourses ? 0.6 : 1) + .padding(.trailing, 16) + } + .padding(.horizontal, 16) + .padding(.top, 4) Text(courseStatusText) .font(.system(size: 13)) .foregroundColor(Theme.textMuted) @@ -509,10 +528,50 @@ struct RootView: View { // MARK: - Load Courses private func loadCourses() async { + guard !isRefreshingCourses else { return } + isRefreshingCourses = true + defer { isRefreshingCourses = false } courseStatusText = "正在同步" do { - courses = try await AuthAPI.courses(token: auth.token) - courseStatusText = "\(courses.count) 个课程" + var fetched = try await AuthAPI.courses(token: auth.token) + var boundarySucceeded = 0 + var boundaryFailed = 0 + courses = fetched + if !fetched.isEmpty { + courseStatusText = "\(fetched.count) 个课程,正在刷新句界" + let refreshed = await catalogVM.preloadBoundaries( + for: fetched.map { $0.toVideoSummary() } + ) + boundarySucceeded = refreshed.succeeded + boundaryFailed = refreshed.failed + let refreshedByHash = Dictionary(uniqueKeysWithValues: refreshed.videos.map { ($0.videoHash, $0) }) + fetched = fetched.map { course in + guard let video = refreshedByHash[course.videoHash] else { return course } + return Course( + videoHash: course.videoHash, + title: course.title, + durationMs: video.durationMs, + language: course.language, + sentenceCount: video.sentenceCount, + streamUrl: course.streamUrl, + enrolled: course.enrolled + ) + } + if currentCourse != nil, + let video = refreshed.videos.first(where: { $0.videoHash == currentCourse?.videoHash }) { + currentCourse = fetched.first(where: { $0.videoHash == video.videoHash }) + } + } + courses = fetched + if fetched.isEmpty { + courseStatusText = "0 个课程" + } else if boundaryFailed == 0 { + courseStatusText = "\(fetched.count) 个课程 · 句界已刷新" + } else if boundarySucceeded == 0 { + courseStatusText = "\(fetched.count) 个课程 · 句界刷新失败" + } else { + courseStatusText = "\(fetched.count) 个课程 · 句界部分刷新(失败 \(boundaryFailed))" + } } catch { courseStatusText = "课程加载失败:\(error.localizedDescription)" }