From d0d43794f3ca1752ffd22efb4cbd6a86757fbdf1 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Tue, 8 Sep 2026 11:11:49 +0800 Subject: [PATCH] fixed some bugs --- .../UserInterfaceState.xcuserstate | Bin 22797 -> 22997 bytes ios/OralTrainer/Models/PlaybackModels.swift | 7 + ios/OralTrainer/Player/PlayerController.swift | 128 ++++++++++++++++-- .../Player/PlayerSurfaceView.swift | 73 ++++++++-- ios/OralTrainer/Views/RootView.swift | 26 ++++ 5 files changed, 208 insertions(+), 26 deletions(-) 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 a8a8d2233ef8610960debb943bfa6f5be3577cf6..4cb113cbe8c48ac5c259bc0536428507dd45dcd3 100644 GIT binary patch delta 10334 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 tcR26od~UuqKP$goe)s%d`4jS|h*De*laZ8e9MX diff --git a/ios/OralTrainer/Models/PlaybackModels.swift b/ios/OralTrainer/Models/PlaybackModels.swift index 9cd634a..2e57942 100644 --- a/ios/OralTrainer/Models/PlaybackModels.swift +++ b/ios/OralTrainer/Models/PlaybackModels.swift @@ -50,6 +50,13 @@ enum GestureKind { case longPressSpeed } +enum SwipeBackResult { + case restartedCurrent + case movedPrevious + case stayedAtFirst + case rewound +} + enum BoundaryState: Equatable { case idle case loading(isLocal: Bool) diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 65c433a..67a8f34 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -22,9 +22,16 @@ final class PlayerController: ObservableObject { private var seekGeneration = 0 private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? + private var swipeBackAnchorIndex: Int? + private var swipeBackAnchorTime: TimeInterval = 0 private var speedBeforeLongPress: Float? private var transientTask: Task? + private static let swipeBackPhaseTimeout: TimeInterval = 1.2 + /// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。 + /// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。 + private static let sentenceSeekInsetMs: Int64 = 20 + private var timeObserver: Any? private var endObserver: NSObjectProtocol? private var itemStatusObserver: NSKeyValueObservation? @@ -69,6 +76,7 @@ final class PlayerController: ObservableObject { func load(_ item: TrainingMediaItem) { lastSentenceIndex = nil + swipeBackAnchorIndex = nil pendingSeekPositionMs = nil stopAtSentenceEnd = false stopAtSentenceEndIndex = nil @@ -140,12 +148,9 @@ final class PlayerController: ObservableObject { guard let item = mediaItem, !item.sentences.isEmpty else { return false } - let position = referencePositionMs() - guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 }) - ?? item.sentences.first else { - return false - } - seek(to: target.startMs) + guard let currentIndex = sentenceAnchorIndex() else { return false } + let target = item.sentences[max(0, currentIndex - 1)] + seekToSentenceBeginning(target) return true } @@ -154,14 +159,47 @@ final class PlayerController: ObservableObject { guard let item = mediaItem, !item.sentences.isEmpty else { return false } - let position = referencePositionMs() - guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else { + guard let currentIndex = sentenceAnchorIndex() else { return false } + let targetIndex = currentIndex + 1 + guard item.sentences.indices.contains(targetIndex) else { return false } - seek(to: target.startMs) + let target = item.sentences[targetIndex] + seekToSentenceBeginning(target) return true } + /// 按索引跳转,而不是每次用异步的 AVPlayer 时间重新搜索。 + /// 这样一次手势只前进/后退一句,也不会因 seek 后时间上报偏差导致重复或无效。 + @discardableResult + func seekToSentence(at index: Int) -> Bool { + guard let item = mediaItem, item.sentences.indices.contains(index) else { + return false + } + seekToSentenceBeginning(item.sentences[index]) + return true + } + + private func sentenceAnchorIndex() -> Int? { + guard let item = mediaItem, !item.sentences.isEmpty else { return nil } + if let index = currentSentence?.index, item.sentences.indices.contains(index) { + return index + } + if let index = lastSentenceIndex, item.sentences.indices.contains(index) { + return index + } + + let position = referencePositionMs() + if let sentence = sentence(at: position) { + return sentence.index + } + // 句间隙中时,取前一句作为锚点;在第一句之前时取下一句。 + if let previous = item.sentences.last(where: { $0.startMs <= position }) { + return previous.index + } + return item.sentences.first?.index + } + func previousSentenceOrRewind() { if !config.sentenceMode || !seekToPreviousSentence() { rewind() @@ -174,12 +212,55 @@ final class PlayerController: ObservableObject { } } + /// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。 + /// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。 + @discardableResult + func replayOrPreviousSentence() -> SwipeBackResult { + guard config.sentenceMode, + let item = mediaItem, + !item.sentences.isEmpty, + let anchorIndex = sentenceAnchorIndex() else { + rewind() + return .rewound + } + + let isActivelyPlaying = player.timeControlStatus == .playing + || player.timeControlStatus == .waitingToPlayAtSpecifiedRate + guard isActivelyPlaying else { + swipeBackAnchorIndex = nil + return seekToPreviousSentence() ? .movedPrevious : .stayedAtFirst + } + + let now = Date().timeIntervalSinceReferenceDate + if swipeBackAnchorIndex == anchorIndex, + now - swipeBackAnchorTime <= Self.swipeBackPhaseTimeout { + swipeBackAnchorIndex = nil + guard anchorIndex > 0 else { + seekToSentenceBeginning(item.sentences[anchorIndex]) + return .stayedAtFirst + } + seekToSentenceBeginning(item.sentences[anchorIndex - 1]) + return .movedPrevious + } + + swipeBackAnchorIndex = anchorIndex + swipeBackAnchorTime = now + let current = item.sentences[anchorIndex] + if stopAtSentenceEnd { + playCurrentSentenceAndStop() + } else { + seekToSentenceBeginning(current) + play() + } + return .restartedCurrent + } + /// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。 func playCurrentSentenceAndStop() { guard let sentence = currentSentence else { return } - performSeek(to: sentence.startMs) + performSeek(to: sentenceSeekPosition(sentence)) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentence.index play() @@ -249,8 +330,16 @@ final class PlayerController: ObservableObject { togglePlayPause() showTransientMessage("播放状态已切换") case .swipeLeft: - previousSentenceOrRewind() - showTransientMessage("已跳到上一句") + switch replayOrPreviousSentence() { + case .restartedCurrent: + showTransientMessage("已回到本句开头,再左划返回上一句") + case .movedPrevious: + showTransientMessage("已跳到上一句") + case .stayedAtFirst: + showTransientMessage("已是第一句") + case .rewound: + showTransientMessage("已快退") + } case .swipeRight: nextSentenceOrForward() showTransientMessage("已跳到下一句") @@ -290,6 +379,9 @@ final class PlayerController: ObservableObject { if sentence?.index != lastSentenceIndex { lastSentenceIndex = sentence?.index } + if sentence?.index != swipeBackAnchorIndex { + swipeBackAnchorIndex = nil + } let duration = durationMs(of: item) let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration) snapshot = PlaybackSnapshot( @@ -422,7 +514,7 @@ final class PlayerController: ObservableObject { return } if positionMs >= anchor.endMs { - seekRaw(anchor.startMs) + seekRaw(sentenceSeekPosition(anchor)) } } @@ -430,6 +522,16 @@ final class PlayerController: ObservableObject { performSeek(to: positionMs) } + private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 { + let available = max(0, sentence.endMs - sentence.startMs - 1) + let inset = min(Self.sentenceSeekInsetMs, available) + return sentence.startMs + inset + } + + private func seekToSentenceBeginning(_ sentence: SentenceBoundary) { + seek(to: sentenceSeekPosition(sentence)) + } + private func handleEnded() { guard loopMode != .off else { tick() diff --git a/ios/OralTrainer/Player/PlayerSurfaceView.swift b/ios/OralTrainer/Player/PlayerSurfaceView.swift index 38dc4d2..be17a85 100644 --- a/ios/OralTrainer/Player/PlayerSurfaceView.swift +++ b/ios/OralTrainer/Player/PlayerSurfaceView.swift @@ -44,6 +44,13 @@ final class VideoSurfaceView: UIView { private var tapRecognizer: UITapGestureRecognizer! private var panRecognizer: UIPanGestureRecognizer! private var longPressRecognizer: UILongPressGestureRecognizer! + private var didDispatchSwipe = false + private var swipeAxis: SwipeAxis? + + private enum SwipeAxis { + case horizontal + case vertical + } override init(frame: CGRect) { super.init(frame: frame) @@ -80,25 +87,65 @@ final class VideoSurfaceView: UIView { } @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { - guard recognizer.state == .ended || recognizer.state == .cancelled else { - return - } let translation = recognizer.translation(in: self) let velocity = recognizer.velocity(in: self) let dx = translation.x let dy = translation.y - if abs(dy) > abs(dx), - abs(dy) >= minSwipeDistance, - abs(velocity.y) >= minSwipeVelocity { + + switch recognizer.state { + case .began: + resetSwipeTracking() + case .changed: + // 在滑动达到阈值时立即派发,并且同一个手势只允许派发一次。 + evaluateSwipe(dx: dx, dy: dy, velocity: velocity) + case .ended, .cancelled, .failed: + if !didDispatchSwipe { + evaluateSwipe(dx: dx, dy: dy, velocity: velocity) + } + resetSwipeTracking() + default: + break + } + } + + private func evaluateSwipe(dx: CGFloat, dy: CGFloat, velocity: CGPoint) { + guard !didDispatchSwipe else { return } + + if swipeAxis == nil { + // 只有方向足够明确时锁定轴向,避免轻微斜滑被误判为上下滑。 + if abs(dx) > abs(dy) * 1.2 { + swipeAxis = .horizontal + } else if abs(dy) > abs(dx) * 1.2 { + swipeAxis = .vertical + } else { + return + } + } + + switch swipeAxis { + case .vertical: + guard abs(dy) >= minSwipeDistance, + abs(velocity.y) >= minSwipeVelocity else { + return + } + didDispatchSwipe = true onVerticalSwipe?(dy < 0) - return + case .horizontal: + guard abs(dx) >= abs(dy), + abs(dx) >= minSwipeDistance, + abs(velocity.x) >= minSwipeVelocity else { + return + } + didDispatchSwipe = true + controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft) + default: + break } - guard abs(dx) >= abs(dy), - abs(dx) >= minSwipeDistance, - abs(velocity.x) >= minSwipeVelocity else { - return - } - controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft) + } + + private func resetSwipeTracking() { + didDispatchSwipe = false + swipeAxis = nil } @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index 69798b3..f133526 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -105,6 +105,9 @@ struct RootView: View { } } } + .onReceive(catalogVM.$activeVideoHash) { videoHash in + syncCurrentCourse(with: videoHash) + } .fullScreenCover(isPresented: $showFullscreenPlayer) { FullscreenPlayerView(controller: controller) { exitFullscreen() @@ -264,6 +267,29 @@ struct RootView: View { activeModule = .train } + /// 训练页的课程卡片直接操作 CatalogViewModel; + /// 这里监听它的 activeVideoHash,保证返回课程页时高亮和“当前课程”同步。 + private func syncCurrentCourse(with videoHash: String?) { + guard let videoHash else { return } + if let course = courses.first(where: { $0.videoHash == videoHash }) { + currentCourse = course + return + } + + // 本地导入视频不在 /courses 列表里;仍建立当前课程信息,避免课程页状态回退。 + if let video = catalogVM.videos.first(where: { $0.videoHash == videoHash }) { + currentCourse = Course( + videoHash: video.videoHash, + title: video.title, + durationMs: video.durationMs, + language: video.language, + sentenceCount: video.sentenceCount, + streamUrl: video.streamUrl, + enrolled: false + ) + } + } + // MARK: - Mine Page private var minePage: some View {