fixed a bug
This commit is contained in:
@@ -26,6 +26,8 @@ final class PlayerController: ObservableObject {
|
||||
private var swipeBackAnchorTime: TimeInterval = 0
|
||||
private var speedBeforeLongPress: Float?
|
||||
private var transientTask: Task<Void, Never>?
|
||||
private var sentenceEndTimeObserver: Any?
|
||||
private var sentenceEndGuardToken = 0
|
||||
|
||||
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
|
||||
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
|
||||
@@ -40,6 +42,7 @@ final class PlayerController: ObservableObject {
|
||||
init() {
|
||||
configureAudioSession()
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
player.actionAtItemEnd = .pause
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
|
||||
queue: .main
|
||||
@@ -55,6 +58,7 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshSentenceBoundaryGuard()
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
@@ -68,6 +72,7 @@ final class PlayerController: ObservableObject {
|
||||
if let endObserver = endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
cancelSentenceBoundaryGuard()
|
||||
itemStatusObserver?.invalidate()
|
||||
timeControlObserver?.invalidate()
|
||||
}
|
||||
@@ -75,6 +80,7 @@ final class PlayerController: ObservableObject {
|
||||
// MARK: - 装载
|
||||
|
||||
func load(_ item: TrainingMediaItem) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
lastSentenceIndex = nil
|
||||
swipeBackAnchorIndex = nil
|
||||
pendingSeekPositionMs = nil
|
||||
@@ -96,11 +102,15 @@ final class PlayerController: ObservableObject {
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
||||
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
||||
refreshSentenceBoundaryGuard(force: true)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
}
|
||||
|
||||
@@ -119,6 +129,7 @@ final class PlayerController: ObservableObject {
|
||||
func stop() {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
cancelSentenceBoundaryGuard()
|
||||
player.pause()
|
||||
seek(to: .zero)
|
||||
}
|
||||
@@ -270,10 +281,12 @@ final class PlayerController: ObservableObject {
|
||||
|
||||
func setContinuousPlayback(_ enabled: Bool) {
|
||||
config.continuousPlayback = enabled
|
||||
refreshSentenceBoundaryGuard()
|
||||
}
|
||||
|
||||
func setSentenceMode(_ enabled: Bool) {
|
||||
config.sentenceMode = enabled
|
||||
refreshSentenceBoundaryGuard()
|
||||
}
|
||||
|
||||
func setLoopMode(_ mode: LoopMode) {
|
||||
@@ -412,7 +425,7 @@ final class PlayerController: ObservableObject {
|
||||
|
||||
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||||
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
||||
private func performSeek(to positionMs: Int64) {
|
||||
private func performSeek(to positionMs: Int64, completion: (() -> Void)? = nil) {
|
||||
let targetPositionMs = max(0, positionMs)
|
||||
seekGeneration += 1
|
||||
let generation = seekGeneration
|
||||
@@ -428,8 +441,10 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
self.pendingSeekPositionMs = nil
|
||||
self.tick()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
refreshSentenceBoundaryGuard()
|
||||
tick()
|
||||
}
|
||||
|
||||
@@ -518,8 +533,122 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func seekRaw(_ positionMs: Int64) {
|
||||
performSeek(to: positionMs)
|
||||
private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
|
||||
performSeek(to: positionMs, completion: completion)
|
||||
}
|
||||
|
||||
/// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时,
|
||||
/// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法,
|
||||
/// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。
|
||||
private func refreshSentenceBoundaryGuard(force: Bool = false) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
guard config.sentenceMode,
|
||||
let item = mediaItem,
|
||||
!item.sentences.isEmpty else {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
guard force || isActivelyPlaying() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let sentence = sentenceBoundaryTarget(in: item) else {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
let currentPosition = referencePositionMs()
|
||||
if currentPosition >= sentence.endMs {
|
||||
applySentenceEndAction(sentence)
|
||||
return
|
||||
}
|
||||
|
||||
let token = sentenceEndGuardToken
|
||||
let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000)
|
||||
// boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界,
|
||||
// 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。
|
||||
player.currentItem?.forwardPlaybackEndTime = endTime
|
||||
sentenceEndTimeObserver = player.addBoundaryTimeObserver(
|
||||
forTimes: [NSValue(time: endTime)],
|
||||
queue: .main
|
||||
) { [weak self] in
|
||||
guard let self, token == self.sentenceEndGuardToken else {
|
||||
return
|
||||
}
|
||||
self.applySentenceEndAction(sentence)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSentenceBoundaryGuard() {
|
||||
sentenceEndGuardToken += 1
|
||||
if let observer = sentenceEndTimeObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
}
|
||||
sentenceEndTimeObserver = nil
|
||||
}
|
||||
|
||||
private func clearForwardPlaybackEnd() {
|
||||
player.currentItem?.forwardPlaybackEndTime = .invalid
|
||||
}
|
||||
|
||||
private func isActivelyPlaying() -> Bool {
|
||||
player.timeControlStatus == .playing
|
||||
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||||
}
|
||||
|
||||
private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? {
|
||||
if stopAtSentenceEnd {
|
||||
guard let index = stopAtSentenceEndIndex else {
|
||||
return nil
|
||||
}
|
||||
return item.sentences.first(where: { $0.index == index })
|
||||
}
|
||||
if config.continuousPlayback {
|
||||
return nil
|
||||
}
|
||||
if let index = lastSentenceIndex ?? currentSentence?.index,
|
||||
let sentence = item.sentences.first(where: { $0.index == index }) {
|
||||
return sentence
|
||||
}
|
||||
|
||||
let position = referencePositionMs()
|
||||
if let sentence = sentence(at: position) {
|
||||
return sentence
|
||||
}
|
||||
return item.sentences.last(where: { $0.startMs <= position })
|
||||
}
|
||||
|
||||
private func applySentenceEndAction(_ target: SentenceBoundary) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
guard let item = mediaItem,
|
||||
let sentence = item.sentences.first(where: { $0.index == target.index }) else {
|
||||
return
|
||||
}
|
||||
lastSentenceIndex = sentence.index
|
||||
|
||||
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
player.pause()
|
||||
seekRaw(sentenceSeekPosition(sentence))
|
||||
tick()
|
||||
return
|
||||
}
|
||||
if config.continuousPlayback {
|
||||
clearForwardPlaybackEnd()
|
||||
return
|
||||
}
|
||||
|
||||
// forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首,
|
||||
// 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。
|
||||
player.pause()
|
||||
seekRaw(sentenceSeekPosition(sentence)) { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.player.play()
|
||||
self.refreshSentenceBoundaryGuard(force: true)
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 {
|
||||
@@ -537,11 +666,47 @@ final class PlayerController: ObservableObject {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
|
||||
// 不能再被识别成整条视频结束。
|
||||
guard pendingSeekPositionMs == nil else {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
|
||||
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
|
||||
if config.sentenceMode,
|
||||
!config.continuousPlayback,
|
||||
let sentence = currentSentence,
|
||||
currentPositionMs() + 30 >= sentence.endMs {
|
||||
applySentenceEndAction(sentence)
|
||||
return
|
||||
}
|
||||
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
|
||||
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
|
||||
guard isNaturalMediaEnd() else {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
performSeek(to: .zero)
|
||||
player.play()
|
||||
tick()
|
||||
}
|
||||
|
||||
private func isNaturalMediaEnd() -> Bool {
|
||||
guard let item = player.currentItem else {
|
||||
return true
|
||||
}
|
||||
let durationMs = durationMs(of: item)
|
||||
let positionMs = currentPositionMs()
|
||||
if durationMs > 0 {
|
||||
return positionMs + 30 >= durationMs
|
||||
}
|
||||
guard let sentence = currentSentence else {
|
||||
return true
|
||||
}
|
||||
return positionMs + 30 >= sentence.endMs
|
||||
}
|
||||
|
||||
// MARK: - 观察器
|
||||
|
||||
private func attachItemObservers(to item: AVPlayerItem) {
|
||||
|
||||
Reference in New Issue
Block a user