From 9d956183c7da0dd6104d550d72a82fb8168753d6 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 9 Sep 2026 12:31:05 +0800 Subject: [PATCH] fixed a bug --- .../cn/learningpad/oraltrainer/sdk/UserApi.kt | 11 +- .../oraltrainer/sample/AuthCoordinator.kt | 3 +- .../UserInterfaceState.xcuserstate | Bin 22997 -> 23197 bytes ios/OralTrainer/Player/PlayerController.swift | 171 +++++++++++++++++- ios/OralTrainer/Views/AuthView.swift | 9 +- 5 files changed, 185 insertions(+), 9 deletions(-) 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 4cb113cbe8c48ac5c259bc0536428507dd45dcd3..4556101d3179501293a3795ed1f0b3888f67849d 100644 GIT binary patch delta 10478 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 + 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()