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? @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( 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 map = try await SentenceBoundaryAPI.translateSubtitles(videoHash: mediaId) 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)") } } } } }