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

542 lines
20 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
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)
}
}
}
.onReceive(catalogVM.$activeVideoHash) { videoHash in
syncCurrentCourse(with: videoHash)
}
.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
}
/// 训练页的课程卡片直接操作 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 {
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()
}
}
}
}