diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt index a991b06..464bf00 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt @@ -106,7 +106,7 @@ class UserApi internal constructor( username: String, password: String, nickname: String? = null, - phone: String? = null, + phone: String, onComplete: (Result) -> Unit, ): CancellableRequest = submit(onComplete) { requestRegisterBlocking( @@ -234,13 +234,16 @@ class UserApi internal constructor( username: String, password: String, nickname: String?, - phone: String?, + phone: String, ): AuthSession { + val cleanedPhone = phone.trim() + require(cleanedPhone.isNotEmpty()) { "请填写手机号" } + require(PHONE_PATTERN.matches(cleanedPhone)) { "请输入有效的 11 位手机号" } val root = JSONObject() .put("username", username) .put("password", password) nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) } - phone?.takeIf { it.isNotBlank() }?.let { root.put("phone", it) } + root.put("phone", cleanedPhone) val response = request(path, body = root.toString(), method = "POST") return parseSession(JSONObject(response)) } @@ -385,3 +388,5 @@ data class SsoExchangeResult( val phoneBound: Boolean, val isNewUser: Boolean, ) + +private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$") diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt index 5901016..53850a3 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/AuthCoordinator.kt @@ -139,7 +139,7 @@ internal class AuthCoordinator( InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, ) val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称") - val phoneInput = labeledInput("手机号", "选填", InputType.TYPE_CLASS_PHONE) + val phoneInput = labeledInput("手机号", "请输入 11 位手机号", InputType.TYPE_CLASS_PHONE) val statusText = statusText() addView(primaryButton("注册") { @@ -151,6 +151,7 @@ internal class AuthCoordinator( username.length < 3 -> statusText.text = "用户名至少 3 个字符" password.length < 8 -> statusText.text = "密码至少 8 位" nickname.isBlank() -> statusText.text = "请填写姓名" + !PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号" else -> { statusText.text = "注册中..." userApi.register(username, password, nickname, phone) { result -> diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 4cb113c..4556101 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 67a8f34..8b9d87c 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -26,6 +26,8 @@ final class PlayerController: ObservableObject { private var swipeBackAnchorTime: TimeInterval = 0 private var speedBeforeLongPress: Float? private var transientTask: Task? + private var sentenceEndTimeObserver: Any? + private var sentenceEndGuardToken = 0 private static let swipeBackPhaseTimeout: TimeInterval = 1.2 /// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。 @@ -40,6 +42,7 @@ final class PlayerController: ObservableObject { init() { configureAudioSession() player.automaticallyWaitsToMinimizeStalling = true + player.actionAtItemEnd = .pause timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main @@ -55,6 +58,7 @@ final class PlayerController: ObservableObject { } timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in DispatchQueue.main.async { + self?.refreshSentenceBoundaryGuard() self?.tick() } } @@ -68,6 +72,7 @@ final class PlayerController: ObservableObject { if let endObserver = endObserver { NotificationCenter.default.removeObserver(endObserver) } + cancelSentenceBoundaryGuard() itemStatusObserver?.invalidate() timeControlObserver?.invalidate() } @@ -75,6 +80,7 @@ final class PlayerController: ObservableObject { // MARK: - 装载 func load(_ item: TrainingMediaItem) { + cancelSentenceBoundaryGuard() lastSentenceIndex = nil swipeBackAnchorIndex = nil pendingSeekPositionMs = nil @@ -96,11 +102,15 @@ final class PlayerController: ObservableObject { func play() { player.play() + // play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用; + // 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。 + refreshSentenceBoundaryGuard(force: true) } func pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + cancelSentenceBoundaryGuard() player.pause() } @@ -119,6 +129,7 @@ final class PlayerController: ObservableObject { func stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + cancelSentenceBoundaryGuard() player.pause() seek(to: .zero) } @@ -270,10 +281,12 @@ final class PlayerController: ObservableObject { func setContinuousPlayback(_ enabled: Bool) { config.continuousPlayback = enabled + refreshSentenceBoundaryGuard() } func setSentenceMode(_ enabled: Bool) { config.sentenceMode = enabled + refreshSentenceBoundaryGuard() } func setLoopMode(_ mode: LoopMode) { @@ -412,7 +425,7 @@ final class PlayerController: ObservableObject { /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 - private func performSeek(to positionMs: Int64) { + private func performSeek(to positionMs: Int64, completion: (() -> Void)? = nil) { let targetPositionMs = max(0, positionMs) seekGeneration += 1 let generation = seekGeneration @@ -428,8 +441,10 @@ final class PlayerController: ObservableObject { } self.pendingSeekPositionMs = nil self.tick() + completion?() } } + refreshSentenceBoundaryGuard() tick() } @@ -518,8 +533,122 @@ final class PlayerController: ObservableObject { } } - private func seekRaw(_ positionMs: Int64) { - performSeek(to: positionMs) + private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) { + performSeek(to: positionMs, completion: completion) + } + + /// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时, + /// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法, + /// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。 + private func refreshSentenceBoundaryGuard(force: Bool = false) { + cancelSentenceBoundaryGuard() + guard config.sentenceMode, + let item = mediaItem, + !item.sentences.isEmpty else { + clearForwardPlaybackEnd() + return + } + guard force || isActivelyPlaying() else { + return + } + + guard let sentence = sentenceBoundaryTarget(in: item) else { + clearForwardPlaybackEnd() + return + } + let currentPosition = referencePositionMs() + if currentPosition >= sentence.endMs { + applySentenceEndAction(sentence) + return + } + + let token = sentenceEndGuardToken + let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000) + // boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界, + // 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。 + player.currentItem?.forwardPlaybackEndTime = endTime + sentenceEndTimeObserver = player.addBoundaryTimeObserver( + forTimes: [NSValue(time: endTime)], + queue: .main + ) { [weak self] in + guard let self, token == self.sentenceEndGuardToken else { + return + } + self.applySentenceEndAction(sentence) + } + } + + private func cancelSentenceBoundaryGuard() { + sentenceEndGuardToken += 1 + if let observer = sentenceEndTimeObserver { + player.removeTimeObserver(observer) + } + sentenceEndTimeObserver = nil + } + + private func clearForwardPlaybackEnd() { + player.currentItem?.forwardPlaybackEndTime = .invalid + } + + private func isActivelyPlaying() -> Bool { + player.timeControlStatus == .playing + || player.timeControlStatus == .waitingToPlayAtSpecifiedRate + } + + private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? { + if stopAtSentenceEnd { + guard let index = stopAtSentenceEndIndex else { + return nil + } + return item.sentences.first(where: { $0.index == index }) + } + if config.continuousPlayback { + return nil + } + if let index = lastSentenceIndex ?? currentSentence?.index, + let sentence = item.sentences.first(where: { $0.index == index }) { + return sentence + } + + let position = referencePositionMs() + if let sentence = sentence(at: position) { + return sentence + } + return item.sentences.last(where: { $0.startMs <= position }) + } + + private func applySentenceEndAction(_ target: SentenceBoundary) { + cancelSentenceBoundaryGuard() + guard let item = mediaItem, + let sentence = item.sentences.first(where: { $0.index == target.index }) else { + return + } + lastSentenceIndex = sentence.index + + if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index { + stopAtSentenceEnd = false + stopAtSentenceEndIndex = nil + player.pause() + seekRaw(sentenceSeekPosition(sentence)) + tick() + return + } + if config.continuousPlayback { + clearForwardPlaybackEnd() + return + } + + // forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首, + // 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。 + player.pause() + seekRaw(sentenceSeekPosition(sentence)) { [weak self] in + guard let self else { + return + } + self.player.play() + self.refreshSentenceBoundaryGuard(force: true) + self.tick() + } } private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 { @@ -537,11 +666,47 @@ final class PlayerController: ObservableObject { tick() return } + // 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知, + // 不能再被识别成整条视频结束。 + guard pendingSeekPositionMs == nil else { + tick() + return + } + // 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束 + // 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。 + if config.sentenceMode, + !config.continuousPlayback, + let sentence = currentSentence, + currentPositionMs() + 30 >= sentence.endMs { + applySentenceEndAction(sentence) + return + } + // 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。 + // 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。 + guard isNaturalMediaEnd() else { + tick() + return + } performSeek(to: .zero) player.play() tick() } + private func isNaturalMediaEnd() -> Bool { + guard let item = player.currentItem else { + return true + } + let durationMs = durationMs(of: item) + let positionMs = currentPositionMs() + if durationMs > 0 { + return positionMs + 30 >= durationMs + } + guard let sentence = currentSentence else { + return true + } + return positionMs + 30 >= sentence.endMs + } + // MARK: - 观察器 private func attachItemObservers(to item: AVPlayerItem) { diff --git a/ios/OralTrainer/Views/AuthView.swift b/ios/OralTrainer/Views/AuthView.swift index c6d04c1..0540838 100644 --- a/ios/OralTrainer/Views/AuthView.swift +++ b/ios/OralTrainer/Views/AuthView.swift @@ -234,7 +234,7 @@ struct RegisterView: View { .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) fieldLabel("手机号") - TextField("选填", text: $phone) + TextField("请输入 11 位手机号", text: $phone) .textFieldStyle(.plain) .keyboardType(.phonePad) .padding(12) @@ -292,13 +292,18 @@ struct RegisterView: View { guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return } guard password.count >= 8 else { statusText = "密码至少 8 位"; return } guard !nickname.isEmpty else { statusText = "请填写姓名"; return } + let cleanedPhone = phone.trimmingCharacters(in: .whitespaces) + guard cleanedPhone.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else { + statusText = "请输入有效的 11 位手机号" + return + } isLoading = true statusText = "注册中..." defer { isLoading = false } do { let session = try await AuthAPI.register( - username: username, password: password, nickname: nickname, phone: phone + username: username, password: password, nickname: nickname, phone: cleanedPhone ) auth.saveSession(session) dismiss()