add a refresh action
This commit is contained in:
@@ -29,20 +29,41 @@ internal class CourseCatalogController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadCatalog() {
|
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 = "正在同步"
|
screenState.catalogStatus = "正在同步"
|
||||||
host.onCatalogStatus(screenState.catalogStatus)
|
host.onCatalogStatus(screenState.catalogStatus)
|
||||||
host.onCatalogLoadingPlaceholder()
|
host.onCatalogLoadingPlaceholder()
|
||||||
sdk.userApi.courses(tokenProvider()) { result ->
|
sdk.userApi.courses(tokenProvider()) { result ->
|
||||||
|
if (!screenState.isCurrentCatalogRequest(generation)) return@courses
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { courses ->
|
onSuccess = { courses ->
|
||||||
screenState.catalogVideos = courses.map { it.toVideoSummary() }
|
screenState.catalogVideos = courses.map { it.toVideoSummary() }
|
||||||
screenState.courseEnrollment.clear()
|
screenState.courseEnrollment.clear()
|
||||||
courses.forEach { screenState.courseEnrollment[it.videoHash] = it.enrolled }
|
courses.forEach { screenState.courseEnrollment[it.videoHash] = it.enrolled }
|
||||||
screenState.catalogStatus = "${screenState.catalogVideos.size} 个课程"
|
if (refreshBoundaries) {
|
||||||
host.onCatalogStatus(screenState.catalogStatus)
|
screenState.catalogStatus = "${courses.size} 个课程,正在刷新句界"
|
||||||
host.onCatalogChanged()
|
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 = {
|
onFailure = {
|
||||||
|
screenState.catalogIsRefreshing = false
|
||||||
screenState.catalogStatus = "课程加载失败:${it.message}"
|
screenState.catalogStatus = "课程加载失败:${it.message}"
|
||||||
host.onCatalogStatus(screenState.catalogStatus)
|
host.onCatalogStatus(screenState.catalogStatus)
|
||||||
screenState.courseEnrollment.clear()
|
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) {
|
fun loadUserData(target: Module, onMine: () -> Unit) {
|
||||||
when (target) {
|
when (target) {
|
||||||
Module.COURSE -> loadCatalog()
|
Module.COURSE -> loadCatalog()
|
||||||
@@ -99,9 +181,15 @@ internal class CourseCatalogController(
|
|||||||
host.onPlayerLoading(video)
|
host.onPlayerLoading(video)
|
||||||
host.onCatalogChanged()
|
host.onCatalogChanged()
|
||||||
|
|
||||||
controller.loadItem(video.toTrainingMediaItem())
|
val cachedBoundary = screenState.courseBoundaries[video.videoHash]
|
||||||
screenState.currentSentenceCount = video.sentenceCount
|
controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty()))
|
||||||
refreshSentenceBoundaries(video, background = false)
|
screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount
|
||||||
|
if (cachedBoundary == null) {
|
||||||
|
refreshSentenceBoundaries(video, background = false)
|
||||||
|
} else {
|
||||||
|
host.refreshPlayerUi()
|
||||||
|
host.onStatus("课程已就绪:${cachedBoundary.sentences.size} 句")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshSentenceBoundaries(
|
fun refreshSentenceBoundaries(
|
||||||
@@ -117,8 +205,11 @@ internal class CourseCatalogController(
|
|||||||
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
if (!screenState.isCurrentBoundaryRequest(requestGeneration, video.videoHash)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
screenState.courseBoundaries[result.videoHash] = result
|
||||||
|
updateCatalogVideo(video.videoHash, result)
|
||||||
screenState.currentSentenceCount = result.sentences.size
|
screenState.currentSentenceCount = result.sentences.size
|
||||||
controller.updateSentences(video.videoHash, result.sentences)
|
controller.updateSentences(video.videoHash, result.sentences)
|
||||||
|
host.onCatalogChanged()
|
||||||
host.refreshPlayerUi()
|
host.refreshPlayerUi()
|
||||||
host.onStatus("课程已就绪:${result.sentences.size} 句")
|
host.onStatus("课程已就绪:${result.sentences.size} 句")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,6 +472,8 @@ class MainActivity : Activity() {
|
|||||||
catalogController.selectCourse(video)
|
catalogController.selectCourse(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRefreshCourses() = catalogController.refreshCourses()
|
||||||
|
|
||||||
override fun onTrainCatalogVideoClick(video: TrainingVideoSummary) {
|
override fun onTrainCatalogVideoClick(video: TrainingVideoSummary) {
|
||||||
catalogController.useCourseInPlace(video)
|
catalogController.useCourseInPlace(video)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal class SampleBindings {
|
|||||||
lateinit var progressBar: SeekBar
|
lateinit var progressBar: SeekBar
|
||||||
lateinit var catalogList: LinearLayout
|
lateinit var catalogList: LinearLayout
|
||||||
lateinit var catalogStatusText: TextView
|
lateinit var catalogStatusText: TextView
|
||||||
|
var catalogRefreshButton: Button? = null
|
||||||
lateinit var testStatusText: TextView
|
lateinit var testStatusText: TextView
|
||||||
lateinit var scoreSummaryText: TextView
|
lateinit var scoreSummaryText: TextView
|
||||||
lateinit var scoreDetailText: TextView
|
lateinit var scoreDetailText: TextView
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ internal interface ModuleTabActions {
|
|||||||
|
|
||||||
internal interface CourseScreenActions {
|
internal interface CourseScreenActions {
|
||||||
fun onSelectCourse(video: TrainingVideoSummary)
|
fun onSelectCourse(video: TrainingVideoSummary)
|
||||||
|
fun onRefreshCourses()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface SentenceExplainActions {
|
internal interface SentenceExplainActions {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cn.learningpad.oraltrainer.sample
|
package cn.learningpad.oraltrainer.sample
|
||||||
|
|
||||||
|
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
|
||||||
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
|
||||||
|
|
||||||
internal class TrainingScreenState {
|
internal class TrainingScreenState {
|
||||||
@@ -7,7 +8,9 @@ internal class TrainingScreenState {
|
|||||||
var currentSentenceCount = 0
|
var currentSentenceCount = 0
|
||||||
var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||||
val courseEnrollment = mutableMapOf<String, Boolean>()
|
val courseEnrollment = mutableMapOf<String, Boolean>()
|
||||||
|
val courseBoundaries = mutableMapOf<String, SentenceBoundaryApiResult>()
|
||||||
var catalogStatus = "正在同步"
|
var catalogStatus = "正在同步"
|
||||||
|
var catalogIsRefreshing = false
|
||||||
var activeModule = Module.COURSE
|
var activeModule = Module.COURSE
|
||||||
var currentCourse: TrainingVideoSummary? = null
|
var currentCourse: TrainingVideoSummary? = null
|
||||||
var continuousPlaybackEnabled = false
|
var continuousPlaybackEnabled = false
|
||||||
@@ -15,15 +18,26 @@ internal class TrainingScreenState {
|
|||||||
var landscapeSubtitleVisible = true
|
var landscapeSubtitleVisible = true
|
||||||
|
|
||||||
private var boundaryRequestGeneration = 0
|
private var boundaryRequestGeneration = 0
|
||||||
|
private var catalogRequestGeneration = 0
|
||||||
|
|
||||||
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
|
fun beginBoundaryRequest(): Int = ++boundaryRequestGeneration
|
||||||
|
|
||||||
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
|
fun isCurrentBoundaryRequest(generation: Int, itemId: String): Boolean =
|
||||||
boundaryRequestGeneration == generation && activeItemId == itemId
|
boundaryRequestGeneration == generation && activeItemId == itemId
|
||||||
|
|
||||||
|
fun beginCatalogRequest(): Int = ++catalogRequestGeneration
|
||||||
|
|
||||||
|
fun isCurrentCatalogRequest(generation: Int): Boolean =
|
||||||
|
catalogRequestGeneration == generation
|
||||||
|
|
||||||
fun clearCourse() {
|
fun clearCourse() {
|
||||||
currentCourse = null
|
currentCourse = null
|
||||||
activeItemId = ""
|
activeItemId = ""
|
||||||
currentSentenceCount = 0
|
currentSentenceCount = 0
|
||||||
|
catalogVideos = emptyList()
|
||||||
|
courseEnrollment.clear()
|
||||||
|
courseBoundaries.clear()
|
||||||
|
catalogStatus = "正在同步"
|
||||||
|
catalogIsRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cn.learningpad.oraltrainer.sample.ui
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
@@ -34,18 +35,31 @@ internal class CourseScreen(
|
|||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(16.dp, 8.dp, 16.dp, 16.dp)
|
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 {
|
return ScrollView(activity).apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||||
addView(LinearLayout(activity).apply {
|
addView(LinearLayout(activity).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding(0.dp, 12.dp, 0.dp, 16.dp)
|
setPadding(0.dp, 12.dp, 0.dp, 16.dp)
|
||||||
addView(TextView(activity).apply {
|
addView(header)
|
||||||
text = "课程首页"
|
|
||||||
textSize = 26f
|
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
|
||||||
setTextColor(Color.WHITE)
|
|
||||||
setPadding(16.dp, 0.dp, 16.dp, 0.dp)
|
|
||||||
})
|
|
||||||
addView(TextView(activity).apply {
|
addView(TextView(activity).apply {
|
||||||
text = "选择一门课程开始训练"
|
text = "选择一门课程开始训练"
|
||||||
textSize = 13f
|
textSize = 13f
|
||||||
@@ -83,5 +97,10 @@ internal class CourseScreen(
|
|||||||
if (bindings.hasCatalogChrome) {
|
if (bindings.hasCatalogChrome) {
|
||||||
bindings.catalogStatusText.text = text
|
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 "刷新课程"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ struct TrainingVideoSummary: Identifiable, Decodable {
|
|||||||
}
|
}
|
||||||
return URL(string: absolute)
|
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 {
|
struct TrainingMediaItem: Identifiable, Equatable {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
|
|
||||||
private var baseURL = AppConfig.serverBaseURL
|
private var baseURL = AppConfig.serverBaseURL
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
private var boundaryDocuments: [String: SentenceBoundaryDocument] = [:]
|
||||||
|
|
||||||
func loadIfNeeded() async {
|
func loadIfNeeded() async {
|
||||||
guard !hasLoaded else {
|
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() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
/// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
||||||
func select(_ video: TrainingVideoSummary) {
|
func select(_ video: TrainingVideoSummary) {
|
||||||
guard let controller = controller else {
|
guard let controller = controller else {
|
||||||
@@ -41,6 +74,19 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
||||||
return
|
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)
|
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
|
||||||
controller.load(loadingItem)
|
controller.load(loadingItem)
|
||||||
controller.boundaryState = .loading(isLocal: false)
|
controller.boundaryState = .loading(isLocal: false)
|
||||||
@@ -51,6 +97,7 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
guard activeVideoHash == video.videoHash else {
|
guard activeVideoHash == video.videoHash else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
boundaryDocuments[document.videoHash] = document
|
||||||
controller.updateBoundaries(document.sentences)
|
controller.updateBoundaries(document.sentences)
|
||||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct RootView: View {
|
|||||||
@State private var courses: [Course] = []
|
@State private var courses: [Course] = []
|
||||||
@State private var currentCourse: Course?
|
@State private var currentCourse: Course?
|
||||||
@State private var courseStatusText = "正在同步"
|
@State private var courseStatusText = "正在同步"
|
||||||
|
@State private var isRefreshingCourses = false
|
||||||
@State private var showCourseGuardToast = false
|
@State private var showCourseGuardToast = false
|
||||||
@State private var managedResults: [UserResultSummary] = []
|
@State private var managedResults: [UserResultSummary] = []
|
||||||
@State private var managedDubShares: [UserDubShareSummary] = []
|
@State private var managedDubShares: [UserDubShareSummary] = []
|
||||||
@@ -208,11 +209,29 @@ struct RootView: View {
|
|||||||
.font(.system(size: 26, weight: .bold))
|
.font(.system(size: 26, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
Text("选择一门课程开始训练")
|
HStack(spacing: 10) {
|
||||||
.font(.system(size: 13))
|
Text("选择一门课程开始训练")
|
||||||
.foregroundColor(Theme.textMuted)
|
.font(.system(size: 13))
|
||||||
.padding(.horizontal, 16)
|
.foregroundColor(Theme.textMuted)
|
||||||
.padding(.top, 4)
|
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)
|
Text(courseStatusText)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Theme.textMuted)
|
.foregroundColor(Theme.textMuted)
|
||||||
@@ -509,10 +528,50 @@ struct RootView: View {
|
|||||||
// MARK: - Load Courses
|
// MARK: - Load Courses
|
||||||
|
|
||||||
private func loadCourses() async {
|
private func loadCourses() async {
|
||||||
|
guard !isRefreshingCourses else { return }
|
||||||
|
isRefreshingCourses = true
|
||||||
|
defer { isRefreshingCourses = false }
|
||||||
courseStatusText = "正在同步"
|
courseStatusText = "正在同步"
|
||||||
do {
|
do {
|
||||||
courses = try await AuthAPI.courses(token: auth.token)
|
var fetched = try await AuthAPI.courses(token: auth.token)
|
||||||
courseStatusText = "\(courses.count) 个课程"
|
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 {
|
} catch {
|
||||||
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user