fixed a bug
This commit is contained in:
@@ -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}$")
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
Binary file not shown.
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user