884 lines
31 KiB
Swift
884 lines
31 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?
|
||
@Published private(set) var cacheState: VideoCacheState = .notRequired
|
||
|
||
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
|
||
/// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在
|
||
/// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断
|
||
/// 用户是否希望继续播放。
|
||
private var playbackIntent = false
|
||
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 var cacheFallbackTask: Task<Void, Never>?
|
||
private var activePlaybackURL: URL?
|
||
private var didAttemptFullFileFallback = false
|
||
private var sentenceEndTimeObserver: Any?
|
||
private var sentenceEndGuardToken = 0
|
||
|
||
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
|
||
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
|
||
/// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。
|
||
private static let sentenceSeekInsetMs: Int64 = 20
|
||
private static let previousSentenceToleranceMs: Int64 = 250
|
||
private static let nextSentenceToleranceMs: Int64 = 150
|
||
|
||
private var timeObserver: Any?
|
||
private var endObserver: NSObjectProtocol?
|
||
private var itemStatusObserver: NSKeyValueObservation?
|
||
private var timeControlObserver: NSKeyValueObservation?
|
||
|
||
init() {
|
||
configureAudioSession()
|
||
player.automaticallyWaitsToMinimizeStalling = true
|
||
player.actionAtItemEnd = .pause
|
||
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?.refreshSentenceBoundaryGuard()
|
||
self?.tick()
|
||
}
|
||
}
|
||
tick()
|
||
}
|
||
|
||
deinit {
|
||
if let timeObserver = timeObserver {
|
||
player.removeTimeObserver(timeObserver)
|
||
}
|
||
if let endObserver = endObserver {
|
||
NotificationCenter.default.removeObserver(endObserver)
|
||
}
|
||
cancelSentenceBoundaryGuard()
|
||
itemStatusObserver?.invalidate()
|
||
timeControlObserver?.invalidate()
|
||
}
|
||
|
||
// MARK: - 装载
|
||
|
||
@MainActor
|
||
func load(_ item: TrainingMediaItem) {
|
||
cancelSentenceBoundaryGuard()
|
||
cacheFallbackTask?.cancel()
|
||
cacheFallbackTask = nil
|
||
didAttemptFullFileFallback = false
|
||
lastSentenceIndex = nil
|
||
swipeBackAnchorIndex = nil
|
||
pendingSeekPositionMs = nil
|
||
stopAtSentenceEnd = false
|
||
stopAtSentenceEndIndex = nil
|
||
playbackIntent = false
|
||
mediaItem = item
|
||
currentItem = item
|
||
boundaryState = .idle
|
||
|
||
if item.isLocalFile {
|
||
cacheState = .notRequired
|
||
preparePlaybackItem(with: item.url)
|
||
} else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) {
|
||
cacheState = .ready
|
||
preparePlaybackItem(with: cachedURL)
|
||
} else {
|
||
cacheState = .notRequired
|
||
preparePlaybackItem(with: item.url)
|
||
}
|
||
}
|
||
|
||
/// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。
|
||
/// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。
|
||
@MainActor
|
||
func updateBoundaries(_ sentences: [SentenceBoundary]) {
|
||
guard let oldItem = mediaItem else {
|
||
return
|
||
}
|
||
let newItem = TrainingMediaItem(
|
||
id: oldItem.id,
|
||
title: oldItem.title,
|
||
url: oldItem.url,
|
||
sentences: sentences,
|
||
isLocalFile: oldItem.isLocalFile
|
||
)
|
||
mediaItem = newItem
|
||
currentItem = newItem
|
||
lastSentenceIndex = nil
|
||
tick()
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
|
||
func play() {
|
||
playbackIntent = true
|
||
player.play()
|
||
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
||
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
||
refreshSentenceBoundaryGuard(force: true)
|
||
}
|
||
|
||
func pause() {
|
||
stopAtSentenceEnd = false
|
||
stopAtSentenceEndIndex = nil
|
||
playbackIntent = false
|
||
cancelSentenceBoundaryGuard()
|
||
player.pause()
|
||
}
|
||
|
||
func setVolume(_ volume: Float) {
|
||
player.volume = volume
|
||
}
|
||
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
pause()
|
||
} else {
|
||
play()
|
||
}
|
||
}
|
||
|
||
func stop() {
|
||
stopAtSentenceEnd = false
|
||
stopAtSentenceEndIndex = nil
|
||
playbackIntent = false
|
||
cancelSentenceBoundaryGuard()
|
||
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
|
||
}
|
||
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
|
||
// 播放点落在两句之间的空隙时也能正确定位前一句。
|
||
let position = referencePositionMs()
|
||
let target = item.sentences
|
||
.reversed()
|
||
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
|
||
?? item.sentences[0]
|
||
seekToSentenceBeginning(target)
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
func seekToNextSentence() -> Bool {
|
||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||
return false
|
||
}
|
||
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
|
||
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
|
||
let position = referencePositionMs()
|
||
guard let target = item.sentences.first(where: {
|
||
$0.startMs > position + Self.nextSentenceToleranceMs
|
||
}) else {
|
||
return false
|
||
}
|
||
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
|
||
}
|
||
|
||
/// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。
|
||
/// 必须先打上句尾标记再 seek,这样 completion 里的 forwardPlaybackEndTime
|
||
/// 会卡在当前句 endMs,而不是等 250ms 观察器才停。
|
||
func playCurrentSentenceAndStop() {
|
||
guard let sentence = currentSentence else {
|
||
return
|
||
}
|
||
stopAtSentenceEnd = true
|
||
stopAtSentenceEndIndex = sentence.index
|
||
lastSentenceIndex = sentence.index
|
||
playbackIntent = true
|
||
performSeek(to: sentenceSeekPosition(sentence), resumePlayback: true)
|
||
}
|
||
|
||
// MARK: - 设置
|
||
|
||
func setContinuousPlayback(_ enabled: Bool) {
|
||
config.continuousPlayback = enabled
|
||
if !enabled {
|
||
lastSentenceIndex = currentSentence?.index ?? lastSentenceIndex
|
||
}
|
||
refreshSentenceBoundaryGuard(force: true)
|
||
}
|
||
|
||
func setSentenceMode(_ enabled: Bool) {
|
||
config.sentenceMode = enabled
|
||
refreshSentenceBoundaryGuard()
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
private func preparePlaybackItem(with playbackURL: URL) {
|
||
let newItem = makePlayerItem(url: playbackURL)
|
||
activePlaybackURL = playbackURL
|
||
attachItemObservers(to: newItem)
|
||
player.replaceCurrentItem(with: newItem)
|
||
let shouldPlay = config.autoPlay || playbackIntent
|
||
if shouldPlay {
|
||
player.play()
|
||
}
|
||
playbackIntent = shouldPlay
|
||
tick()
|
||
}
|
||
|
||
private func makePlayerItem(url: URL) -> AVPlayerItem {
|
||
guard !url.isFileURL else {
|
||
return AVPlayerItem(url: url)
|
||
}
|
||
var options: [String: Any] = [:]
|
||
let ext = url.pathExtension.lowercased()
|
||
if ext.isEmpty || ["mp4", "m4v", "mov"].contains(ext) {
|
||
options["AVURLAssetOverrideMIMETypeKey"] = "video/mp4"
|
||
}
|
||
let asset = AVURLAsset(url: url, options: options.isEmpty ? nil : options)
|
||
return AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["playable", "tracks", "duration"])
|
||
}
|
||
|
||
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
||
private func performSeek(
|
||
to positionMs: Int64,
|
||
resumePlayback: Bool? = nil,
|
||
completion: (() -> Void)? = nil
|
||
) {
|
||
let targetPositionMs = max(0, positionMs)
|
||
let shouldResumePlayback = resumePlayback ?? playbackIntent
|
||
// 必须在发起 seek 前清掉上一句的硬边界。否则从第 1 句右划到第 2 句时,
|
||
// AVPlayerItem 上的 forwardPlaybackEndTime 仍是第 1 句 endMs;seek 可能
|
||
// 被这个旧边界截断,UI 先显示第 2 句,然后立刻又回到第 1 句。
|
||
cancelSentenceBoundaryGuard()
|
||
clearForwardPlaybackEnd()
|
||
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()
|
||
if shouldResumePlayback {
|
||
self.player.play()
|
||
}
|
||
completion?()
|
||
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
|
||
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
|
||
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, completion: (() -> Void)? = nil) {
|
||
performSeek(to: positionMs, completion: completion)
|
||
}
|
||
|
||
/// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时,
|
||
/// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法,
|
||
/// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。
|
||
private func refreshSentenceBoundaryGuard(force: Bool = false) {
|
||
cancelSentenceBoundaryGuard()
|
||
guard config.sentenceMode,
|
||
let item = mediaItem,
|
||
!item.sentences.isEmpty else {
|
||
clearForwardPlaybackEnd()
|
||
return
|
||
}
|
||
guard force || isActivelyPlaying() else {
|
||
return
|
||
}
|
||
|
||
guard let sentence = sentenceBoundaryTarget(in: item) else {
|
||
clearForwardPlaybackEnd()
|
||
return
|
||
}
|
||
let currentPosition = referencePositionMs()
|
||
if currentPosition >= sentence.endMs {
|
||
applySentenceEndAction(sentence)
|
||
return
|
||
}
|
||
|
||
let token = sentenceEndGuardToken
|
||
let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000)
|
||
// boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界,
|
||
// 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。
|
||
player.currentItem?.forwardPlaybackEndTime = endTime
|
||
sentenceEndTimeObserver = player.addBoundaryTimeObserver(
|
||
forTimes: [NSValue(time: endTime)],
|
||
queue: .main
|
||
) { [weak self] in
|
||
guard let self, token == self.sentenceEndGuardToken else {
|
||
return
|
||
}
|
||
self.applySentenceEndAction(sentence)
|
||
}
|
||
}
|
||
|
||
private func cancelSentenceBoundaryGuard() {
|
||
sentenceEndGuardToken += 1
|
||
if let observer = sentenceEndTimeObserver {
|
||
player.removeTimeObserver(observer)
|
||
}
|
||
sentenceEndTimeObserver = nil
|
||
}
|
||
|
||
private func clearForwardPlaybackEnd() {
|
||
player.currentItem?.forwardPlaybackEndTime = .invalid
|
||
}
|
||
|
||
private func isActivelyPlaying() -> Bool {
|
||
player.timeControlStatus == .playing
|
||
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||
}
|
||
|
||
private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? {
|
||
if stopAtSentenceEnd {
|
||
guard let index = stopAtSentenceEndIndex else {
|
||
return nil
|
||
}
|
||
return item.sentences.first(where: { $0.index == index })
|
||
}
|
||
if config.continuousPlayback {
|
||
return nil
|
||
}
|
||
if let index = lastSentenceIndex ?? currentSentence?.index,
|
||
let sentence = item.sentences.first(where: { $0.index == index }) {
|
||
return sentence
|
||
}
|
||
|
||
let position = referencePositionMs()
|
||
if let sentence = sentence(at: position) {
|
||
return sentence
|
||
}
|
||
return item.sentences.last(where: { $0.startMs <= position })
|
||
}
|
||
|
||
private func applySentenceEndAction(_ target: SentenceBoundary) {
|
||
cancelSentenceBoundaryGuard()
|
||
guard let item = mediaItem,
|
||
let sentence = item.sentences.first(where: { $0.index == target.index }) else {
|
||
return
|
||
}
|
||
lastSentenceIndex = sentence.index
|
||
|
||
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
|
||
stopAtSentenceEnd = false
|
||
stopAtSentenceEndIndex = nil
|
||
playbackIntent = false
|
||
player.pause()
|
||
seekRaw(sentenceSeekPosition(sentence))
|
||
tick()
|
||
return
|
||
}
|
||
if config.continuousPlayback {
|
||
clearForwardPlaybackEnd()
|
||
return
|
||
}
|
||
|
||
// forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首,
|
||
// 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。
|
||
player.pause()
|
||
seekRaw(sentenceSeekPosition(sentence)) { [weak self] in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.player.play()
|
||
self.refreshSentenceBoundaryGuard(force: true)
|
||
self.tick()
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
|
||
// 不能再被识别成整条视频结束。
|
||
guard pendingSeekPositionMs == nil else {
|
||
tick()
|
||
return
|
||
}
|
||
// 评测“播放原句”即使处于连续播放,也要用当前句硬边界,不能滑进下一句。
|
||
if stopAtSentenceEnd,
|
||
let index = stopAtSentenceEndIndex,
|
||
let sentence = mediaItem?.sentences.first(where: { $0.index == index }) {
|
||
applySentenceEndAction(sentence)
|
||
return
|
||
}
|
||
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
|
||
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
|
||
if config.sentenceMode,
|
||
!config.continuousPlayback,
|
||
let sentence = currentSentence,
|
||
currentPositionMs() + 30 >= sentence.endMs {
|
||
applySentenceEndAction(sentence)
|
||
return
|
||
}
|
||
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
|
||
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
|
||
guard isNaturalMediaEnd() else {
|
||
tick()
|
||
return
|
||
}
|
||
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
|
||
// 则不应因为到达 media end 而突然自动循环。
|
||
performSeek(to: .zero)
|
||
tick()
|
||
}
|
||
|
||
private func isNaturalMediaEnd() -> Bool {
|
||
guard let item = player.currentItem else {
|
||
return true
|
||
}
|
||
let durationMs = durationMs(of: item)
|
||
let positionMs = currentPositionMs()
|
||
if durationMs > 0 {
|
||
return positionMs + 30 >= durationMs
|
||
}
|
||
guard let sentence = currentSentence else {
|
||
return true
|
||
}
|
||
return positionMs + 30 >= sentence.endMs
|
||
}
|
||
|
||
// 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)")
|
||
} else if item.status == .readyToPlay {
|
||
self.considerFullFileFallback(for: item)
|
||
}
|
||
self.tick()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func considerFullFileFallback(for item: AVPlayerItem) {
|
||
guard !didAttemptFullFileFallback,
|
||
let media = mediaItem,
|
||
!media.isLocalFile,
|
||
!(activePlaybackURL?.isFileURL ?? false) else {
|
||
return
|
||
}
|
||
let hasVisibleVideo = item.presentationSize.height >= 2 && item.presentationSize.width >= 2
|
||
if hasVisibleVideo {
|
||
return
|
||
}
|
||
didAttemptFullFileFallback = true
|
||
cacheFallbackTask?.cancel()
|
||
cacheFallbackTask = Task { @MainActor [weak self] in
|
||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||
guard let self, !Task.isCancelled else {
|
||
return
|
||
}
|
||
guard self.mediaItem?.id == media.id,
|
||
self.player.currentItem === item,
|
||
item.presentationSize.height < 2 else {
|
||
return
|
||
}
|
||
do {
|
||
let cachedURL = try await StreamingVideoCache.shared.prefetch(media.url)
|
||
guard self.mediaItem?.id == media.id else {
|
||
return
|
||
}
|
||
let shouldPlay = self.playbackIntent
|
||
let positionMs = self.pendingSeekPositionMs ?? self.currentPositionMs()
|
||
self.preparePlaybackItem(with: cachedURL)
|
||
self.cacheState = .ready
|
||
if positionMs > 0 {
|
||
self.performSeek(to: positionMs, resumePlayback: shouldPlay)
|
||
}
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
self.showTransientMessage("视频画面加载失败:\(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func configureAudioSession() {
|
||
let session = AVAudioSession.sharedInstance()
|
||
try? session.setCategory(.playback, mode: .moviePlayback)
|
||
try? session.setActive(true)
|
||
}
|
||
}
|