added ios module

This commit is contained in:
2026-08-17 20:13:28 +08:00
parent a8031e2e3b
commit 7c81976b2b
27 changed files with 2767 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
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)
}
}