added ios module
This commit is contained in:
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
427
ios/OralTrainer/Player/PlayerController.swift
Normal file
@@ -0,0 +1,427 @@
|
||||
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 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
|
||||
let target = item.sentences.reversed().first { $0.startMs < position - 250 }
|
||||
?? item.sentences.first
|
||||
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 { [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)
|
||||
}
|
||||
}
|
||||
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal file
106
ios/OralTrainer/Player/PlayerSurfaceView.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 与安卓端 OralTrainerPlayerView 对齐的播放区:
|
||||
/// - 单击:播放 / 暂停
|
||||
/// - 左划 / 右划:上一句 / 下一句(无句边界时回退为快退 / 快进)
|
||||
/// - 长按:按触摸点水平位置变速(左慢右快),松手恢复原速
|
||||
struct PlayerSurfaceView: UIViewRepresentable {
|
||||
let controller: PlayerController
|
||||
|
||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||
let view = VideoSurfaceView()
|
||||
view.playerLayer.player = controller.player
|
||||
view.controller = controller
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
||||
uiView.playerLayer.player = controller.player
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoSurfaceView: UIView {
|
||||
weak var controller: PlayerController?
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVPlayerLayer.self
|
||||
}
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
private let minSwipeDistance: CGFloat = 48
|
||||
private let minSwipeVelocity: CGFloat = 160
|
||||
private let longPressTimeout: TimeInterval = 0.5
|
||||
|
||||
private var tapRecognizer: UITapGestureRecognizer!
|
||||
private var panRecognizer: UIPanGestureRecognizer!
|
||||
private var longPressRecognizer: UILongPressGestureRecognizer!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .black
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
panRecognizer.minimumNumberOfTouches = 1
|
||||
panRecognizer.maximumNumberOfTouches = 1
|
||||
longPressRecognizer = UILongPressGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleLongPress)
|
||||
)
|
||||
longPressRecognizer.minimumPressDuration = longPressTimeout
|
||||
|
||||
tapRecognizer.require(toFail: panRecognizer)
|
||||
tapRecognizer.require(toFail: longPressRecognizer)
|
||||
panRecognizer.require(toFail: longPressRecognizer)
|
||||
|
||||
addGestureRecognizer(tapRecognizer)
|
||||
addGestureRecognizer(panRecognizer)
|
||||
addGestureRecognizer(longPressRecognizer)
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func handleTap() {
|
||||
controller?.handleGesture(.singleTap)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard recognizer.state == .ended || recognizer.state == .cancelled else {
|
||||
return
|
||||
}
|
||||
let translation = recognizer.translation(in: self)
|
||||
let velocity = recognizer.velocity(in: self)
|
||||
let dx = translation.x
|
||||
let dy = translation.y
|
||||
guard abs(dx) >= abs(dy),
|
||||
abs(dx) >= minSwipeDistance,
|
||||
abs(velocity.x) >= minSwipeVelocity else {
|
||||
return
|
||||
}
|
||||
controller?.handleGesture(dx > 0 ? .swipeRight : .swipeLeft)
|
||||
}
|
||||
|
||||
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
controller?.beginLongPressSpeed(atX: location.x, width: bounds.width)
|
||||
case .changed:
|
||||
controller?.updateLongPressSpeed(atX: location.x, width: bounds.width)
|
||||
case .ended, .cancelled:
|
||||
controller?.endLongPressSpeed()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user