From 7e4c0f5f906e733eea2c5fdefb5e3c572d92108f Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 31 Aug 2026 13:37:38 +0800 Subject: [PATCH] fix a bug about ios --- .../UserInterfaceState.xcuserstate | Bin 20828 -> 22397 bytes ios/OralTrainer/Player/PlayerController.swift | 61 ++++++++++---- .../Player/PlayerSurfaceView.swift | 11 +++ .../Views/FullscreenPlayerView.swift | 75 +++++++++++++----- 4 files changed, 113 insertions(+), 34 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 543bbeb0eba76d7096422f4ed5b5daac0592127b..62498e0d2bf9756c6ce873ae8c4eb88678d9448b 100644 GIT binary patch delta 10911 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 H&$j*_=&?o4 delta 10316 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 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)) + } }