add a refresh action

This commit is contained in:
2026-09-26 10:05:48 +08:00
parent 7fae2bb8c9
commit 55fad2c0cd
9 changed files with 267 additions and 20 deletions

View File

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

View File

@@ -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) 句")

View File

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