Files
mediaplayer/ios/OralTrainer/Views/CatalogView.swift
2026-08-17 20:13:28 +08:00

185 lines
7.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}