434 lines
13 KiB
Swift
434 lines
13 KiB
Swift
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<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
|
||
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()
|
||
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
|
||
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 = 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 { @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 = 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)
|
||
}
|
||
}
|