185 lines
7.1 KiB
Swift
185 lines
7.1 KiB
Swift
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)
|
||
}
|
||
}
|