fixed some bugs
This commit is contained in:
Binary file not shown.
@@ -50,6 +50,13 @@ enum GestureKind {
|
|||||||
case longPressSpeed
|
case longPressSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SwipeBackResult {
|
||||||
|
case restartedCurrent
|
||||||
|
case movedPrevious
|
||||||
|
case stayedAtFirst
|
||||||
|
case rewound
|
||||||
|
}
|
||||||
|
|
||||||
enum BoundaryState: Equatable {
|
enum BoundaryState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading(isLocal: Bool)
|
case loading(isLocal: Bool)
|
||||||
|
|||||||
@@ -22,9 +22,16 @@ final class PlayerController: ObservableObject {
|
|||||||
private var seekGeneration = 0
|
private var seekGeneration = 0
|
||||||
private var stopAtSentenceEnd = false
|
private var stopAtSentenceEnd = false
|
||||||
private var stopAtSentenceEndIndex: Int?
|
private var stopAtSentenceEndIndex: Int?
|
||||||
|
private var swipeBackAnchorIndex: Int?
|
||||||
|
private var swipeBackAnchorTime: TimeInterval = 0
|
||||||
private var speedBeforeLongPress: Float?
|
private var speedBeforeLongPress: Float?
|
||||||
private var transientTask: Task<Void, Never>?
|
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 timeObserver: Any?
|
||||||
private var endObserver: NSObjectProtocol?
|
private var endObserver: NSObjectProtocol?
|
||||||
private var itemStatusObserver: NSKeyValueObservation?
|
private var itemStatusObserver: NSKeyValueObservation?
|
||||||
@@ -69,6 +76,7 @@ final class PlayerController: ObservableObject {
|
|||||||
|
|
||||||
func load(_ item: TrainingMediaItem) {
|
func load(_ item: TrainingMediaItem) {
|
||||||
lastSentenceIndex = nil
|
lastSentenceIndex = nil
|
||||||
|
swipeBackAnchorIndex = nil
|
||||||
pendingSeekPositionMs = nil
|
pendingSeekPositionMs = nil
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
@@ -140,12 +148,9 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = referencePositionMs()
|
guard let currentIndex = sentenceAnchorIndex() else { return false }
|
||||||
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
let target = item.sentences[max(0, currentIndex - 1)]
|
||||||
?? item.sentences.first else {
|
seekToSentenceBeginning(target)
|
||||||
return false
|
|
||||||
}
|
|
||||||
seek(to: target.startMs)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,14 +159,47 @@ final class PlayerController: ObservableObject {
|
|||||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let position = referencePositionMs()
|
guard let currentIndex = sentenceAnchorIndex() else { return false }
|
||||||
guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else {
|
let targetIndex = currentIndex + 1
|
||||||
|
guard item.sentences.indices.contains(targetIndex) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
seek(to: target.startMs)
|
let target = item.sentences[targetIndex]
|
||||||
|
seekToSentenceBeginning(target)
|
||||||
return true
|
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() {
|
func previousSentenceOrRewind() {
|
||||||
if !config.sentenceMode || !seekToPreviousSentence() {
|
if !config.sentenceMode || !seekToPreviousSentence() {
|
||||||
rewind()
|
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() {
|
func playCurrentSentenceAndStop() {
|
||||||
guard let sentence = currentSentence else {
|
guard let sentence = currentSentence else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
performSeek(to: sentence.startMs)
|
performSeek(to: sentenceSeekPosition(sentence))
|
||||||
stopAtSentenceEnd = true
|
stopAtSentenceEnd = true
|
||||||
stopAtSentenceEndIndex = sentence.index
|
stopAtSentenceEndIndex = sentence.index
|
||||||
play()
|
play()
|
||||||
@@ -249,8 +330,16 @@ final class PlayerController: ObservableObject {
|
|||||||
togglePlayPause()
|
togglePlayPause()
|
||||||
showTransientMessage("播放状态已切换")
|
showTransientMessage("播放状态已切换")
|
||||||
case .swipeLeft:
|
case .swipeLeft:
|
||||||
previousSentenceOrRewind()
|
switch replayOrPreviousSentence() {
|
||||||
showTransientMessage("已跳到上一句")
|
case .restartedCurrent:
|
||||||
|
showTransientMessage("已回到本句开头,再左划返回上一句")
|
||||||
|
case .movedPrevious:
|
||||||
|
showTransientMessage("已跳到上一句")
|
||||||
|
case .stayedAtFirst:
|
||||||
|
showTransientMessage("已是第一句")
|
||||||
|
case .rewound:
|
||||||
|
showTransientMessage("已快退")
|
||||||
|
}
|
||||||
case .swipeRight:
|
case .swipeRight:
|
||||||
nextSentenceOrForward()
|
nextSentenceOrForward()
|
||||||
showTransientMessage("已跳到下一句")
|
showTransientMessage("已跳到下一句")
|
||||||
@@ -290,6 +379,9 @@ final class PlayerController: ObservableObject {
|
|||||||
if sentence?.index != lastSentenceIndex {
|
if sentence?.index != lastSentenceIndex {
|
||||||
lastSentenceIndex = sentence?.index
|
lastSentenceIndex = sentence?.index
|
||||||
}
|
}
|
||||||
|
if sentence?.index != swipeBackAnchorIndex {
|
||||||
|
swipeBackAnchorIndex = nil
|
||||||
|
}
|
||||||
let duration = durationMs(of: item)
|
let duration = durationMs(of: item)
|
||||||
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
|
||||||
snapshot = PlaybackSnapshot(
|
snapshot = PlaybackSnapshot(
|
||||||
@@ -422,7 +514,7 @@ final class PlayerController: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if positionMs >= anchor.endMs {
|
if positionMs >= anchor.endMs {
|
||||||
seekRaw(anchor.startMs)
|
seekRaw(sentenceSeekPosition(anchor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +522,16 @@ final class PlayerController: ObservableObject {
|
|||||||
performSeek(to: positionMs)
|
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() {
|
private func handleEnded() {
|
||||||
guard loopMode != .off else {
|
guard loopMode != .off else {
|
||||||
tick()
|
tick()
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ final class VideoSurfaceView: UIView {
|
|||||||
private var tapRecognizer: UITapGestureRecognizer!
|
private var tapRecognizer: UITapGestureRecognizer!
|
||||||
private var panRecognizer: UIPanGestureRecognizer!
|
private var panRecognizer: UIPanGestureRecognizer!
|
||||||
private var longPressRecognizer: UILongPressGestureRecognizer!
|
private var longPressRecognizer: UILongPressGestureRecognizer!
|
||||||
|
private var didDispatchSwipe = false
|
||||||
|
private var swipeAxis: SwipeAxis?
|
||||||
|
|
||||||
|
private enum SwipeAxis {
|
||||||
|
case horizontal
|
||||||
|
case vertical
|
||||||
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -80,25 +87,65 @@ final class VideoSurfaceView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||||
guard recognizer.state == .ended || recognizer.state == .cancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let translation = recognizer.translation(in: self)
|
let translation = recognizer.translation(in: self)
|
||||||
let velocity = recognizer.velocity(in: self)
|
let velocity = recognizer.velocity(in: self)
|
||||||
let dx = translation.x
|
let dx = translation.x
|
||||||
let dy = translation.y
|
let dy = translation.y
|
||||||
if abs(dy) > abs(dx),
|
|
||||||
abs(dy) >= minSwipeDistance,
|
switch recognizer.state {
|
||||||
abs(velocity.y) >= minSwipeVelocity {
|
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)
|
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 {
|
private func resetSwipeTracking() {
|
||||||
return
|
didDispatchSwipe = false
|
||||||
}
|
swipeAxis = nil
|
||||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onReceive(catalogVM.$activeVideoHash) { videoHash in
|
||||||
|
syncCurrentCourse(with: videoHash)
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
.fullScreenCover(isPresented: $showFullscreenPlayer) {
|
||||||
FullscreenPlayerView(controller: controller) {
|
FullscreenPlayerView(controller: controller) {
|
||||||
exitFullscreen()
|
exitFullscreen()
|
||||||
@@ -264,6 +267,29 @@ struct RootView: View {
|
|||||||
activeModule = .train
|
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
|
// MARK: - Mine Page
|
||||||
|
|
||||||
private var minePage: some View {
|
private var minePage: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user