fixed a bug

This commit is contained in:
2026-09-09 12:31:05 +08:00
parent fb64343a9f
commit 9d956183c7
5 changed files with 185 additions and 9 deletions

View File

@@ -106,7 +106,7 @@ class UserApi internal constructor(
username: String, username: String,
password: String, password: String,
nickname: String? = null, nickname: String? = null,
phone: String? = null, phone: String,
onComplete: (Result<AuthSession>) -> Unit, onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { ): CancellableRequest = submit(onComplete) {
requestRegisterBlocking( requestRegisterBlocking(
@@ -234,13 +234,16 @@ class UserApi internal constructor(
username: String, username: String,
password: String, password: String,
nickname: String?, nickname: String?,
phone: String?, phone: String,
): AuthSession { ): AuthSession {
val cleanedPhone = phone.trim()
require(cleanedPhone.isNotEmpty()) { "请填写手机号" }
require(PHONE_PATTERN.matches(cleanedPhone)) { "请输入有效的 11 位手机号" }
val root = JSONObject() val root = JSONObject()
.put("username", username) .put("username", username)
.put("password", password) .put("password", password)
nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) } 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") val response = request(path, body = root.toString(), method = "POST")
return parseSession(JSONObject(response)) return parseSession(JSONObject(response))
} }
@@ -385,3 +388,5 @@ data class SsoExchangeResult(
val phoneBound: Boolean, val phoneBound: Boolean,
val isNewUser: Boolean, val isNewUser: Boolean,
) )
private val PHONE_PATTERN = Regex("^1[3-9]\\d{9}$")

View File

@@ -139,7 +139,7 @@ internal class AuthCoordinator(
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD,
) )
val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称") val nicknameInput = labeledInput("姓名", "你的真实姓名或昵称")
val phoneInput = labeledInput("手机号", "选填", InputType.TYPE_CLASS_PHONE) val phoneInput = labeledInput("手机号", "请输入 11 位手机号", InputType.TYPE_CLASS_PHONE)
val statusText = statusText() val statusText = statusText()
addView(primaryButton("注册") { addView(primaryButton("注册") {
@@ -151,6 +151,7 @@ internal class AuthCoordinator(
username.length < 3 -> statusText.text = "用户名至少 3 个字符" username.length < 3 -> statusText.text = "用户名至少 3 个字符"
password.length < 8 -> statusText.text = "密码至少 8 位" password.length < 8 -> statusText.text = "密码至少 8 位"
nickname.isBlank() -> statusText.text = "请填写姓名" nickname.isBlank() -> statusText.text = "请填写姓名"
!PHONE_PATTERN.matches(phone) -> statusText.text = "请输入有效的 11 位手机号"
else -> { else -> {
statusText.text = "注册中..." statusText.text = "注册中..."
userApi.register(username, password, nickname, phone) { result -> userApi.register(username, password, nickname, phone) { result ->

View File

@@ -26,6 +26,8 @@ final class PlayerController: ObservableObject {
private var swipeBackAnchorTime: TimeInterval = 0 private var swipeBackAnchorTime: TimeInterval = 0
private var speedBeforeLongPress: Float? private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>? private var transientTask: Task<Void, Never>?
private var sentenceEndTimeObserver: Any?
private var sentenceEndGuardToken = 0
private static let swipeBackPhaseTimeout: TimeInterval = 1.2 private static let swipeBackPhaseTimeout: TimeInterval = 1.2
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。 /// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
@@ -40,6 +42,7 @@ final class PlayerController: ObservableObject {
init() { init() {
configureAudioSession() configureAudioSession()
player.automaticallyWaitsToMinimizeStalling = true player.automaticallyWaitsToMinimizeStalling = true
player.actionAtItemEnd = .pause
timeObserver = player.addPeriodicTimeObserver( timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main queue: .main
@@ -55,6 +58,7 @@ final class PlayerController: ObservableObject {
} }
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.refreshSentenceBoundaryGuard()
self?.tick() self?.tick()
} }
} }
@@ -68,6 +72,7 @@ final class PlayerController: ObservableObject {
if let endObserver = endObserver { if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver) NotificationCenter.default.removeObserver(endObserver)
} }
cancelSentenceBoundaryGuard()
itemStatusObserver?.invalidate() itemStatusObserver?.invalidate()
timeControlObserver?.invalidate() timeControlObserver?.invalidate()
} }
@@ -75,6 +80,7 @@ final class PlayerController: ObservableObject {
// MARK: - 装载 // MARK: - 装载
func load(_ item: TrainingMediaItem) { func load(_ item: TrainingMediaItem) {
cancelSentenceBoundaryGuard()
lastSentenceIndex = nil lastSentenceIndex = nil
swipeBackAnchorIndex = nil swipeBackAnchorIndex = nil
pendingSeekPositionMs = nil pendingSeekPositionMs = nil
@@ -96,11 +102,15 @@ final class PlayerController: ObservableObject {
func play() { func play() {
player.play() player.play()
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
refreshSentenceBoundaryGuard(force: true)
} }
func pause() { func pause() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil stopAtSentenceEndIndex = nil
cancelSentenceBoundaryGuard()
player.pause() player.pause()
} }
@@ -119,6 +129,7 @@ final class PlayerController: ObservableObject {
func stop() { func stop() {
stopAtSentenceEnd = false stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil stopAtSentenceEndIndex = nil
cancelSentenceBoundaryGuard()
player.pause() player.pause()
seek(to: .zero) seek(to: .zero)
} }
@@ -270,10 +281,12 @@ final class PlayerController: ObservableObject {
func setContinuousPlayback(_ enabled: Bool) { func setContinuousPlayback(_ enabled: Bool) {
config.continuousPlayback = enabled config.continuousPlayback = enabled
refreshSentenceBoundaryGuard()
} }
func setSentenceMode(_ enabled: Bool) { func setSentenceMode(_ enabled: Bool) {
config.sentenceMode = enabled config.sentenceMode = enabled
refreshSentenceBoundaryGuard()
} }
func setLoopMode(_ mode: LoopMode) { func setLoopMode(_ mode: LoopMode) {
@@ -412,7 +425,7 @@ final class PlayerController: ObservableObject {
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
private func performSeek(to positionMs: Int64) { private func performSeek(to positionMs: Int64, completion: (() -> Void)? = nil) {
let targetPositionMs = max(0, positionMs) let targetPositionMs = max(0, positionMs)
seekGeneration += 1 seekGeneration += 1
let generation = seekGeneration let generation = seekGeneration
@@ -428,8 +441,10 @@ final class PlayerController: ObservableObject {
} }
self.pendingSeekPositionMs = nil self.pendingSeekPositionMs = nil
self.tick() self.tick()
completion?()
} }
} }
refreshSentenceBoundaryGuard()
tick() tick()
} }
@@ -518,8 +533,122 @@ final class PlayerController: ObservableObject {
} }
} }
private func seekRaw(_ positionMs: Int64) { private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
performSeek(to: positionMs) 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 { private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 {
@@ -537,11 +666,47 @@ final class PlayerController: ObservableObject {
tick() tick()
return 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) performSeek(to: .zero)
player.play() player.play()
tick() 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: - 观察器 // MARK: - 观察器
private func attachItemObservers(to item: AVPlayerItem) { private func attachItemObservers(to item: AVPlayerItem) {

View File

@@ -234,7 +234,7 @@ struct RegisterView: View {
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1))
fieldLabel("手机号") fieldLabel("手机号")
TextField("选填", text: $phone) TextField("请输入 11 位手机号", text: $phone)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.keyboardType(.phonePad) .keyboardType(.phonePad)
.padding(12) .padding(12)
@@ -292,13 +292,18 @@ struct RegisterView: View {
guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return } guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return }
guard password.count >= 8 else { statusText = "密码至少 8 位"; return } guard password.count >= 8 else { statusText = "密码至少 8 位"; return }
guard !nickname.isEmpty else { statusText = "请填写姓名"; 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 isLoading = true
statusText = "注册中..." statusText = "注册中..."
defer { isLoading = false } defer { isLoading = false }
do { do {
let session = try await AuthAPI.register( 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) auth.saveSession(session)
dismiss() dismiss()