import SwiftUI @MainActor final class CatalogViewModel: ObservableObject { @Published private(set) var videos: [TrainingVideoSummary] = [] @Published private(set) var statusText = "正在同步" @Published private(set) var activeVideoHash: String? weak var controller: PlayerController? private var baseURL = AppConfig.serverBaseURL private var hasLoaded = false func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true await load() } func load() async { statusText = "正在同步" do { let (fetched, base) = try await VideoCatalogAPI.fetch() baseURL = base videos = fetched statusText = fetched.isEmpty ? "暂无云端课程" : "\(fetched.count) 个云端课程" } catch { statusText = "云端暂不可用:\(error.localizedDescription)" } } /// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。 func select(_ video: TrainingVideoSummary) { guard let controller = controller else { return } activeVideoHash = video.videoHash guard let url = video.resolvedStreamURL(baseURL: baseURL) else { controller.boundaryState = .failed(message: "无法解析视频流地址") return } let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url) controller.load(loadingItem) controller.boundaryState = .loading(isLocal: false) controller.showTransientMessage("正在加载云端课程...") Task { do { let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash) guard activeVideoHash == video.videoHash else { return } 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) 句") } catch { guard activeVideoHash == video.videoHash else { return } controller.boundaryState = .failed(message: error.localizedDescription) controller.showTransientMessage("句子边界加载失败:\(error.localizedDescription)") } } } /// 与安卓端 onActivityResult 本地视频流程一致:先装载再按 SHA-256 匹配句边界。 func importVideo(at url: URL) async { guard let controller = controller else { return } let didAccess = url.startAccessingSecurityScopedResource() defer { if didAccess { url.stopAccessingSecurityScopedResource() } } controller.showTransientMessage("正在获取本地视频的句子边界...") do { let fileName = url.lastPathComponent let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let tempURL = cacheDir.appendingPathComponent("import-\(UUID().uuidString)-\(fileName)") let hash = try ImitationAssessor.copyAndHash(from: url, to: tempURL) let item = TrainingMediaItem( id: hash, title: fileName, url: tempURL, isLocalFile: true ) activeVideoHash = hash controller.load(item) controller.boundaryState = .loading(isLocal: true) do { let document = try await SentenceBoundaryAPI.fetch(videoHash: hash) guard activeVideoHash == hash else { return } let loadedItem = TrainingMediaItem( id: hash, title: fileName, url: tempURL, sentences: document.sentences, isLocalFile: true ) controller.load(loadedItem) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句") } catch { guard activeVideoHash == hash else { return } controller.boundaryState = .failed(message: error.localizedDescription) controller.showTransientMessage("句子边界获取失败,使用默认快退/快进:\(error.localizedDescription)") } } catch { controller.showTransientMessage("本地视频导入失败:\(error.localizedDescription)") } } } struct CatalogView: View { @ObservedObject var vm: CatalogViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 0) { Text("课程") .font(.system(size: 16, weight: .bold)) .foregroundColor(.white) Spacer() Text(vm.statusText) .font(.system(size: 13)) .foregroundColor(Theme.textMuted) .lineLimit(2) .multilineTextAlignment(.trailing) } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(vm.videos) { video in catalogCard(video) } } .padding(.vertical, 2) } } .padding(.horizontal, 16) .task { await vm.loadIfNeeded() } } private func catalogCard(_ video: TrainingVideoSummary) -> some View { let selected = vm.activeVideoHash == video.videoHash return Button { vm.select(video) } label: { VStack(alignment: .leading, spacing: 6) { Text(video.title) .font(.system(size: 15, weight: .bold)) .foregroundColor(selected ? Theme.textDark : .white) .lineLimit(2) .multilineTextAlignment(.leading) Text("\(video.sentenceCount) 句 · \(Theme.formatTime(video.durationMs ?? -1))") .font(.system(size: 12)) .foregroundColor(selected ? Theme.accentDeep : Theme.textMuted) } .padding(.horizontal, 14) .frame(width: 218, height: 84, alignment: .leading) .background(selected ? Theme.selected : Theme.surface) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(selected ? Theme.accent : Theme.border, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } }