added ios module
This commit is contained in:
184
ios/OralTrainer/Views/CatalogView.swift
Normal file
184
ios/OralTrainer/Views/CatalogView.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class CatalogViewModel: ObservableObject {
|
||||
@Published private(set) var videos: [TrainingVideoSummary] = []
|
||||
@Published private(set) var statusText = "正在同步"
|
||||
@Published private(set) var activeVideoHash: String?
|
||||
|
||||
weak var controller: PlayerController?
|
||||
|
||||
private var baseURL = AppConfig.serverBaseURL
|
||||
private var hasLoaded = false
|
||||
|
||||
func loadIfNeeded() async {
|
||||
guard !hasLoaded else {
|
||||
return
|
||||
}
|
||||
hasLoaded = true
|
||||
await load()
|
||||
}
|
||||
|
||||
func load() async {
|
||||
statusText = "正在同步"
|
||||
do {
|
||||
let (fetched, base) = try await VideoCatalogAPI.fetch()
|
||||
baseURL = base
|
||||
videos = fetched
|
||||
statusText = fetched.isEmpty ? "暂无云端课程" : "\(fetched.count) 个云端课程"
|
||||
} catch {
|
||||
statusText = "云端暂不可用:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 与安卓端 loadRemoteVideo() 一致:先以无句边界装载,再拉取句边界后重新装载。
|
||||
func select(_ video: TrainingVideoSummary) {
|
||||
guard let controller = controller else {
|
||||
return
|
||||
}
|
||||
activeVideoHash = video.videoHash
|
||||
guard let url = video.resolvedStreamURL(baseURL: baseURL) else {
|
||||
controller.boundaryState = .failed(message: "无法解析视频流地址")
|
||||
return
|
||||
}
|
||||
let loadingItem = TrainingMediaItem(id: video.videoHash, title: video.title, url: url)
|
||||
controller.load(loadingItem)
|
||||
controller.boundaryState = .loading(isLocal: false)
|
||||
controller.showTransientMessage("正在加载云端课程...")
|
||||
Task {
|
||||
do {
|
||||
let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash)
|
||||
guard activeVideoHash == video.videoHash else {
|
||||
return
|
||||
}
|
||||
let item = TrainingMediaItem(
|
||||
id: video.videoHash,
|
||||
title: video.title,
|
||||
url: url,
|
||||
sentences: document.sentences
|
||||
)
|
||||
controller.load(item)
|
||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||
} catch {
|
||||
guard activeVideoHash == video.videoHash else {
|
||||
return
|
||||
}
|
||||
controller.boundaryState = .failed(message: error.localizedDescription)
|
||||
controller.showTransientMessage("句子边界加载失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 与安卓端 onActivityResult 本地视频流程一致:先装载再按 SHA-256 匹配句边界。
|
||||
func importVideo(at url: URL) async {
|
||||
guard let controller = controller else {
|
||||
return
|
||||
}
|
||||
let didAccess = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccess {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
controller.showTransientMessage("正在获取本地视频的句子边界...")
|
||||
do {
|
||||
let fileName = url.lastPathComponent
|
||||
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let tempURL = cacheDir.appendingPathComponent("import-\(UUID().uuidString)-\(fileName)")
|
||||
let hash = try ImitationAssessor.copyAndHash(from: url, to: tempURL)
|
||||
let item = TrainingMediaItem(
|
||||
id: hash,
|
||||
title: fileName,
|
||||
url: tempURL,
|
||||
isLocalFile: true
|
||||
)
|
||||
activeVideoHash = hash
|
||||
controller.load(item)
|
||||
controller.boundaryState = .loading(isLocal: true)
|
||||
do {
|
||||
let document = try await SentenceBoundaryAPI.fetch(videoHash: hash)
|
||||
guard activeVideoHash == hash else {
|
||||
return
|
||||
}
|
||||
let loadedItem = TrainingMediaItem(
|
||||
id: hash,
|
||||
title: fileName,
|
||||
url: tempURL,
|
||||
sentences: document.sentences,
|
||||
isLocalFile: true
|
||||
)
|
||||
controller.load(loadedItem)
|
||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
||||
} catch {
|
||||
guard activeVideoHash == hash else {
|
||||
return
|
||||
}
|
||||
controller.boundaryState = .failed(message: error.localizedDescription)
|
||||
controller.showTransientMessage("句子边界获取失败,使用默认快退/快进:\(error.localizedDescription)")
|
||||
}
|
||||
} catch {
|
||||
controller.showTransientMessage("本地视频导入失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CatalogView: View {
|
||||
@ObservedObject var vm: CatalogViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
Text("课程")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(vm.statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(vm.videos) { video in
|
||||
catalogCard(video)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.task {
|
||||
await vm.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func catalogCard(_ video: TrainingVideoSummary) -> some View {
|
||||
let selected = vm.activeVideoHash == video.videoHash
|
||||
return Button {
|
||||
vm.select(video)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(video.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(selected ? Theme.textDark : .white)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("\(video.sentenceCount) 句 · \(Theme.formatTime(video.durationMs ?? -1))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(selected ? Theme.accentDeep : Theme.textMuted)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(width: 218, height: 84, alignment: .leading)
|
||||
.background(selected ? Theme.selected : Theme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(selected ? Theme.accent : Theme.border, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
136
ios/OralTrainer/Views/FullscreenPlayerView.swift
Normal file
136
ios/OralTrainer/Views/FullscreenPlayerView.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 与安卓端横屏布局一致:全屏播放 + 半透明句面板 + 退出全屏按钮。
|
||||
struct FullscreenPlayerView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
let onExit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
PlayerSurfaceView(controller: controller)
|
||||
.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.overlay)
|
||||
}
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onExit) {
|
||||
Image(systemName: "arrow.down.right.and.arrow.up.left")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
|
||||
private var progressRow: some View {
|
||||
let duration = controller.snapshot.durationMs
|
||||
return HStack(spacing: 8) {
|
||||
Text(Theme.formatTime(controller.snapshot.positionMs))
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
Slider(
|
||||
value: progressBinding,
|
||||
in: 0...max(1, Double(duration)),
|
||||
onEditingChanged: { editing in
|
||||
if editing {
|
||||
progressDragging = true
|
||||
} else {
|
||||
progressDragging = false
|
||||
controller.seek(to: dragPositionMs)
|
||||
}
|
||||
}
|
||||
)
|
||||
.tint(Theme.accentLight)
|
||||
Text(Theme.formatTime(controller.snapshot.durationMs))
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: {
|
||||
if progressDragging {
|
||||
return Double(dragPositionMs)
|
||||
}
|
||||
return Double(controller.snapshot.positionMs)
|
||||
},
|
||||
set: { newValue in
|
||||
dragPositionMs = Int64(newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var sentenceMetaText: String {
|
||||
switch controller.boundaryState {
|
||||
case .loading(let isLocal):
|
||||
return isLocal ? "句子边界分析中" : "句子边界加载中"
|
||||
case .failed:
|
||||
return "暂无句子边界"
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return "暂无句子边界"
|
||||
}
|
||||
let count = controller.currentItem?.sentences.count ?? 0
|
||||
let countText = count > 0 ? " / \(count)" : ""
|
||||
return "第 \(sentence.index + 1)\(countText) 句 \(Theme.formatTime(sentence.startMs))-\(Theme.formatTime(sentence.endMs))"
|
||||
}
|
||||
}
|
||||
|
||||
private var sentenceTextText: String {
|
||||
switch controller.boundaryState {
|
||||
case .loading(let isLocal):
|
||||
return isLocal ? "正在匹配服务端句子边界" : (controller.currentItem?.title ?? "加载中")
|
||||
case .failed:
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
}
|
||||
if let text = sentence.text, !text.isEmpty {
|
||||
return text
|
||||
}
|
||||
return "当前句子"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
return controller.playbackStatusText(controller.snapshot)
|
||||
}
|
||||
}
|
||||
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal file
194
ios/OralTrainer/Views/PlayerSectionView.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 播放区 + 悬浮信息 + 进度/句面板。训练与全屏横屏共用。
|
||||
struct PlayerSectionView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsContinuousToggle: Bool
|
||||
@Binding var continuousPlayback: Bool
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@State private var dragPositionMs: Int64 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
playerSurface
|
||||
sentencePanel
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var playerSurface: some View {
|
||||
ZStack {
|
||||
PlayerSurfaceView(controller: controller)
|
||||
.aspectRatio(16.0 / 9.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
VStack {
|
||||
HStack(spacing: 8) {
|
||||
overlayPill(timeText)
|
||||
Spacer()
|
||||
Button(action: cycleSpeed) {
|
||||
overlayPill(Theme.formatSpeed(controller.snapshot.speed))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onFullscreen) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.background(Color.black)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
private var sentencePanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(controller.currentItem?.title ?? "未选择课程")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
if showsContinuousToggle {
|
||||
HStack(spacing: 0) {
|
||||
Text("连续播放")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Spacer()
|
||||
Toggle("", isOn: $continuousPlayback)
|
||||
.labelsHidden()
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
Text(sentenceMetaText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
Text(sentenceTextText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
progressRow
|
||||
Text(statusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
private var progressRow: some View {
|
||||
let duration = controller.snapshot.durationMs
|
||||
return HStack(spacing: 8) {
|
||||
Slider(
|
||||
value: progressBinding,
|
||||
in: 0...max(1, Double(duration)),
|
||||
onEditingChanged: { editing in
|
||||
if editing {
|
||||
progressDragging = true
|
||||
} else {
|
||||
progressDragging = false
|
||||
controller.seek(to: dragPositionMs)
|
||||
}
|
||||
}
|
||||
)
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: {
|
||||
if progressDragging {
|
||||
return Double(dragPositionMs)
|
||||
}
|
||||
return Double(controller.snapshot.positionMs)
|
||||
},
|
||||
set: { newValue in
|
||||
dragPositionMs = Int64(newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var timeText: String {
|
||||
let snapshot = controller.snapshot
|
||||
return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))"
|
||||
}
|
||||
|
||||
private var sentenceMetaText: String {
|
||||
switch controller.boundaryState {
|
||||
case .loading(let isLocal):
|
||||
return isLocal ? "句子边界分析中" : "句子边界加载中"
|
||||
case .failed:
|
||||
return "暂无句子边界"
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return "暂无句子边界"
|
||||
}
|
||||
let count = controller.currentItem?.sentences.count ?? 0
|
||||
let countText = count > 0 ? " / \(count)" : ""
|
||||
return "第 \(sentence.index + 1)\(countText) 句 \(Theme.formatTime(sentence.startMs))-\(Theme.formatTime(sentence.endMs))"
|
||||
}
|
||||
}
|
||||
|
||||
private var sentenceTextText: String {
|
||||
switch controller.boundaryState {
|
||||
case .loading(let isLocal):
|
||||
if isLocal {
|
||||
return "正在匹配服务端句子边界"
|
||||
}
|
||||
return controller.currentItem?.title ?? "加载中"
|
||||
case .failed:
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
case .idle, .loaded:
|
||||
guard let sentence = controller.currentSentence else {
|
||||
return controller.currentItem?.title ?? "未选择课程"
|
||||
}
|
||||
if let text = sentence.text, !text.isEmpty {
|
||||
return text
|
||||
}
|
||||
return "当前句子"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
return controller.playbackStatusText(controller.snapshot)
|
||||
}
|
||||
|
||||
private func overlayPill(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func cycleSpeed() {
|
||||
let current = controller.snapshot.speed
|
||||
let cycle: [Float] = [1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
let next = cycle.first { $0 > current + 0.001 } ?? cycle.first ?? 1.0
|
||||
controller.setPlaybackSpeed(next)
|
||||
}
|
||||
}
|
||||
143
ios/OralTrainer/Views/RootView.swift
Normal file
143
ios/OralTrainer/Views/RootView.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@StateObject private var controller = PlayerController()
|
||||
@StateObject private var catalogVM = CatalogViewModel()
|
||||
@State private var activeModule: Module = .train
|
||||
@State private var continuousPlayback = false
|
||||
@State private var showImporter = false
|
||||
@State private var showFullscreenPlayer = false
|
||||
|
||||
enum Module {
|
||||
case train
|
||||
case test
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
moduleTabs
|
||||
if activeModule == .train {
|
||||
TrainingView(
|
||||
controller: controller,
|
||||
catalogVM: catalogVM,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
} else {
|
||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
catalogVM.controller = controller
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
}
|
||||
.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("外语跟读训练神器")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
Spacer()
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
}
|
||||
headerButton("刷新") {
|
||||
Task {
|
||||
await catalogVM.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
.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: .train)
|
||||
moduleTab("测试", module: .test)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||
let selected = activeModule == module
|
||||
return Button {
|
||||
activeModule = module
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
private func enterFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
showFullscreenPlayer = true
|
||||
}
|
||||
|
||||
private func exitFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
showFullscreenPlayer = false
|
||||
}
|
||||
}
|
||||
274
ios/OralTrainer/Views/TestView.swift
Normal file
274
ios/OralTrainer/Views/TestView.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
struct TestView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
let onFullscreen: () -> Void
|
||||
|
||||
@State private var recorder: AVAudioRecorder?
|
||||
@State private var recordingURL: URL?
|
||||
@State private var isRecording = false
|
||||
@State private var isAssessing = false
|
||||
@State private var testStatus = "请先在训练模块选择一个云端课程"
|
||||
@State private var scoreSummary = "尚未评测"
|
||||
@State private var scoreDetail = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
PlayerSectionView(
|
||||
controller: controller,
|
||||
showsContinuousToggle: false,
|
||||
continuousPlayback: .constant(true),
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("朗读评测(测试)")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
Text(testStatus)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentDeep)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(spacing: 8) {
|
||||
sentenceButton("上一句") {
|
||||
controller.seekToPreviousSentence()
|
||||
}
|
||||
sentenceButton("播放原句") {
|
||||
controller.playCurrentSentenceAndStop()
|
||||
controller.showTransientMessage("正在播放当前句子,播放完自动停止")
|
||||
}
|
||||
sentenceButton("下一句") {
|
||||
controller.seekToNextSentence()
|
||||
}
|
||||
}
|
||||
Button(action: toggleRecording) {
|
||||
Text(isRecording ? "停止并评测" : "开始录音")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(isRecording ? Theme.buttonBlue : Theme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAssessing)
|
||||
Text(scoreSummary)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
Text(scoreDetail)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textSubtle)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(18)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Theme.lightBorder, lineWidth: 1)
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.onReceive(controller.$currentItem) { _ in
|
||||
refreshTestUI()
|
||||
}
|
||||
.onReceive(controller.$currentSentence) { _ in
|
||||
refreshTestUI()
|
||||
}
|
||||
}
|
||||
|
||||
private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 40)
|
||||
.background(Theme.buttonBlue)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 录音
|
||||
|
||||
private func toggleRecording() {
|
||||
if isRecording {
|
||||
stopRecordingAndAssess()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private func startRecording() {
|
||||
controller.pause()
|
||||
requestMicrophonePermission { granted in
|
||||
guard granted else {
|
||||
testStatus = "需要麦克风权限才能进行录音评测"
|
||||
return
|
||||
}
|
||||
beginRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestMicrophonePermission(completion: @escaping (Bool) -> Void) {
|
||||
if #available(iOS 17.0, *) {
|
||||
AVAudioApplication.requestRecordPermission { granted in
|
||||
DispatchQueue.main.async {
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
DispatchQueue.main.async {
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginRecording() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("attempt-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 96_000,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
|
||||
]
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
let newRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
||||
guard newRecorder.record() else {
|
||||
testStatus = "录音启动失败:无法开始录制"
|
||||
return
|
||||
}
|
||||
recorder = newRecorder
|
||||
recordingURL = fileURL
|
||||
isRecording = true
|
||||
testStatus = "正在录音…读完当前句子后点击“停止并评测”"
|
||||
} catch {
|
||||
testStatus = "录音启动失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRecordingAndAssess() {
|
||||
guard let currentRecorder = recorder else {
|
||||
return
|
||||
}
|
||||
let duration = currentRecorder.currentTime
|
||||
currentRecorder.stop()
|
||||
restorePlaybackAudioSession()
|
||||
recorder = nil
|
||||
isRecording = false
|
||||
guard let url = recordingURL else {
|
||||
return
|
||||
}
|
||||
recordingURL = nil
|
||||
if duration < 1.0 {
|
||||
testStatus = "录音太短或无法保存"
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return
|
||||
}
|
||||
submitAssessment(recordingURL: url)
|
||||
}
|
||||
|
||||
private func restorePlaybackAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .moviePlayback)
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
// MARK: - 评测
|
||||
|
||||
private func submitAssessment(recordingURL url: URL) {
|
||||
guard let item = controller.currentItem, let sentence = controller.currentSentence else {
|
||||
testStatus = "没有正在学习的句子,无法评测"
|
||||
return
|
||||
}
|
||||
guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
testStatus = "仅云端课程支持朗读评测(本地视频请先上传到服务器)"
|
||||
return
|
||||
}
|
||||
guard !AppConfig.assessmentAPIKey.isEmpty,
|
||||
!AppConfig.assessmentAPIKey.hasPrefix("replace-") else {
|
||||
testStatus = "未配置评分密钥:请在 Config.swift 填入服务器 .env 的 CLIENT_API_KEY"
|
||||
return
|
||||
}
|
||||
testStatus = "正在评测第 \(sentence.index + 1) 句,请稍候…"
|
||||
scoreSummary = "评测中…"
|
||||
scoreDetail = ""
|
||||
isAssessing = true
|
||||
let request = ImitationAssessmentRequest(
|
||||
mediaId: item.id,
|
||||
sentence: sentence,
|
||||
recordingURL: url,
|
||||
locale: sentence.language?.isEmpty == false ? sentence.language : nil
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let result = try await ImitationAssessor.assess(request: request)
|
||||
applyAssessmentResult(result)
|
||||
} catch {
|
||||
testStatus = "评测失败:\(error.localizedDescription)"
|
||||
scoreSummary = "评测失败"
|
||||
}
|
||||
isAssessing = false
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyAssessmentResult(_ result: ImitationAssessmentResult) {
|
||||
testStatus = result.passed == true ? "已通过" : "未通过"
|
||||
scoreSummary = String(format: "总分 %.1f", result.overallScore)
|
||||
var lines: [String] = []
|
||||
if let value = result.contentScore {
|
||||
lines.append(String(format: "内容分 %.1f", value))
|
||||
}
|
||||
if let value = result.fluencyScore {
|
||||
lines.append(String(format: "流畅度 %.1f", value))
|
||||
}
|
||||
if let value = result.durationScore {
|
||||
lines.append(String(format: "时长分 %.1f", value))
|
||||
}
|
||||
if let value = result.pauseScore {
|
||||
lines.append(String(format: "停顿分 %.1f", value))
|
||||
}
|
||||
if let value = result.speechRateScore {
|
||||
lines.append(String(format: "语速分 %.1f", value))
|
||||
}
|
||||
if let text = result.referenceText, !text.isEmpty {
|
||||
lines.append("参考:\(text)")
|
||||
}
|
||||
if let text = result.recognizedText, !text.isEmpty {
|
||||
lines.append("识别:\(text)")
|
||||
}
|
||||
if !result.missingTokens.isEmpty {
|
||||
lines.append("漏读:\(result.missingTokens.joined(separator: "、"))")
|
||||
}
|
||||
if !result.extraTokens.isEmpty {
|
||||
lines.append("多读:\(result.extraTokens.joined(separator: "、"))")
|
||||
}
|
||||
if let feedback = result.feedback, !feedback.isEmpty {
|
||||
lines.append(feedback)
|
||||
}
|
||||
scoreDetail = lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func refreshTestUI() {
|
||||
guard !isRecording, !isAssessing else {
|
||||
return
|
||||
}
|
||||
guard let item = controller.currentItem, let sentence = controller.currentSentence else {
|
||||
testStatus = "请先在训练模块选择一个云端课程"
|
||||
return
|
||||
}
|
||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)"
|
||||
}
|
||||
}
|
||||
77
ios/OralTrainer/Views/Theme.swift
Normal file
77
ios/OralTrainer/Views/Theme.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
static let background = Color(hex: 0x0C0F12)
|
||||
static let surface = Color(hex: 0x1C2228)
|
||||
static let border = Color(hex: 0x323B43)
|
||||
static let buttonBlue = Color(hex: 0x2563EB)
|
||||
static let accent = Color(hex: 0x15B884)
|
||||
static let accentDeep = Color(hex: 0x077656)
|
||||
static let accentLight = Color(hex: 0x5EEAB6)
|
||||
static let selected = Color(hex: 0xE5F8F0)
|
||||
static let progressTrack = Color(hex: 0xE0E7EB)
|
||||
static let progressTrackDark = Color(hex: 0x373E46)
|
||||
static let lightBorder = Color(hex: 0xDAE2E8)
|
||||
static let overlay = Color(red: 9.0 / 255.0, green: 12.0 / 255.0, blue: 16.0 / 255.0, opacity: 0.7)
|
||||
static let textDark = Color(hex: 0x12181F)
|
||||
static let textMuted = Color(hex: 0x97A2AE)
|
||||
static let textSubtle = Color(hex: 0x58626C)
|
||||
static let textOverlay = Color(hex: 0xA3ACB6)
|
||||
|
||||
static func formatTime(_ ms: Int64) -> String {
|
||||
if ms < 0 {
|
||||
return "--:--"
|
||||
}
|
||||
let totalSeconds = ms / 1000
|
||||
let hours = Int(totalSeconds / 3600)
|
||||
let minutes = Int((totalSeconds % 3600) / 60)
|
||||
let seconds = Int(totalSeconds % 60)
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
static func formatSpeed(_ speed: Float) -> String {
|
||||
var value = String(format: "%.2f", speed)
|
||||
while value.hasSuffix("0") {
|
||||
value.removeLast()
|
||||
}
|
||||
if value.hasSuffix(".") {
|
||||
value.removeLast()
|
||||
}
|
||||
return "\(value)x"
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255.0,
|
||||
green: Double((hex >> 8) & 0xFF) / 255.0,
|
||||
blue: Double(hex & 0xFF) / 255.0,
|
||||
opacity: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerController {
|
||||
/// 与安卓端 playbackStatus() 一致的播放状态文案。
|
||||
func playbackStatusText(_ snapshot: PlaybackSnapshot) -> String {
|
||||
let state: String
|
||||
if snapshot.isPlaying {
|
||||
state = "播放中"
|
||||
} else if snapshot.state == .buffering {
|
||||
state = "缓冲中"
|
||||
} else if snapshot.state == .ended {
|
||||
state = "已结束"
|
||||
} else {
|
||||
state = "已暂停"
|
||||
}
|
||||
if let index = snapshot.sentenceIndex {
|
||||
return "\(state) · 第 \(index + 1) 句"
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
31
ios/OralTrainer/Views/TrainingView.swift
Normal file
31
ios/OralTrainer/Views/TrainingView.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrainingView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
@ObservedObject var catalogVM: CatalogViewModel
|
||||
@Binding var continuousPlayback: Bool
|
||||
let onFullscreen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
PlayerSectionView(
|
||||
controller: controller,
|
||||
showsContinuousToggle: true,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
CatalogView(vm: catalogVM)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
.onChange(of: continuousPlayback) { enabled in
|
||||
controller.setContinuousPlayback(enabled)
|
||||
controller.showTransientMessage(
|
||||
enabled
|
||||
? "已开启连续播放,将按句子顺序连续播放"
|
||||
: "已关闭连续播放,将循环播放当前句子"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user