added ios module
This commit is contained in:
184
ios/OralTrainer/Views/CatalogView.swift
Normal file
184
ios/OralTrainer/Views/CatalogView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user