Files
mediaplayer/ios/OralTrainer/Views/RootView.swift
2026-09-07 11:42:53 +08:00

516 lines
19 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 showImporter = false
@State private var showFullscreenPlayer = false
@State private var courses: [Course] = []
@State private var currentCourse: Course?
@State private var courseStatusText = "正在同步"
@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,
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: activeModule) { module in
if module == .train {
controller.setContinuousPlayback(continuousPlayback)
} 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)
}
}
}
.fullScreenCover(isPresented: $showFullscreenPlayer) {
FullscreenPlayerView(controller: controller) {
exitFullscreen()
}
.onAppear(perform: enterFullscreen)
}
}
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)
Text("选择一门课程开始训练")
.font(.system(size: 13))
.foregroundColor(Theme.textMuted)
.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
}
// 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 {
courseStatusText = "正在同步"
do {
courses = try await AuthAPI.courses(token: auth.token)
courseStatusText = "\(courses.count) 个课程"
} catch {
courseStatusText = "课程加载失败:\(error.localizedDescription)"
}
}
// MARK: - Fullscreen
private func enterFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
updateSupportedInterfaceOrientations()
showFullscreenPlayer = true
}
private func exitFullscreen() {
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
updateSupportedInterfaceOrientations()
showFullscreenPlayer = false
}
private func updateSupportedInterfaceOrientations() {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController)
for controller in controllers {
if #available(iOS 16.0, *) {
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
}