fixed some bugs

This commit is contained in:
2026-09-08 11:11:49 +08:00
parent 9230db3b69
commit d0d43794f3
5 changed files with 208 additions and 26 deletions

View File

@@ -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)

View File

@@ -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<Void, Never>?
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()
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()

View File

@@ -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 {
onVerticalSwipe?(dy < 0)
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)
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
}
}
private func resetSwipeTracking() {
didDispatchSwipe = false
swipeAxis = nil
}
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {

View File

@@ -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 {