added ios module
This commit is contained in:
12
ios/OralTrainer/AppDelegate.swift
Normal file
12
ios/OralTrainer/AppDelegate.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import UIKit
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
static var orientationLock: UIInterfaceOrientationMask = .portrait
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
supportedInterfaceOrientationsFor window: UIWindow?
|
||||
) -> UIInterfaceOrientationMask {
|
||||
AppDelegate.orientationLock
|
||||
}
|
||||
}
|
||||
20
ios/OralTrainer/Config.swift
Normal file
20
ios/OralTrainer/Config.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConfig {
|
||||
/// 服务端地址:改成你实际部署的域名或 IP。
|
||||
/// 内网/开发环境可填 http://<服务器IP>:<端口>(例如 http://192.168.1.100:80)。
|
||||
static let serverBaseURL = "https://videoservice.d1kt.cn"
|
||||
|
||||
/// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
|
||||
static let assessmentAPIKey = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
/// 与安卓端一致:HTTPS 请求因 TLS 问题失败时自动降级重试 HTTP(仅限内网/私有部署)。
|
||||
static let allowHTTPFallback = true
|
||||
|
||||
static let userAgent = "OralTrainerKit/0.1.0 (iOS)"
|
||||
static let connectTimeout: TimeInterval = 15
|
||||
static let readTimeout: TimeInterval = 30
|
||||
static let assessmentTimeout: TimeInterval = 180
|
||||
|
||||
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#
|
||||
}
|
||||
98
ios/OralTrainer/Models/AssessmentModels.swift
Normal file
98
ios/OralTrainer/Models/AssessmentModels.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
struct ImitationAssessmentRequest {
|
||||
let mediaId: String
|
||||
let sentence: SentenceBoundary
|
||||
let recordingURL: URL
|
||||
let locale: String?
|
||||
}
|
||||
|
||||
struct TextSubstitution: Decodable {
|
||||
let expected: String
|
||||
let actual: String
|
||||
}
|
||||
|
||||
struct ImitationAssessmentResult: Decodable {
|
||||
let attemptId: String?
|
||||
let scoringVersion: String?
|
||||
let overallScore: Float
|
||||
let passed: Bool?
|
||||
let passScore: Float?
|
||||
let contentScore: Float?
|
||||
let completenessScore: Float?
|
||||
let fluencyScore: Float?
|
||||
let pronunciationScore: Float?
|
||||
let prosodyScore: Float?
|
||||
let durationScore: Float?
|
||||
let pauseScore: Float?
|
||||
let speechRateScore: Float?
|
||||
let referenceText: String?
|
||||
let recognizedText: String?
|
||||
let referenceDurationMs: Int64?
|
||||
let referenceSpeechDurationMs: Int64?
|
||||
let studentRecordingDurationMs: Int64?
|
||||
let studentSpeechDurationMs: Int64?
|
||||
let durationRatio: Float?
|
||||
let missingTokens: [String]
|
||||
let extraTokens: [String]
|
||||
let substitutions: [TextSubstitution]
|
||||
let feedback: String?
|
||||
let details: [String: String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case attemptId = "attempt_id"
|
||||
case scoringVersion = "scoring_version"
|
||||
case overallScore = "overall_score"
|
||||
case passed
|
||||
case passScore = "pass_score"
|
||||
case contentScore = "content_score"
|
||||
case completenessScore = "completeness_score"
|
||||
case fluencyScore = "fluency_score"
|
||||
case pronunciationScore = "pronunciation_score"
|
||||
case prosodyScore = "prosody_score"
|
||||
case durationScore = "duration_score"
|
||||
case pauseScore = "pause_score"
|
||||
case speechRateScore = "speech_rate_score"
|
||||
case referenceText = "reference_text"
|
||||
case recognizedText = "recognized_text"
|
||||
case referenceDurationMs = "reference_duration_ms"
|
||||
case referenceSpeechDurationMs = "reference_speech_duration_ms"
|
||||
case studentRecordingDurationMs = "student_recording_duration_ms"
|
||||
case studentSpeechDurationMs = "student_speech_duration_ms"
|
||||
case durationRatio = "duration_ratio"
|
||||
case missingTokens = "missing_tokens"
|
||||
case extraTokens = "extra_tokens"
|
||||
case substitutions
|
||||
case feedback
|
||||
case details
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
attemptId = try container.decodeIfPresent(String.self, forKey: .attemptId)
|
||||
scoringVersion = try container.decodeIfPresent(String.self, forKey: .scoringVersion)
|
||||
overallScore = try container.decodeIfPresent(Float.self, forKey: .overallScore) ?? 0
|
||||
passed = try container.decodeIfPresent(Bool.self, forKey: .passed)
|
||||
passScore = try container.decodeIfPresent(Float.self, forKey: .passScore)
|
||||
contentScore = try container.decodeIfPresent(Float.self, forKey: .contentScore)
|
||||
completenessScore = try container.decodeIfPresent(Float.self, forKey: .completenessScore)
|
||||
fluencyScore = try container.decodeIfPresent(Float.self, forKey: .fluencyScore)
|
||||
pronunciationScore = try container.decodeIfPresent(Float.self, forKey: .pronunciationScore)
|
||||
prosodyScore = try container.decodeIfPresent(Float.self, forKey: .prosodyScore)
|
||||
durationScore = try container.decodeIfPresent(Float.self, forKey: .durationScore)
|
||||
pauseScore = try container.decodeIfPresent(Float.self, forKey: .pauseScore)
|
||||
speechRateScore = try container.decodeIfPresent(Float.self, forKey: .speechRateScore)
|
||||
referenceText = try container.decodeIfPresent(String.self, forKey: .referenceText)
|
||||
recognizedText = try container.decodeIfPresent(String.self, forKey: .recognizedText)
|
||||
referenceDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceDurationMs)
|
||||
referenceSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .referenceSpeechDurationMs)
|
||||
studentRecordingDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentRecordingDurationMs)
|
||||
studentSpeechDurationMs = try container.decodeIfPresent(Int64.self, forKey: .studentSpeechDurationMs)
|
||||
durationRatio = try container.decodeIfPresent(Float.self, forKey: .durationRatio)
|
||||
missingTokens = try container.decodeIfPresent([String].self, forKey: .missingTokens) ?? []
|
||||
extraTokens = try container.decodeIfPresent([String].self, forKey: .extraTokens) ?? []
|
||||
substitutions = try container.decodeIfPresent([TextSubstitution].self, forKey: .substitutions) ?? []
|
||||
feedback = try container.decodeIfPresent(String.self, forKey: .feedback)
|
||||
details = try container.decodeIfPresent([String: String].self, forKey: .details) ?? [:]
|
||||
}
|
||||
}
|
||||
58
ios/OralTrainer/Models/PlaybackModels.swift
Normal file
58
ios/OralTrainer/Models/PlaybackModels.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
enum PlaybackState: String {
|
||||
case idle
|
||||
case buffering
|
||||
case ready
|
||||
case ended
|
||||
}
|
||||
|
||||
struct PlaybackSnapshot: Equatable {
|
||||
var mediaId: String?
|
||||
var positionMs: Int64 = 0
|
||||
var durationMs: Int64 = -1
|
||||
var bufferedPositionMs: Int64 = 0
|
||||
var isPlaying = false
|
||||
var state: PlaybackState = .idle
|
||||
var speed: Float = 1
|
||||
var sentenceIndex: Int?
|
||||
}
|
||||
|
||||
struct PlayerConfig {
|
||||
var sentenceMode = true
|
||||
var defaultSeekStepMs: Int64 = 10_000
|
||||
var autoPlay = false
|
||||
var continuousPlayback = true
|
||||
var minPlaybackSpeed: Float = 0.5
|
||||
var maxPlaybackSpeed: Float = 2.0
|
||||
}
|
||||
|
||||
enum LoopMode {
|
||||
case off
|
||||
case one
|
||||
case all
|
||||
}
|
||||
|
||||
enum SwipeAction {
|
||||
case none
|
||||
case rewind
|
||||
case forward
|
||||
case previousSentence
|
||||
case nextSentence
|
||||
case previousSentenceOrRewind
|
||||
case nextSentenceOrForward
|
||||
}
|
||||
|
||||
enum GestureKind {
|
||||
case singleTap
|
||||
case swipeLeft
|
||||
case swipeRight
|
||||
case longPressSpeed
|
||||
}
|
||||
|
||||
enum BoundaryState: Equatable {
|
||||
case idle
|
||||
case loading(isLocal: Bool)
|
||||
case loaded(count: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
95
ios/OralTrainer/Models/TrainingModels.swift
Normal file
95
ios/OralTrainer/Models/TrainingModels.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
struct SentenceBoundary: Identifiable, Equatable, Decodable {
|
||||
let index: Int
|
||||
let startMs: Int64
|
||||
let endMs: Int64
|
||||
let text: String?
|
||||
let language: String?
|
||||
let referenceSpeechDurationMs: Int64?
|
||||
|
||||
var id: Int { index }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case index, text, language
|
||||
case startMs = "start_ms"
|
||||
case endMs = "end_ms"
|
||||
case referenceSpeechDurationMs = "reference_speech_duration_ms"
|
||||
}
|
||||
}
|
||||
|
||||
struct SentenceBoundaryDocument: Decodable {
|
||||
let videoHash: String
|
||||
let durationMs: Int64
|
||||
let algorithmVersion: String
|
||||
let sentences: [SentenceBoundary]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case videoHash = "video_hash"
|
||||
case durationMs = "duration_ms"
|
||||
case algorithmVersion = "algorithm_version"
|
||||
case sentences
|
||||
}
|
||||
}
|
||||
|
||||
struct TrainingVideoSummary: Identifiable, Decodable {
|
||||
let videoHash: String
|
||||
let title: String
|
||||
let streamUrl: String
|
||||
let durationMs: Int64?
|
||||
let sizeBytes: Int64
|
||||
let language: String?
|
||||
let sentenceCount: Int
|
||||
let status: String
|
||||
|
||||
var id: String { videoHash }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case title, language, status
|
||||
case videoHash = "video_hash"
|
||||
case streamUrl = "stream_url"
|
||||
case durationMs = "duration_ms"
|
||||
case sizeBytes = "size_bytes"
|
||||
case sentenceCount = "sentence_count"
|
||||
}
|
||||
|
||||
/// 与安卓端一致:相对路径基于服务地址解析;服务地址为 http 时强制使用 http 流地址。
|
||||
func resolvedStreamURL(baseURL: String) -> URL? {
|
||||
guard let base = URL(string: baseURL) else { return nil }
|
||||
var absolute = URL(string: streamUrl, relativeTo: base)?.absoluteString ?? streamUrl
|
||||
if base.scheme == "http" {
|
||||
absolute = absolute.replacingOccurrences(
|
||||
of: "https://",
|
||||
with: "http://",
|
||||
options: [.anchored]
|
||||
)
|
||||
}
|
||||
return URL(string: absolute)
|
||||
}
|
||||
}
|
||||
|
||||
struct TrainingMediaItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: URL
|
||||
let sentences: [SentenceBoundary]
|
||||
let isLocalFile: Bool
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
url: URL,
|
||||
sentences: [SentenceBoundary] = [],
|
||||
isLocalFile: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.sentences = sentences
|
||||
self.isLocalFile = isLocalFile
|
||||
}
|
||||
|
||||
static func == (lhs: TrainingMediaItem, rhs: TrainingMediaItem) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
12
ios/OralTrainer/OralTrainerApp.swift
Normal file
12
ios/OralTrainer/OralTrainerApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OralTrainerApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
@@ -0,0 +1,427 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// 与安卓端 OralTrainerController 对齐的播放控制器。
|
||||
/// 所有属性变更都发生在主线程(时间观察器、KVO、UI 回调均为主线程)。
|
||||
final class PlayerController: ObservableObject {
|
||||
@Published private(set) var snapshot = PlaybackSnapshot()
|
||||
@Published private(set) var currentItem: TrainingMediaItem?
|
||||
@Published private(set) var currentSentence: SentenceBoundary?
|
||||
@Published var boundaryState: BoundaryState = .idle
|
||||
@Published var transientMessage: String?
|
||||
|
||||
let player = AVPlayer()
|
||||
|
||||
var config = PlayerConfig()
|
||||
var loopMode: LoopMode = .all
|
||||
|
||||
private var mediaItem: TrainingMediaItem?
|
||||
private var lastSentenceIndex: Int?
|
||||
private var stopAtSentenceEnd = false
|
||||
private var stopAtSentenceEndIndex: Int?
|
||||
private var speedBeforeLongPress: Float?
|
||||
private var transientTask: Task<Void, Never>?
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
private var itemStatusObserver: NSKeyValueObservation?
|
||||
private var timeControlObserver: NSKeyValueObservation?
|
||||
|
||||
init() {
|
||||
configureAudioSession()
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.handleEnded()
|
||||
}
|
||||
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let timeObserver = timeObserver {
|
||||
player.removeTimeObserver(timeObserver)
|
||||
}
|
||||
if let endObserver = endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
itemStatusObserver?.invalidate()
|
||||
timeControlObserver?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - 装载
|
||||
|
||||
func load(_ item: TrainingMediaItem) {
|
||||
lastSentenceIndex = nil
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
mediaItem = item
|
||||
currentItem = item
|
||||
boundaryState = .idle
|
||||
let playerItem = AVPlayerItem(url: item.url)
|
||||
attachItemObservers(to: playerItem)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
if config.autoPlay {
|
||||
player.play()
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
if player.timeControlStatus == .playing {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
player.pause()
|
||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
func seek(to positionMs: Int64) {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
func seekRelative(_ deltaMs: Int64) {
|
||||
seek(to: snapshot.positionMs + deltaMs)
|
||||
}
|
||||
|
||||
func rewind() {
|
||||
seekRelative(-max(0, config.defaultSeekStepMs))
|
||||
}
|
||||
|
||||
func fastForward() {
|
||||
seekRelative(max(0, config.defaultSeekStepMs))
|
||||
}
|
||||
|
||||
// MARK: - 按句跳转
|
||||
|
||||
@discardableResult
|
||||
func seekToPreviousSentence() -> Bool {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let position = snapshot.positionMs
|
||||
let target = item.sentences.reversed().first { $0.startMs < position - 250 }
|
||||
?? item.sentences.first
|
||||
seek(to: target.startMs)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func seekToNextSentence() -> Bool {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
let position = snapshot.positionMs
|
||||
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
||||
return false
|
||||
}
|
||||
seek(to: target.startMs)
|
||||
return true
|
||||
}
|
||||
|
||||
func previousSentenceOrRewind() {
|
||||
if !config.sentenceMode || !seekToPreviousSentence() {
|
||||
rewind()
|
||||
}
|
||||
}
|
||||
|
||||
func nextSentenceOrForward() {
|
||||
if !config.sentenceMode || !seekToNextSentence() {
|
||||
fastForward()
|
||||
}
|
||||
}
|
||||
|
||||
/// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。
|
||||
func playCurrentSentenceAndStop() {
|
||||
guard let sentence = currentSentence else {
|
||||
return
|
||||
}
|
||||
let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
stopAtSentenceEnd = true
|
||||
stopAtSentenceEndIndex = sentence.index
|
||||
play()
|
||||
}
|
||||
|
||||
// MARK: - 设置
|
||||
|
||||
func setContinuousPlayback(_ enabled: Bool) {
|
||||
config.continuousPlayback = enabled
|
||||
}
|
||||
|
||||
func setSentenceMode(_ enabled: Bool) {
|
||||
config.sentenceMode = enabled
|
||||
}
|
||||
|
||||
func setLoopMode(_ mode: LoopMode) {
|
||||
loopMode = mode
|
||||
}
|
||||
|
||||
func setPlaybackSpeed(_ speed: Float) {
|
||||
let clamped = min(config.maxPlaybackSpeed, max(config.minPlaybackSpeed, speed))
|
||||
if player.timeControlStatus == .playing {
|
||||
player.rate = clamped
|
||||
} else {
|
||||
player.defaultRate = clamped
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - 长按变速(与安卓端 GestureControlsConfig 一致)
|
||||
|
||||
func beginLongPressSpeed(atX x: CGFloat, width: CGFloat) {
|
||||
speedBeforeLongPress = snapshot.speed
|
||||
setPlaybackSpeed(speedForTouchX(x, width: width))
|
||||
showTransientMessage("变速播放中")
|
||||
}
|
||||
|
||||
func updateLongPressSpeed(atX x: CGFloat, width: CGFloat) {
|
||||
setPlaybackSpeed(speedForTouchX(x, width: width))
|
||||
}
|
||||
|
||||
func endLongPressSpeed() {
|
||||
if let speed = speedBeforeLongPress {
|
||||
setPlaybackSpeed(speed)
|
||||
}
|
||||
speedBeforeLongPress = nil
|
||||
}
|
||||
|
||||
private func speedForTouchX(_ x: CGFloat, width: CGFloat) -> Float {
|
||||
guard width > 0 else {
|
||||
return 1
|
||||
}
|
||||
let center = width / 2
|
||||
let distanceRatio = abs(x - center) / center
|
||||
let farFromCenter = distanceRatio >= 0.5
|
||||
if x < center {
|
||||
return farFromCenter ? 0.5 : 0.75
|
||||
}
|
||||
return farFromCenter ? 2.0 : 1.5
|
||||
}
|
||||
|
||||
// MARK: - 手势消息
|
||||
|
||||
func handleGesture(_ kind: GestureKind) {
|
||||
switch kind {
|
||||
case .singleTap:
|
||||
togglePlayPause()
|
||||
showTransientMessage("播放状态已切换")
|
||||
case .swipeLeft:
|
||||
previousSentenceOrRewind()
|
||||
showTransientMessage("已跳到上一句")
|
||||
case .swipeRight:
|
||||
nextSentenceOrForward()
|
||||
showTransientMessage("已跳到下一句")
|
||||
case .longPressSpeed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func showTransientMessage(_ message: String) {
|
||||
transientMessage = message
|
||||
transientTask?.cancel()
|
||||
transientTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
self?.transientMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 状态刷新
|
||||
|
||||
private func tick() {
|
||||
guard let item = player.currentItem else {
|
||||
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
|
||||
return
|
||||
}
|
||||
let position = currentPositionMs()
|
||||
enforceSentenceBoundary(positionMs: position)
|
||||
let finalPosition = currentPositionMs()
|
||||
let sentence = sentence(at: finalPosition)
|
||||
if sentence?.index != lastSentenceIndex {
|
||||
lastSentenceIndex = sentence?.index
|
||||
}
|
||||
let duration = durationMs(of: item)
|
||||
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
||||
snapshot = PlaybackSnapshot(
|
||||
mediaId: mediaItem?.id,
|
||||
positionMs: finalPosition,
|
||||
durationMs: duration,
|
||||
bufferedPositionMs: bufferedPositionMs(of: item),
|
||||
isPlaying: player.timeControlStatus == .playing,
|
||||
state: state,
|
||||
speed: currentSpeed(),
|
||||
sentenceIndex: sentence?.index
|
||||
)
|
||||
currentSentence = sentence
|
||||
}
|
||||
|
||||
private func currentPositionMs() -> Int64 {
|
||||
let seconds = player.currentTime().seconds
|
||||
guard seconds.isFinite else {
|
||||
return 0
|
||||
}
|
||||
return max(0, Int64(seconds * 1000))
|
||||
}
|
||||
|
||||
private func durationMs(of item: AVPlayerItem) -> Int64 {
|
||||
let seconds = item.duration.seconds
|
||||
guard seconds.isFinite, seconds > 0 else {
|
||||
return -1
|
||||
}
|
||||
return Int64(seconds * 1000)
|
||||
}
|
||||
|
||||
private func bufferedPositionMs(of item: AVPlayerItem) -> Int64 {
|
||||
guard let range = item.loadedTimeRanges.last?.timeRangeValue else {
|
||||
return 0
|
||||
}
|
||||
let seconds = range.end.seconds
|
||||
guard seconds.isFinite else {
|
||||
return 0
|
||||
}
|
||||
return max(0, Int64(seconds * 1000))
|
||||
}
|
||||
|
||||
private func currentSpeed() -> Float {
|
||||
let rate = player.rate
|
||||
return rate == 0 ? player.defaultRate : rate
|
||||
}
|
||||
|
||||
private func playbackState(of item: AVPlayerItem, positionMs: Int64, durationMs: Int64) -> PlaybackState {
|
||||
if item.status == .failed {
|
||||
return .idle
|
||||
}
|
||||
switch player.timeControlStatus {
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
return .buffering
|
||||
case .playing:
|
||||
return .ready
|
||||
case .paused:
|
||||
if durationMs > 0, positionMs >= durationMs {
|
||||
return .ended
|
||||
}
|
||||
return .ready
|
||||
@unknown default:
|
||||
return .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func sentence(at positionMs: Int64) -> SentenceBoundary? {
|
||||
guard let item = mediaItem else {
|
||||
return nil
|
||||
}
|
||||
return item.sentences.last { sentence in
|
||||
positionMs >= sentence.startMs && positionMs < sentence.endMs
|
||||
}
|
||||
}
|
||||
|
||||
/// 与安卓端一致:非连续播放时到句尾回到句首;playCurrentSentenceAndStop 播完自动暂停。
|
||||
private func enforceSentenceBoundary(positionMs: Int64) {
|
||||
guard config.sentenceMode,
|
||||
player.timeControlStatus == .playing,
|
||||
let item = mediaItem,
|
||||
!item.sentences.isEmpty else {
|
||||
return
|
||||
}
|
||||
if stopAtSentenceEnd {
|
||||
guard let index = stopAtSentenceEndIndex,
|
||||
let sentence = item.sentences.first(where: { $0.index == index }) else {
|
||||
return
|
||||
}
|
||||
if positionMs >= sentence.endMs {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
player.pause()
|
||||
seekRaw(sentence.startMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if config.continuousPlayback {
|
||||
return
|
||||
}
|
||||
guard let anchorIndex = lastSentenceIndex,
|
||||
let anchor = item.sentences.first(where: { $0.index == anchorIndex }) else {
|
||||
return
|
||||
}
|
||||
if positionMs >= anchor.endMs {
|
||||
seekRaw(anchor.startMs)
|
||||
}
|
||||
}
|
||||
|
||||
private func seekRaw(_ positionMs: Int64) {
|
||||
let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600)
|
||||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
private func handleEnded() {
|
||||
guard loopMode != .off else {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
player.play()
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - 观察器
|
||||
|
||||
private func attachItemObservers(to item: AVPlayerItem) {
|
||||
itemStatusObserver?.invalidate()
|
||||
itemStatusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
if item.status == .failed {
|
||||
let message = item.error?.localizedDescription ?? "未知错误"
|
||||
self.showTransientMessage("播放失败:\(message)")
|
||||
}
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .moviePlayback)
|
||||
try? session.setActive(true)
|
||||
}
|
||||
}
|
||||
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal file
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
||||
/// - 单击:播放 / 暂停
|
||||
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
||||
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
||||
struct PlayerSurfaceView: UIViewRepresentable {
|
||||
let controller: PlayerController
|
||||
|
||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||
let view = VideoSurfaceView()
|
||||
view.playerLayer.player = controller.player
|
||||
view.controller = controller
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
||||
uiView.playerLayer.player = controller.player
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoSurfaceView: UIView {
|
||||
weak var controller: PlayerController?
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVPlayerLayer.self
|
||||
}
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
private let minSwipeDistance: CGFloat = 48
|
||||
private let minSwipeVelocity: CGFloat = 160
|
||||
private let longPressTimeout: TimeInterval = 0.5
|
||||
|
||||
private var tapRecognizer: UITapGestureRecognizer!
|
||||
private var panRecognizer: UIPanGestureRecognizer!
|
||||
private var longPressRecognizer: UILongPressGestureRecognizer!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .black
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
panRecognizer.minimumNumberOfTouches = 1
|
||||
panRecognizer.maximumNumberOfTouches = 1
|
||||
longPressRecognizer = UILongPressGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleLongPress)
|
||||
)
|
||||
longPressRecognizer.minimumPressDuration = longPressTimeout
|
||||
|
||||
tapRecognizer.require(toFail: panRecognizer)
|
||||
tapRecognizer.require(toFail: longPressRecognizer)
|
||||
panRecognizer.require(toFail: longPressRecognizer)
|
||||
|
||||
addGestureRecognizer(tapRecognizer)
|
||||
addGestureRecognizer(panRecognizer)
|
||||
addGestureRecognizer(longPressRecognizer)
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func handleTap() {
|
||||
controller?.handleGesture(.singleTap)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard recognizer.state == .ended || recognizer.state == .cancelled else {
|
||||
return
|
||||
}
|
||||
let translation = recognizer.translation(in: self)
|
||||
let velocity = recognizer.velocity(in: self)
|
||||
let dx = translation.x
|
||||
let dy = translation.y
|
||||
guard abs(dx) >= abs(dy),
|
||||
abs(dx) >= minSwipeDistance,
|
||||
abs(velocity.x) >= minSwipeVelocity else {
|
||||
return
|
||||
}
|
||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
||||
}
|
||||
|
||||
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
controller?.beginLongPressSpeed(atX: location.x, width: bounds.width)
|
||||
case .changed:
|
||||
controller?.updateLongPressSpeed(atX: location.x, width: bounds.width)
|
||||
case .ended, .cancelled:
|
||||
controller?.endLongPressSpeed()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.518",
|
||||
"green" : "0.722",
|
||||
"red" : "0.082"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/OralTrainer/Resources/Assets.xcassets/Contents.json
Normal file
6
ios/OralTrainer/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
50
ios/OralTrainer/Resources/Info.plist
Normal file
50
ios/OralTrainer/Resources/Info.plist
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>跟读虫</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要麦克风权限才能进行录音评测</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
133
ios/OralTrainer/Services/HTTPClient.swift
Normal file
133
ios/OralTrainer/Services/HTTPClient.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
enum HTTPClientError: LocalizedError {
|
||||
case invalidURL
|
||||
case badStatus(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "无效的服务地址"
|
||||
case .badStatus(let code, let body):
|
||||
return "HTTP \(code): \(body)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPResult {
|
||||
let statusCode: Int
|
||||
let body: Data
|
||||
let effectiveBaseURL: String
|
||||
}
|
||||
|
||||
enum HTTPClient {
|
||||
static func getJSON<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
baseURL: String,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||
let result = try await execute(
|
||||
baseURL: baseURL,
|
||||
path: path,
|
||||
method: "GET",
|
||||
timeout: timeout,
|
||||
headers: ["Accept": "application/json"]
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, body)
|
||||
}
|
||||
let value = try JSONDecoder().decode(T.self, from: result.body)
|
||||
return (value, result.effectiveBaseURL)
|
||||
}
|
||||
|
||||
static func execute(
|
||||
baseURL: String,
|
||||
path: String,
|
||||
method: String,
|
||||
timeout: TimeInterval,
|
||||
headers: [String: String] = [:],
|
||||
body: Data? = nil
|
||||
) async throws -> HTTPResult {
|
||||
let trimmedBase = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let base = URL(string: trimmedBase), base.scheme != nil, base.host != nil else {
|
||||
throw HTTPClientError.invalidURL
|
||||
}
|
||||
let endpoint = URL(string: path, relativeTo: base) ?? base
|
||||
do {
|
||||
return try await perform(
|
||||
url: endpoint,
|
||||
method: method,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
body: body,
|
||||
effectiveBaseURL: trimmedBase
|
||||
)
|
||||
} catch {
|
||||
// 与安卓端一致:HTTPS 因 TLS 问题失败时,降级用同一请求重试 HTTP。
|
||||
guard AppConfig.allowHTTPFallback,
|
||||
endpoint.scheme == "https",
|
||||
isTLSError(error) else {
|
||||
throw error
|
||||
}
|
||||
var baseComponents = URLComponents(string: trimmedBase)
|
||||
baseComponents?.scheme = "http"
|
||||
guard let httpBase = baseComponents?.string,
|
||||
let httpURL = URL(string: path, relativeTo: URL(string: httpBase)) else {
|
||||
throw error
|
||||
}
|
||||
return try await perform(
|
||||
url: httpURL,
|
||||
method: method,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
body: body,
|
||||
effectiveBaseURL: httpBase
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func perform(
|
||||
url: URL,
|
||||
method: String,
|
||||
timeout: TimeInterval,
|
||||
headers: [String: String],
|
||||
body: Data?,
|
||||
effectiveBaseURL: String
|
||||
) async throws -> HTTPResult {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = timeout
|
||||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
request.httpBody = body
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw HTTPClientError.invalidURL
|
||||
}
|
||||
return HTTPResult(statusCode: http.statusCode, body: data, effectiveBaseURL: effectiveBaseURL)
|
||||
}
|
||||
|
||||
private static func isTLSError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
let tlsCodes: [Int] = [
|
||||
NSURLErrorSecureConnectionFailed,
|
||||
NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasUnknownRoot,
|
||||
NSURLErrorServerCertificateNotYetValid,
|
||||
NSURLErrorClientCertificateRejected,
|
||||
NSURLErrorClientCertificateRequired,
|
||||
NSURLErrorCannotLoadFromNetwork,
|
||||
]
|
||||
if tlsCodes.contains(nsError.code) {
|
||||
return true
|
||||
}
|
||||
let message = (nsError.localizedDescription + " " + (nsError.localizedFailureReason ?? ""))
|
||||
.lowercased()
|
||||
return message.contains("ssl") || message.contains("tls") || message.contains("certificate")
|
||||
}
|
||||
}
|
||||
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal file
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum ImitationAssessorError: LocalizedError {
|
||||
case invalidHash
|
||||
case cannotReadRecording
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHash:
|
||||
return "视频 id 必须是 64 位 SHA-256 十六进制字符串"
|
||||
case .cannotReadRecording:
|
||||
return "无法读取录音文件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ImitationAssessor {
|
||||
/// 分块计算文件的 SHA-256(与安卓端一致,适合大视频文件)。
|
||||
static func sha256Hex(of fileURL: URL) throws -> String {
|
||||
let handle: FileHandle
|
||||
do {
|
||||
handle = try FileHandle(forReadingFrom: fileURL)
|
||||
} catch {
|
||||
throw ImitationAssessorError.cannotReadRecording
|
||||
}
|
||||
defer { try? handle.close() }
|
||||
var hasher = SHA256()
|
||||
while true {
|
||||
guard let data = try? handle.read(upToCount: 1 << 20), !data.isEmpty else {
|
||||
break
|
||||
}
|
||||
hasher.update(data: data)
|
||||
}
|
||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 复制到缓存目录的同时计算 SHA-256(单次读取完成两件事)。
|
||||
static func copyAndHash(from source: URL, to destination: URL) throws -> String {
|
||||
let input: FileHandle
|
||||
do {
|
||||
input = try FileHandle(forReadingFrom: source)
|
||||
} catch {
|
||||
throw ImitationAssessorError.cannotReadRecording
|
||||
}
|
||||
defer { try? input.close() }
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
FileManager.default.createFile(atPath: destination.path, contents: nil)
|
||||
let output = try FileHandle(forWritingTo: destination)
|
||||
defer { try? output.close() }
|
||||
var hasher = SHA256()
|
||||
while true {
|
||||
guard let data = try input.read(upToCount: 1 << 20), !data.isEmpty else {
|
||||
break
|
||||
}
|
||||
hasher.update(data: data)
|
||||
try output.write(contentsOf: data)
|
||||
}
|
||||
try output.synchronize()
|
||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 上传一句录音到服务端 MOSS 评测接口(multipart/form-data,与安卓端字段一致)。
|
||||
static func assess(request: ImitationAssessmentRequest) async throws -> ImitationAssessmentResult {
|
||||
let hash = request.mediaId.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw ImitationAssessorError.invalidHash
|
||||
}
|
||||
let path = "api/v1/videos/\(hash)/sentences/\(request.sentence.index)/assessments"
|
||||
let boundary = "----OralTrainer-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
appendField(&body, boundary: boundary, name: "language", value: request.locale ?? request.sentence.language ?? "")
|
||||
let recordingData: Data
|
||||
do {
|
||||
recordingData = try Data(contentsOf: request.recordingURL)
|
||||
} catch {
|
||||
throw ImitationAssessorError.cannotReadRecording
|
||||
}
|
||||
let filename = request.recordingURL.lastPathComponent
|
||||
let mimeType = request.recordingURL.pathExtension.lowercased() == "wav" ? "audio/wav" : "audio/m4a"
|
||||
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
|
||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: path,
|
||||
method: "POST",
|
||||
timeout: AppConfig.assessmentTimeout,
|
||||
headers: [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
"X-Client-Key": AppConfig.assessmentAPIKey,
|
||||
],
|
||||
body: body
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, responseBody)
|
||||
}
|
||||
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
|
||||
}
|
||||
|
||||
private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
|
||||
body.append(Data("--\(boundary)\r\n".utf8))
|
||||
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
|
||||
body.append(Data(value.utf8))
|
||||
body.append(Data("\r\n".utf8))
|
||||
}
|
||||
|
||||
private static func appendFile(
|
||||
_ body: inout Data,
|
||||
boundary: String,
|
||||
name: String,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
data: Data
|
||||
) {
|
||||
body.append(Data("--\(boundary)\r\n".utf8))
|
||||
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8))
|
||||
body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
|
||||
body.append(data)
|
||||
body.append(Data("\r\n".utf8))
|
||||
}
|
||||
}
|
||||
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal file
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
enum SentenceBoundaryAPIError: LocalizedError {
|
||||
case invalidHash
|
||||
case hashMismatch
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHash:
|
||||
return "video_hash 必须是 64 位 SHA-256 十六进制字符串"
|
||||
case .hashMismatch:
|
||||
return "服务端返回的句边界哈希与请求不一致"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SentenceBoundaryAPI {
|
||||
static func fetch(videoHash: String) async throws -> SentenceBoundaryDocument {
|
||||
let hash = videoHash.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
let result = try await HTTPClient.getJSON(
|
||||
SentenceBoundaryDocument.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||
timeout: AppConfig.readTimeout
|
||||
)
|
||||
guard result.value.videoHash.lowercased() == hash else {
|
||||
throw SentenceBoundaryAPIError.hashMismatch
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal file
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct VideoCatalogResponse: Decodable {
|
||||
let videos: [TrainingVideoSummary]
|
||||
}
|
||||
|
||||
enum VideoCatalogAPI {
|
||||
static func fetch() async throws -> (videos: [TrainingVideoSummary], baseURL: String) {
|
||||
let result = try await HTTPClient.getJSON(
|
||||
VideoCatalogResponse.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos",
|
||||
timeout: AppConfig.readTimeout
|
||||
)
|
||||
let ready = result.value.videos.filter { $0.status == "ready" }
|
||||
return (ready, result.effectiveBaseURL)
|
||||
}
|
||||
}
|
||||
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