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 543bbeb..62498e0 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/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 71f3017..65c433a 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -18,6 +18,8 @@ final class PlayerController: ObservableObject { private var mediaItem: TrainingMediaItem? private var lastSentenceIndex: Int? + private var pendingSeekPositionMs: Int64? + private var seekGeneration = 0 private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? private var speedBeforeLongPress: Float? @@ -67,6 +69,7 @@ final class PlayerController: ObservableObject { func load(_ item: TrainingMediaItem) { lastSentenceIndex = nil + pendingSeekPositionMs = nil stopAtSentenceEnd = false stopAtSentenceEndIndex = nil mediaItem = item @@ -109,18 +112,17 @@ final class PlayerController: ObservableObject { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil player.pause() - player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) + seek(to: .zero) } func seek(to positionMs: Int64) { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil - let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600) - player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + performSeek(to: positionMs) } func seekRelative(_ deltaMs: Int64) { - seek(to: snapshot.positionMs + deltaMs) + seek(to: referencePositionMs() + deltaMs) } func rewind() { @@ -138,7 +140,7 @@ final class PlayerController: ObservableObject { guard let item = mediaItem, !item.sentences.isEmpty else { return false } - let position = snapshot.positionMs + let position = referencePositionMs() guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 }) ?? item.sentences.first else { return false @@ -152,7 +154,7 @@ final class PlayerController: ObservableObject { guard let item = mediaItem, !item.sentences.isEmpty else { return false } - let position = snapshot.positionMs + let position = referencePositionMs() guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else { return false } @@ -177,8 +179,7 @@ final class PlayerController: ObservableObject { guard let sentence = currentSentence else { return } - let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600) - player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + performSeek(to: sentence.startMs) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentence.index play() @@ -277,9 +278,14 @@ final class PlayerController: ObservableObject { snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed()) return } - let position = currentPositionMs() - enforceSentenceBoundary(positionMs: position) - let finalPosition = currentPositionMs() + let position = referencePositionMs() + if pendingSeekPositionMs == nil, lastSentenceIndex == nil { + lastSentenceIndex = sentence(at: position)?.index + } + if pendingSeekPositionMs == nil { + enforceSentenceBoundary(positionMs: position) + } + let finalPosition = referencePositionMs() let sentence = sentence(at: finalPosition) if sentence?.index != lastSentenceIndex { lastSentenceIndex = sentence?.index @@ -307,6 +313,34 @@ final class PlayerController: ObservableObject { return max(0, Int64(seconds * 1000)) } + /// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。 + private func referencePositionMs() -> Int64 { + pendingSeekPositionMs ?? currentPositionMs() + } + + /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 + /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 + private func performSeek(to positionMs: Int64) { + let targetPositionMs = max(0, positionMs) + seekGeneration += 1 + let generation = seekGeneration + pendingSeekPositionMs = targetPositionMs + // 立即切换句子锚点。否则 periodic observer 仍可能以旧句子为锚点, + // 在 seek 完成前后把新位置拉回旧句开头。 + lastSentenceIndex = sentence(at: targetPositionMs)?.index + let time = CMTime(seconds: Double(targetPositionMs) / 1000, preferredTimescale: 600) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + DispatchQueue.main.async { + guard let self, self.seekGeneration == generation else { + return + } + self.pendingSeekPositionMs = nil + self.tick() + } + } + tick() + } + private func durationMs(of item: AVPlayerItem) -> Int64 { let seconds = item.duration.seconds guard seconds.isFinite, seconds > 0 else { @@ -393,8 +427,7 @@ final class PlayerController: ObservableObject { } private func seekRaw(_ positionMs: Int64) { - let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600) - player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + performSeek(to: positionMs) } private func handleEnded() { @@ -402,7 +435,7 @@ final class PlayerController: ObservableObject { tick() return } - player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) + performSeek(to: .zero) player.play() tick() } diff --git a/ios/OralTrainer/Player/PlayerSurfaceView.swift b/ios/OralTrainer/Player/PlayerSurfaceView.swift index 2bd0167..38dc4d2 100644 --- a/ios/OralTrainer/Player/PlayerSurfaceView.swift +++ b/ios/OralTrainer/Player/PlayerSurfaceView.swift @@ -5,24 +5,29 @@ import UIKit /// 与安卓端 OralTrainerPlayerView 对齐的播放区: /// - 单击:播放 / 暂停 /// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进) +/// - 上划 / 下划:显示 / 隐藏字幕层(由 SwiftUI 横屏页接收) /// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速 struct PlayerSurfaceView: UIViewRepresentable { let controller: PlayerController + var onVerticalSwipe: ((Bool) -> Void)? func makeUIView(context: Context) -> VideoSurfaceView { let view = VideoSurfaceView() view.playerLayer.player = controller.player view.controller = controller + view.onVerticalSwipe = onVerticalSwipe return view } func updateUIView(_ uiView: VideoSurfaceView, context: Context) { uiView.playerLayer.player = controller.player + uiView.onVerticalSwipe = onVerticalSwipe } } final class VideoSurfaceView: UIView { weak var controller: PlayerController? + var onVerticalSwipe: ((Bool) -> Void)? override class var layerClass: AnyClass { AVPlayerLayer.self @@ -82,6 +87,12 @@ final class VideoSurfaceView: UIView { 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 { + onVerticalSwipe?(dy < 0) + return + } guard abs(dx) >= abs(dy), abs(dx) >= minSwipeDistance, abs(velocity.x) >= minSwipeVelocity else { diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift index 6aaec85..7c4cdfd 100644 --- a/ios/OralTrainer/Views/FullscreenPlayerView.swift +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -5,33 +5,53 @@ struct FullscreenPlayerView: View { @ObservedObject var controller: PlayerController let onExit: () -> Void + @State private var subtitleVisible = true + var body: some View { ZStack { Color.black.ignoresSafeArea() - PlayerSurfaceView(controller: controller) + PlayerSurfaceView(controller: controller) { isSwipeUp in + guard subtitleVisible != isSwipeUp else { + return + } + withAnimation(.easeInOut(duration: 0.22)) { + subtitleVisible = isSwipeUp + } + } .ignoresSafeArea() + HStack(spacing: 8) { + overlayPill(timeText) + Spacer() + overlayPill(Theme.formatSpeed(controller.snapshot.speed)) + } + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .allowsHitTesting(false) VStack { Spacer() - VStack(alignment: .leading, spacing: 6) { - Text(controller.currentItem?.title ?? "未选择课程") - .font(.system(size: 17, weight: .bold)) - .foregroundColor(.white) - .lineLimit(1) - Text(sentenceMetaText) - .font(.system(size: 13)) - .foregroundColor(Theme.textOverlay) - Text(sentenceTextText) - .font(.system(size: 15)) - .foregroundColor(.white) - .lineLimit(2) - progressRow - Text(statusText) - .font(.system(size: 13)) - .foregroundColor(Theme.textOverlay) + if subtitleVisible { + VStack(alignment: .leading, spacing: 6) { + Text(controller.currentItem?.title ?? "未选择课程") + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Text(sentenceMetaText) + .font(.system(size: 13)) + .foregroundColor(Theme.textOverlay) + Text(sentenceTextText) + .font(.system(size: 15)) + .foregroundColor(.white) + .lineLimit(2) + progressRow + Text(statusText) + .font(.system(size: 13)) + .foregroundColor(Theme.textOverlay) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.overlayFaint) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.overlayFaint) } VStack { Spacer() @@ -55,6 +75,11 @@ struct FullscreenPlayerView: View { @State private var progressDragging = false @State private var dragPositionMs: Int64 = 0 + private var timeText: String { + let snapshot = controller.snapshot + return "\(Theme.formatTime(snapshot.positionMs)) / \(Theme.formatTime(snapshot.durationMs))" + } + private var progressRow: some View { let duration = controller.snapshot.durationMs return HStack(spacing: 8) { @@ -133,4 +158,14 @@ struct FullscreenPlayerView: View { } return controller.playbackStatusText(controller.snapshot) } + + private func overlayPill(_ text: String) -> some View { + Text(text) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.overlay) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } }