319 lines
11 KiB
Swift
319 lines
11 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()
|
|
@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
|
|
|
|
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, 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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
}
|