fixed some bugs
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user