fixed a bug
This commit is contained in:
@@ -106,7 +106,7 @@ class UserApi internal constructor(
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String? = null,
|
||||
phone: String? = null,
|
||||
phone: String,
|
||||
onComplete: (Result<AuthSession>) -> 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}$")
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Binary file not shown.
@@ -26,6 +26,8 @@ final class PlayerController: ObservableObject {
|
||||
private var swipeBackAnchorTime: TimeInterval = 0
|
||||
private var speedBeforeLongPress: Float?
|
||||
private var transientTask: Task<Void, Never>?
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user