import AVFoundation import Combine import Foundation /// 与安卓端 OralTrainerController 对齐的播放控制器。 /// 所有属性变更都发生在主线程(时间观察器、KVO、UI 回调均为主线程)。 final class PlayerController: ObservableObject { @Published private(set) var snapshot = PlaybackSnapshot() @Published private(set) var currentItem: TrainingMediaItem? @Published private(set) var currentSentence: SentenceBoundary? @Published var boundaryState: BoundaryState = .idle @Published var transientMessage: String? @Published private(set) var cacheState: VideoCacheState = .notRequired let player = AVPlayer() var config = PlayerConfig() var loopMode: LoopMode = .all private var mediaItem: TrainingMediaItem? private var lastSentenceIndex: Int? private var pendingSeekPositionMs: Int64? private var seekGeneration = 0 /// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在 /// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断 /// 用户是否希望继续播放。 private var playbackIntent = false 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? private var cacheSwapTask: Task? private var activePlaybackURL: URL? private var sentenceEndTimeObserver: Any? private var sentenceEndGuardToken = 0 private static let swipeBackPhaseTimeout: TimeInterval = 1.2 /// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。 /// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。 private static let sentenceSeekInsetMs: Int64 = 20 private static let previousSentenceToleranceMs: Int64 = 250 private static let nextSentenceToleranceMs: Int64 = 150 private var timeObserver: Any? private var endObserver: NSObjectProtocol? private var itemStatusObserver: NSKeyValueObservation? private var timeControlObserver: NSKeyValueObservation? init() { configureAudioSession() player.automaticallyWaitsToMinimizeStalling = true player.actionAtItemEnd = .pause timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main ) { [weak self] _ in self?.tick() } endObserver = NotificationCenter.default.addObserver( forName: AVPlayerItem.didPlayToEndTimeNotification, object: nil, queue: .main ) { [weak self] _ in self?.handleEnded() } timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in DispatchQueue.main.async { self?.refreshSentenceBoundaryGuard() self?.tick() } } tick() } deinit { if let timeObserver = timeObserver { player.removeTimeObserver(timeObserver) } if let endObserver = endObserver { NotificationCenter.default.removeObserver(endObserver) } cancelSentenceBoundaryGuard() itemStatusObserver?.invalidate() timeControlObserver?.invalidate() } // MARK: - 装载 @MainActor func load(_ item: TrainingMediaItem) { cancelSentenceBoundaryGuard() cacheSwapTask?.cancel() cacheSwapTask = nil lastSentenceIndex = nil swipeBackAnchorIndex = nil pendingSeekPositionMs = nil stopAtSentenceEnd = false stopAtSentenceEndIndex = nil playbackIntent = false mediaItem = item currentItem = item boundaryState = .idle if item.isLocalFile { cacheState = .notRequired preparePlaybackItem(with: item.url) } else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) { cacheState = .ready preparePlaybackItem(with: cachedURL) } else { // 关键约束:云端视频没有完整本地文件前,不把 remote URL 交给 AVPlayer。 activePlaybackURL = nil cacheState = .downloading player.replaceCurrentItem(with: nil) tick() scheduleFullFileDownload(for: item) } } /// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。 /// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。 @MainActor func updateBoundaries(_ sentences: [SentenceBoundary]) { guard let oldItem = mediaItem else { return } let newItem = TrainingMediaItem( id: oldItem.id, title: oldItem.title, url: oldItem.url, sentences: sentences, isLocalFile: oldItem.isLocalFile ) mediaItem = newItem currentItem = newItem lastSentenceIndex = nil tick() } // MARK: - 播放控制 func play() { playbackIntent = true player.play() // play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用; // 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。 refreshSentenceBoundaryGuard(force: true) } func pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil playbackIntent = false cancelSentenceBoundaryGuard() player.pause() } func setVolume(_ volume: Float) { player.volume = volume } func togglePlayPause() { if player.timeControlStatus == .playing { pause() } else { play() } } func stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil playbackIntent = false cancelSentenceBoundaryGuard() player.pause() seek(to: .zero) } func seek(to positionMs: Int64) { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil performSeek(to: positionMs) } func seekRelative(_ deltaMs: Int64) { seek(to: referencePositionMs() + deltaMs) } func rewind() { seekRelative(-max(0, config.defaultSeekStepMs)) } func fastForward() { seekRelative(max(0, config.defaultSeekStepMs)) } // MARK: - 按句跳转 @discardableResult func seekToPreviousSentence() -> Bool { guard let item = mediaItem, !item.sentences.isEmpty else { return false } // 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。 // 播放点落在两句之间的空隙时也能正确定位前一句。 let position = referencePositionMs() let target = item.sentences .reversed() .first { $0.startMs < position - Self.previousSentenceToleranceMs } ?? item.sentences[0] seekToSentenceBeginning(target) return true } @discardableResult func seekToNextSentence() -> Bool { guard let item = mediaItem, !item.sentences.isEmpty else { return false } // Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间 // 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。 let position = referencePositionMs() guard let target = item.sentences.first(where: { $0.startMs > position + Self.nextSentenceToleranceMs }) else { return false } 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() } } func nextSentenceOrForward() { if !config.sentenceMode || !seekToNextSentence() { fastForward() } } /// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。 /// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。 @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: sentenceSeekPosition(sentence)) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentence.index play() } // MARK: - 设置 func setContinuousPlayback(_ enabled: Bool) { config.continuousPlayback = enabled refreshSentenceBoundaryGuard() } func setSentenceMode(_ enabled: Bool) { config.sentenceMode = enabled refreshSentenceBoundaryGuard() } func setLoopMode(_ mode: LoopMode) { loopMode = mode } func setPlaybackSpeed(_ speed: Float) { let clamped = min(config.maxPlaybackSpeed, max(config.minPlaybackSpeed, speed)) if player.timeControlStatus == .playing { player.rate = clamped } else { player.defaultRate = clamped } tick() } // MARK: - 长按变速(与安卓端 GestureControlsConfig 一致) func beginLongPressSpeed(atX x: CGFloat, width: CGFloat) { speedBeforeLongPress = snapshot.speed setPlaybackSpeed(speedForTouchX(x, width: width)) showTransientMessage("变速播放中") } func updateLongPressSpeed(atX x: CGFloat, width: CGFloat) { setPlaybackSpeed(speedForTouchX(x, width: width)) } func endLongPressSpeed() { if let speed = speedBeforeLongPress { setPlaybackSpeed(speed) } speedBeforeLongPress = nil } private func speedForTouchX(_ x: CGFloat, width: CGFloat) -> Float { guard width > 0 else { return 1 } let center = width / 2 let distanceRatio = abs(x - center) / center let farFromCenter = distanceRatio >= 0.5 if x < center { return farFromCenter ? 0.5 : 0.75 } return farFromCenter ? 2.0 : 1.5 } // MARK: - 手势消息 func handleGesture(_ kind: GestureKind) { switch kind { case .singleTap: togglePlayPause() showTransientMessage("播放状态已切换") case .swipeLeft: switch replayOrPreviousSentence() { case .restartedCurrent: showTransientMessage("已回到本句开头,再左划返回上一句") case .movedPrevious: showTransientMessage("已跳到上一句") case .stayedAtFirst: showTransientMessage("已是第一句") case .rewound: showTransientMessage("已快退") } case .swipeRight: nextSentenceOrForward() showTransientMessage("已跳到下一句") case .longPressSpeed: break } } func showTransientMessage(_ message: String) { transientMessage = message transientTask?.cancel() transientTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 1_500_000_000) guard !Task.isCancelled else { return } self?.transientMessage = nil } } // MARK: - 状态刷新 private func tick() { guard let item = player.currentItem else { snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed()) return } 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 } if sentence?.index != swipeBackAnchorIndex { swipeBackAnchorIndex = nil } let duration = durationMs(of: item) let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration) snapshot = PlaybackSnapshot( mediaId: mediaItem?.id, positionMs: finalPosition, durationMs: duration, bufferedPositionMs: bufferedPositionMs(of: item), isPlaying: player.timeControlStatus == .playing, state: state, speed: currentSpeed(), sentenceIndex: sentence?.index ) currentSentence = sentence } private func currentPositionMs() -> Int64 { let seconds = player.currentTime().seconds guard seconds.isFinite else { return 0 } return max(0, Int64(seconds * 1000)) } /// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。 private func referencePositionMs() -> Int64 { pendingSeekPositionMs ?? currentPositionMs() } @MainActor private func scheduleFullFileDownload(for item: TrainingMediaItem) { let remoteURL = item.url cacheSwapTask = Task { [weak self] in let cachedURL: URL do { cachedURL = try await StreamingVideoCache.shared.prefetch(remoteURL) } catch is CancellationError { return } catch { self?.cacheState = .failed(message: error.localizedDescription) self?.showTransientMessage("视频缓存失败:\(error.localizedDescription)") return } self?.prepareDownloadedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL) } } @MainActor private func prepareDownloadedFileIfNeeded(remoteURL: URL, cachedURL: URL) { guard !Task.isCancelled, mediaItem?.url == remoteURL, activePlaybackURL == nil else { return } cacheState = .ready preparePlaybackItem(with: cachedURL) showTransientMessage("视频已完整缓存,可以播放") } private func preparePlaybackItem(with playbackURL: URL) { let newItem = AVPlayerItem(url: playbackURL) activePlaybackURL = playbackURL attachItemObservers(to: newItem) player.replaceCurrentItem(with: newItem) let shouldPlay = config.autoPlay || playbackIntent if shouldPlay { player.play() } playbackIntent = shouldPlay tick() } /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 private func performSeek( to positionMs: Int64, resumePlayback: Bool? = nil, completion: (() -> Void)? = nil ) { let targetPositionMs = max(0, positionMs) let shouldResumePlayback = resumePlayback ?? playbackIntent // 必须在发起 seek 前清掉上一句的硬边界。否则从第 1 句右划到第 2 句时, // AVPlayerItem 上的 forwardPlaybackEndTime 仍是第 1 句 endMs;seek 可能 // 被这个旧边界截断,UI 先显示第 2 句,然后立刻又回到第 1 句。 cancelSentenceBoundaryGuard() clearForwardPlaybackEnd() 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() if shouldResumePlayback { self.player.play() } completion?() // seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。 self.refreshSentenceBoundaryGuard(force: shouldResumePlayback) self.tick() } } tick() } private func durationMs(of item: AVPlayerItem) -> Int64 { let seconds = item.duration.seconds guard seconds.isFinite, seconds > 0 else { return -1 } return Int64(seconds * 1000) } private func bufferedPositionMs(of item: AVPlayerItem) -> Int64 { guard let range = item.loadedTimeRanges.last?.timeRangeValue else { return 0 } let seconds = range.end.seconds guard seconds.isFinite else { return 0 } return max(0, Int64(seconds * 1000)) } private func currentSpeed() -> Float { let rate = player.rate return rate == 0 ? player.defaultRate : rate } private func playbackState(of item: AVPlayerItem, positionMs: Int64, durationMs: Int64) -> PlaybackState { if item.status == .failed { return .idle } switch player.timeControlStatus { case .waitingToPlayAtSpecifiedRate: return .buffering case .playing: return .ready case .paused: if durationMs > 0, positionMs >= durationMs { return .ended } return .ready @unknown default: return .idle } } private func sentence(at positionMs: Int64) -> SentenceBoundary? { guard let item = mediaItem else { return nil } return item.sentences.last { sentence in positionMs >= sentence.startMs && positionMs < sentence.endMs } } /// 与安卓端一致:非连续播放时到句尾回到句首;playCurrentSentenceAndStop 播完自动暂停。 private func enforceSentenceBoundary(positionMs: Int64) { guard config.sentenceMode, player.timeControlStatus == .playing, let item = mediaItem, !item.sentences.isEmpty else { return } if stopAtSentenceEnd { guard let index = stopAtSentenceEndIndex, let sentence = item.sentences.first(where: { $0.index == index }) else { return } if positionMs >= sentence.endMs { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil player.pause() seekRaw(sentence.startMs) } return } if config.continuousPlayback { return } guard let anchorIndex = lastSentenceIndex, let anchor = item.sentences.first(where: { $0.index == anchorIndex }) else { return } if positionMs >= anchor.endMs { seekRaw(sentenceSeekPosition(anchor)) } } 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 playbackIntent = false 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 { 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() 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 } // 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停, // 则不应因为到达 media end 而突然自动循环。 performSeek(to: .zero) 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) { itemStatusObserver?.invalidate() itemStatusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in DispatchQueue.main.async { guard let self = self else { return } if item.status == .failed { let message = item.error?.localizedDescription ?? "未知错误" self.showTransientMessage("播放失败:\(message)") } self.tick() } } } private func configureAudioSession() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .moviePlayback) try? session.setActive(true) } }