682 lines
26 KiB
Swift
682 lines
26 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
struct RootView: View {
|
||
@StateObject private var auth = AuthManager()
|
||
@StateObject private var controller = PlayerController()
|
||
@StateObject private var catalogVM = CatalogViewModel()
|
||
@StateObject private var historyManager = TrainingHistoryManager()
|
||
@State private var activeModule: Module = .course
|
||
@State private var continuousPlayback = false
|
||
@State private var showChineseSubtitles = false
|
||
@State private var chineseTranslationLoading = false
|
||
@State private var chineseTranslationError = ""
|
||
@State private var chineseTranslationTask: Task<Void, Never>?
|
||
@State private var showImporter = false
|
||
@State private var showFullscreenPlayer = false
|
||
@State private var courses: [Course] = []
|
||
@State private var currentCourse: Course?
|
||
@State private var courseStatusText = "正在同步"
|
||
@State private var isRefreshingCourses = false
|
||
@State private var showCourseGuardToast = false
|
||
@State private var managedResults: [UserResultSummary] = []
|
||
@State private var managedDubShares: [UserDubShareSummary] = []
|
||
@State private var managementStatus = ""
|
||
@State private var managementIsLoading = false
|
||
@State private var deletingHistoryID: String?
|
||
|
||
enum Module {
|
||
case course
|
||
case train
|
||
case test
|
||
case mine
|
||
}
|
||
|
||
var body: some View {
|
||
if auth.isLoggedIn {
|
||
mainContent
|
||
} else {
|
||
AuthView(auth: auth) {
|
||
Task { await loadCourses() }
|
||
}
|
||
}
|
||
}
|
||
|
||
private var mainContent: some View {
|
||
ZStack {
|
||
Theme.background.ignoresSafeArea()
|
||
VStack(spacing: 0) {
|
||
header
|
||
moduleTabs
|
||
switch activeModule {
|
||
case .course:
|
||
coursePage
|
||
case .train:
|
||
TrainingView(
|
||
controller: controller,
|
||
catalogVM: catalogVM,
|
||
continuousPlayback: $continuousPlayback,
|
||
showChineseSubtitles: $showChineseSubtitles,
|
||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder,
|
||
onFullscreen: enterFullscreen
|
||
)
|
||
case .test:
|
||
TestView(
|
||
controller: controller,
|
||
historyManager: historyManager,
|
||
onFullscreen: enterFullscreen
|
||
)
|
||
case .mine:
|
||
minePage
|
||
}
|
||
}
|
||
|
||
if showCourseGuardToast {
|
||
VStack {
|
||
Spacer()
|
||
Text("请先选择课程")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 12)
|
||
.background(Theme.overlay)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
Spacer().frame(height: 80)
|
||
}
|
||
}
|
||
}
|
||
.preferredColorScheme(.dark)
|
||
.onAppear {
|
||
catalogVM.controller = controller
|
||
controller.setContinuousPlayback(continuousPlayback)
|
||
if auth.isLoggedIn && courses.isEmpty {
|
||
Task { await loadCourses() }
|
||
}
|
||
}
|
||
.onChange(of: showChineseSubtitles) { enabled in
|
||
if enabled {
|
||
ensureChineseTranslations()
|
||
} else {
|
||
chineseTranslationTask?.cancel()
|
||
chineseTranslationLoading = false
|
||
}
|
||
}
|
||
.onChange(of: controller.currentItem?.id) { _ in
|
||
ensureChineseTranslations()
|
||
}
|
||
.onChange(of: controller.currentItem?.sentences.count) { _ in
|
||
ensureChineseTranslations()
|
||
}
|
||
.onChange(of: activeModule) { module in
|
||
if module == .train {
|
||
controller.setContinuousPlayback(continuousPlayback)
|
||
ensureChineseTranslations()
|
||
} else if module == .test {
|
||
// 与安卓测试页一致:按当前句窗口播放,避免滑进下一句。
|
||
controller.setContinuousPlayback(false)
|
||
} else {
|
||
controller.setContinuousPlayback(true)
|
||
}
|
||
}
|
||
.fileImporter(
|
||
isPresented: $showImporter,
|
||
allowedContentTypes: [.movie],
|
||
allowsMultipleSelection: false
|
||
) { result in
|
||
if case .success(let urls) = result, let url = urls.first {
|
||
Task {
|
||
await catalogVM.importVideo(at: url)
|
||
}
|
||
}
|
||
}
|
||
.onReceive(catalogVM.$activeVideoHash) { videoHash in
|
||
syncCurrentCourse(with: videoHash)
|
||
}
|
||
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
||
FullscreenPlayerView(
|
||
controller: controller,
|
||
showsLessonTitle: activeModule != .train,
|
||
showChineseSubtitles: showChineseSubtitles,
|
||
chineseSubtitlePlaceholder: chineseSubtitlePlaceholder
|
||
) {
|
||
exitFullscreen()
|
||
}
|
||
.onAppear {
|
||
AppDelegate.lockOrientation(.landscape)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 0) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("八哥口语")
|
||
.font(.system(size: 32, weight: .bold))
|
||
.foregroundColor(.white)
|
||
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||
.font(.system(size: 15))
|
||
.foregroundColor(Theme.textMuted)
|
||
}
|
||
Spacer()
|
||
if activeModule == .train {
|
||
headerButton("导入") {
|
||
showImporter = true
|
||
}
|
||
}
|
||
headerButton("刷新") {
|
||
Task { await loadCourses() }
|
||
}
|
||
headerButton("退出") {
|
||
auth.logout()
|
||
currentCourse = nil
|
||
courses = []
|
||
activeModule = .course
|
||
}
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 10)
|
||
}
|
||
|
||
private func headerButton(_ label: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 14)
|
||
.frame(height: 40)
|
||
.background(Theme.buttonBlue)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.leading, 8)
|
||
}
|
||
|
||
private var moduleTabs: some View {
|
||
HStack(spacing: 0) {
|
||
moduleTab("课程", module: .course)
|
||
moduleTab("训练", module: .train)
|
||
moduleTab("测试", module: .test)
|
||
moduleTab("我的", module: .mine)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
private func moduleTab(_ label: String, module: Module) -> some View {
|
||
Button {
|
||
if module != .course && currentCourse == nil {
|
||
showCourseGuardToast = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||
showCourseGuardToast = false
|
||
}
|
||
} else {
|
||
activeModule = module
|
||
}
|
||
} label: {
|
||
Text(label)
|
||
.font(.system(size: 15, weight: .bold))
|
||
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 38)
|
||
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// MARK: - Course Page
|
||
|
||
private var coursePage: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("课程首页")
|
||
.font(.system(size: 26, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 16)
|
||
HStack(spacing: 10) {
|
||
Text("选择一门课程开始训练")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(Theme.textMuted)
|
||
Spacer()
|
||
Button {
|
||
Task { await loadCourses() }
|
||
} label: {
|
||
Text(isRefreshingCourses ? "刷新中" : "刷新课程")
|
||
.font(.system(size: 13, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 32)
|
||
.background(Theme.buttonBlue)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isRefreshingCourses)
|
||
.opacity(isRefreshingCourses ? 0.6 : 1)
|
||
.padding(.trailing, 16)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 4)
|
||
Text(courseStatusText)
|
||
.font(.system(size: 13))
|
||
.foregroundColor(Theme.textMuted)
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
|
||
if courses.isEmpty {
|
||
Text("暂无课程")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(Theme.textMuted)
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 20)
|
||
} else {
|
||
LazyVStack(spacing: 10) {
|
||
ForEach(courses) { course in
|
||
courseCard(course)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.padding(.top, 12)
|
||
}
|
||
}
|
||
|
||
private func courseCard(_ course: Course) -> some View {
|
||
let isSelected = currentCourse?.videoHash == course.videoHash
|
||
return Button {
|
||
selectCourse(course)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(course.title)
|
||
.font(.system(size: 16, weight: .bold))
|
||
.foregroundColor(isSelected ? Theme.textDark : .white)
|
||
.lineLimit(2)
|
||
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(14)
|
||
.background(isSelected ? Theme.selected : Theme.surface)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func selectCourse(_ course: Course) {
|
||
currentCourse = course
|
||
let video = course.toVideoSummary()
|
||
catalogVM.select(video)
|
||
activeModule = .train
|
||
}
|
||
|
||
/// 训练页的课程卡片直接操作 CatalogViewModel;
|
||
/// 这里监听它的 activeVideoHash,保证返回课程页时高亮和“当前课程”同步。
|
||
private func syncCurrentCourse(with videoHash: String?) {
|
||
guard let videoHash else { return }
|
||
if let course = courses.first(where: { $0.videoHash == videoHash }) {
|
||
currentCourse = course
|
||
return
|
||
}
|
||
|
||
// 本地导入视频不在 /courses 列表里;仍建立当前课程信息,避免课程页状态回退。
|
||
if let video = catalogVM.videos.first(where: { $0.videoHash == videoHash }) {
|
||
currentCourse = Course(
|
||
videoHash: video.videoHash,
|
||
title: video.title,
|
||
durationMs: video.durationMs,
|
||
language: video.language,
|
||
sentenceCount: video.sentenceCount,
|
||
streamUrl: video.streamUrl,
|
||
enrolled: false
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Mine Page
|
||
|
||
private var minePage: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
|
||
.font(.system(size: 26, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 12)
|
||
|
||
if let course = currentCourse {
|
||
Text("当前课程:\(course.title)")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 16)
|
||
}
|
||
|
||
Text("此页面显示当前课程的测试记录和配音分享")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(Theme.textMuted)
|
||
.padding(.horizontal, 16)
|
||
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
Task { await loadManagedHistory() }
|
||
} label: {
|
||
Text(managementIsLoading ? "刷新中..." : "刷新记录")
|
||
.font(.system(size: 13, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 14)
|
||
.frame(height: 36)
|
||
.background(Theme.buttonBlue)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(managementIsLoading)
|
||
|
||
if !managementStatus.isEmpty {
|
||
Text(managementStatus)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Theme.textMuted)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
|
||
historyGroup(title: "我的测试结果", isEmpty: managedResults.isEmpty) {
|
||
ForEach(managedResults) { row in
|
||
resultRow(row)
|
||
}
|
||
}
|
||
|
||
historyGroup(title: "我的配音分享", isEmpty: managedDubShares.isEmpty) {
|
||
ForEach(managedDubShares) { row in
|
||
dubShareRow(row)
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 24)
|
||
}
|
||
.task(id: currentCourse?.videoHash) {
|
||
await loadManagedHistory()
|
||
}
|
||
}
|
||
|
||
private func historyGroup<Content: View>(
|
||
title: String,
|
||
isEmpty: Bool,
|
||
@ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(title)
|
||
.font(.system(size: 18, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 16)
|
||
if isEmpty {
|
||
Text("该课程暂无记录")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(Theme.textMuted)
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Theme.surface)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.padding(.horizontal, 16)
|
||
} else {
|
||
LazyVStack(spacing: 8, content: content)
|
||
.padding(.horizontal, 16)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func resultRow(_ row: UserResultSummary) -> some View {
|
||
historyRow(
|
||
title: "第 \(row.sentenceIndex + 1) 句 · \(String(format: "%.0f", row.overallScore)) 分",
|
||
subtitle: "\(row.passed ? "通过" : "未通过") · \(displayDate(row.createdAt))\n\(row.sentenceText)",
|
||
isDeleting: deletingHistoryID == row.id,
|
||
deleteLabel: "删除"
|
||
) {
|
||
await deleteResult(row)
|
||
}
|
||
}
|
||
|
||
private func dubShareRow(_ row: UserDubShareSummary) -> some View {
|
||
historyRow(
|
||
title: row.title,
|
||
subtitle: "\(row.segmentCount) 句 · 平均 \(String(format: "%.0f", row.averageScore)) 分 · \(displayDate(row.createdAt))",
|
||
isDeleting: deletingHistoryID == row.id,
|
||
deleteLabel: "删除"
|
||
) {
|
||
await deleteDubShare(row)
|
||
}
|
||
}
|
||
|
||
private func historyRow(
|
||
title: String,
|
||
subtitle: String,
|
||
isDeleting: Bool,
|
||
deleteLabel: String,
|
||
action: @escaping () async -> Void
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(title)
|
||
.font(.system(size: 15, weight: .bold))
|
||
.foregroundColor(Theme.textDark)
|
||
Text(subtitle)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Theme.textSubtle)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
Task { await action() }
|
||
} label: {
|
||
Text(isDeleting ? "删除中..." : deleteLabel)
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 28)
|
||
.background(isDeleting ? Theme.border : Theme.danger)
|
||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isDeleting)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Theme.surface)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.stroke(Theme.lightBorder, lineWidth: 1)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
private func loadManagedHistory() async {
|
||
guard let course = currentCourse else {
|
||
managementStatus = "请先选择课程"
|
||
managedResults = []
|
||
managedDubShares = []
|
||
return
|
||
}
|
||
managementIsLoading = true
|
||
managementStatus = "正在加载..."
|
||
defer { managementIsLoading = false }
|
||
do {
|
||
async let resultsTask = UserHistoryAPI.results(token: auth.token)
|
||
async let sharesTask = UserHistoryAPI.dubShares(token: auth.token)
|
||
let (allResults, allShares) = try await (resultsTask, sharesTask)
|
||
managedResults = allResults.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
|
||
managedDubShares = allShares.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame }
|
||
managementStatus = "共 \(managedResults.count) 条测试、\(managedDubShares.count) 条配音"
|
||
} catch {
|
||
managementStatus = "记录加载失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func deleteResult(_ row: UserResultSummary) async {
|
||
guard let course = currentCourse, deletingHistoryID == nil else { return }
|
||
deletingHistoryID = row.id
|
||
defer { deletingHistoryID = nil }
|
||
do {
|
||
try await UserHistoryAPI.deleteResult(token: auth.token, attemptId: row.attemptId)
|
||
managedResults.removeAll { $0.id == row.id }
|
||
historyManager.store.removeScore(videoHash: course.videoHash, sentenceIndex: row.sentenceIndex)
|
||
historyManager.notifyHistoryChanged()
|
||
managementStatus = "测试记录已删除"
|
||
} catch {
|
||
managementStatus = "删除失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func deleteDubShare(_ row: UserDubShareSummary) async {
|
||
guard let course = currentCourse, deletingHistoryID == nil else { return }
|
||
deletingHistoryID = row.id
|
||
defer { deletingHistoryID = nil }
|
||
do {
|
||
try await UserHistoryAPI.deleteDubShare(token: auth.token, shareId: row.shareId)
|
||
managedDubShares.removeAll { $0.id == row.id }
|
||
historyManager.store.removeAllAudio(videoHash: course.videoHash)
|
||
historyManager.notifyHistoryChanged()
|
||
managementStatus = "配音分享已删除"
|
||
} catch {
|
||
managementStatus = "删除失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func displayDate(_ value: String) -> String {
|
||
value.replacingOccurrences(of: "T", with: " ").prefix(16).description
|
||
}
|
||
|
||
// MARK: - Load Courses
|
||
|
||
private func loadCourses() async {
|
||
guard !isRefreshingCourses else { return }
|
||
isRefreshingCourses = true
|
||
defer { isRefreshingCourses = false }
|
||
courseStatusText = "正在同步"
|
||
do {
|
||
var fetched = try await AuthAPI.courses(token: auth.token)
|
||
var boundarySucceeded = 0
|
||
var boundaryFailed = 0
|
||
courses = fetched
|
||
if !fetched.isEmpty {
|
||
courseStatusText = "\(fetched.count) 个课程,正在刷新句界"
|
||
let refreshed = await catalogVM.preloadBoundaries(
|
||
for: fetched.map { $0.toVideoSummary() }
|
||
)
|
||
boundarySucceeded = refreshed.succeeded
|
||
boundaryFailed = refreshed.failed
|
||
let refreshedByHash = Dictionary(uniqueKeysWithValues: refreshed.videos.map { ($0.videoHash, $0) })
|
||
fetched = fetched.map { course in
|
||
guard let video = refreshedByHash[course.videoHash] else { return course }
|
||
return Course(
|
||
videoHash: course.videoHash,
|
||
title: course.title,
|
||
durationMs: video.durationMs,
|
||
language: course.language,
|
||
sentenceCount: video.sentenceCount,
|
||
streamUrl: course.streamUrl,
|
||
enrolled: course.enrolled
|
||
)
|
||
}
|
||
if currentCourse != nil,
|
||
let video = refreshed.videos.first(where: { $0.videoHash == currentCourse?.videoHash }) {
|
||
currentCourse = fetched.first(where: { $0.videoHash == video.videoHash })
|
||
}
|
||
}
|
||
courses = fetched
|
||
if fetched.isEmpty {
|
||
courseStatusText = "0 个课程"
|
||
} else if boundaryFailed == 0 {
|
||
courseStatusText = "\(fetched.count) 个课程 · 句界已刷新"
|
||
} else if boundarySucceeded == 0 {
|
||
courseStatusText = "\(fetched.count) 个课程 · 句界刷新失败"
|
||
} else {
|
||
courseStatusText = "\(fetched.count) 个课程 · 句界部分刷新(失败 \(boundaryFailed))"
|
||
}
|
||
} catch {
|
||
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
// MARK: - Fullscreen
|
||
|
||
private func enterFullscreen() {
|
||
AppDelegate.lockOrientation(.landscape)
|
||
showFullscreenPlayer = true
|
||
}
|
||
|
||
private func exitFullscreen() {
|
||
let restored: UIInterfaceOrientationMask =
|
||
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||
AppDelegate.lockOrientation(restored)
|
||
showFullscreenPlayer = false
|
||
}
|
||
|
||
private var chineseSubtitlePlaceholder: String {
|
||
if chineseTranslationLoading {
|
||
return "正在翻译中文字幕…"
|
||
}
|
||
if !chineseTranslationError.isEmpty {
|
||
return chineseTranslationError
|
||
}
|
||
return "暂无中文字幕"
|
||
}
|
||
|
||
private func ensureChineseTranslations() {
|
||
guard showChineseSubtitles, activeModule == .train else {
|
||
return
|
||
}
|
||
guard let item = controller.currentItem else {
|
||
return
|
||
}
|
||
let needsTranslation = item.sentences.contains {
|
||
!($0.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||
($0.zhText ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
guard needsTranslation else {
|
||
chineseTranslationLoading = false
|
||
chineseTranslationError = ""
|
||
return
|
||
}
|
||
chineseTranslationTask?.cancel()
|
||
chineseTranslationLoading = true
|
||
chineseTranslationError = ""
|
||
let mediaId = item.id
|
||
chineseTranslationTask = Task {
|
||
do {
|
||
let expectedIndexes = item.sentences.compactMap { sentence -> Int? in
|
||
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : sentence.index
|
||
}
|
||
let map = try await SentenceBoundaryAPI.loadSubtitles(
|
||
videoHash: mediaId,
|
||
expectedIndexes: expectedIndexes
|
||
)
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
guard controller.currentItem?.id == mediaId else { return }
|
||
let updated = (controller.currentItem?.sentences ?? []).map { sentence in
|
||
sentence.withZhText(map[sentence.index] ?? sentence.zhText)
|
||
}
|
||
controller.updateBoundaries(updated)
|
||
chineseTranslationLoading = false
|
||
chineseTranslationError = ""
|
||
controller.showTransientMessage("已加载中文字幕")
|
||
}
|
||
} catch {
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
chineseTranslationLoading = false
|
||
chineseTranslationError = "中文字幕翻译失败"
|
||
controller.showTransientMessage("中文字幕翻译失败:\(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|