Files
mediaplayer/ios/OralTrainer/Player/PlayerController.swift
2026-08-31 13:37:38 +08:00

467 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 pendingSeekPositionMs: Int64?
private var seekGeneration = 0
private var stopAtSentenceEnd = false
private var stopAtSentenceEndIndex: Int?
private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>?
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
pendingSeekPositionMs = 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 setVolume(_ volume: Float) {
player.volume = volume
}
func togglePlayPause() {
if player.timeControlStatus == .playing {
pause()
} else {
play()
}
}
func stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
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
}
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)
return true
}
@discardableResult
func seekToNextSentence() -> Bool {
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 {
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
}
performSeek(to: sentence.startMs)
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 { @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
}
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()
}
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
private func performSeek(to positionMs: Int64) {
let targetPositionMs = max(0, positionMs)
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()
}
}
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(anchor.startMs)
}
}
private func seekRaw(_ positionMs: Int64) {
performSeek(to: positionMs)
}
private func handleEnded() {
guard loopMode != .off else {
tick()
return
}
performSeek(to: .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)
}
}