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 a8a8d22..4cb113c 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ 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 {