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
/// 线KVOUI 线
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)
}
}