added ios module

This commit is contained in:
2026-08-17 20:13:28 +08:00
parent a8031e2e3b
commit 7c81976b2b
27 changed files with 2767 additions and 0 deletions

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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) 句)"
}
}

View 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
}
}

View 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
? "已开启连续播放,将按句子顺序连续播放"
: "已关闭连续播放,将循环播放当前句子"
)
}
}
}