Files
mediaplayer/ios/OralTrainer/Player/PlayerController.swift
2026-09-08 11:11:49 +08:00

569 lines
18 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 swipeBackAnchorIndex: Int?
private var swipeBackAnchorTime: TimeInterval = 0
private var speedBeforeLongPress: Float?
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 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
swipeBackAnchorIndex = 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
}
guard let currentIndex = sentenceAnchorIndex() else { return false }
let target = item.sentences[max(0, currentIndex - 1)]
seekToSentenceBeginning(target)
return true
}
@discardableResult
func seekToNextSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
guard let currentIndex = sentenceAnchorIndex() else { return false }
let targetIndex = currentIndex + 1
guard item.sentences.indices.contains(targetIndex) else {
return false
}
let target = item.sentences[targetIndex]
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
}
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:
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()
}
/// 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(sentenceSeekPosition(anchor))
}
}
private func seekRaw(_ positionMs: Int64) {
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() {
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)
}
}