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? let player = AVPlayer() var config = PlayerConfig() var loopMode: LoopMode = .all private var mediaItem: TrainingMediaItem? private var lastSentenceIndex: Int? private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? private var speedBeforeLongPress: Float? private var transientTask: Task? private var timeObserver: Any? private var endObserver: NSObjectProtocol? private var itemStatusObserver: NSKeyValueObservation? private var timeControlObserver: NSKeyValueObservation? init() { configureAudioSession() player.automaticallyWaitsToMinimizeStalling = true 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?.tick() } } tick() } deinit { if let timeObserver = timeObserver { player.removeTimeObserver(timeObserver) } if let endObserver = endObserver { NotificationCenter.default.removeObserver(endObserver) } itemStatusObserver?.invalidate() timeControlObserver?.invalidate() } // MARK: - 装载 func load(_ item: TrainingMediaItem) { lastSentenceIndex = nil stopAtSentenceEnd = false stopAtSentenceEndIndex = nil mediaItem = item currentItem = item boundaryState = .idle let playerItem = AVPlayerItem(url: item.url) attachItemObservers(to: playerItem) player.replaceCurrentItem(with: playerItem) if config.autoPlay { player.play() } tick() } // MARK: - 播放控制 func play() { player.play() } func pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil player.pause() } func togglePlayPause() { if player.timeControlStatus == .playing { pause() } else { play() } } func stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil player.pause() player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) } func seek(to positionMs: Int64) { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) } func seekRelative(_ deltaMs: Int64) { seek(to: snapshot.positionMs + 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 } let position = snapshot.positionMs let target = item.sentences.reversed().first { $0.startMs < position - 250 } ?? item.sentences.first seek(to: target.startMs) return true } @discardableResult func seekToNextSentence() -> Bool { guard let item = mediaItem, !item.sentences.isEmpty else { return false } let position = snapshot.positionMs guard let target = item.sentences.first(where: { $0.startMs > position + 150 }) else { return false } seek(to: target.startMs) return true } func previousSentenceOrRewind() { if !config.sentenceMode || !seekToPreviousSentence() { rewind() } } func nextSentenceOrForward() { if !config.sentenceMode || !seekToNextSentence() { fastForward() } } /// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。 func playCurrentSentenceAndStop() { guard let sentence = currentSentence else { return } let time = CMTime(seconds: Double(sentence.startMs) / 1000, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) stopAtSentenceEnd = true stopAtSentenceEndIndex = sentence.index play() } // MARK: - 设置 func setContinuousPlayback(_ enabled: Bool) { config.continuousPlayback = enabled } func setSentenceMode(_ enabled: Bool) { config.sentenceMode = enabled } 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: previousSentenceOrRewind() showTransientMessage("已跳到上一句") case .swipeRight: nextSentenceOrForward() showTransientMessage("已跳到下一句") case .longPressSpeed: break } } func showTransientMessage(_ message: String) { transientMessage = message transientTask?.cancel() transientTask = Task { [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 = currentPositionMs() enforceSentenceBoundary(positionMs: position) let finalPosition = currentPositionMs() let sentence = sentence(at: finalPosition) if sentence?.index != lastSentenceIndex { lastSentenceIndex = sentence?.index } 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)) } 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(anchor.startMs) } } private func seekRaw(_ positionMs: Int64) { let time = CMTime(seconds: Double(max(0, positionMs)) / 1000, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) } private func handleEnded() { guard loopMode != .off else { tick() return } player.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero) player.play() tick() } // 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) } }