fixed bugs
This commit is contained in:
@@ -31,8 +31,9 @@ final class PlayerController: ObservableObject {
|
||||
private var swipeBackAnchorTime: TimeInterval = 0
|
||||
private var speedBeforeLongPress: Float?
|
||||
private var transientTask: Task<Void, Never>?
|
||||
private var cacheSwapTask: 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
|
||||
|
||||
@@ -91,8 +92,9 @@ final class PlayerController: ObservableObject {
|
||||
@MainActor
|
||||
func load(_ item: TrainingMediaItem) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
cacheSwapTask?.cancel()
|
||||
cacheSwapTask = nil
|
||||
cacheFallbackTask?.cancel()
|
||||
cacheFallbackTask = nil
|
||||
didAttemptFullFileFallback = false
|
||||
lastSentenceIndex = nil
|
||||
swipeBackAnchorIndex = nil
|
||||
pendingSeekPositionMs = nil
|
||||
@@ -110,12 +112,8 @@ final class PlayerController: ObservableObject {
|
||||
cacheState = .ready
|
||||
preparePlaybackItem(with: cachedURL)
|
||||
} else {
|
||||
// 关键约束:云端视频没有完整本地文件前,不把 remote URL 交给 AVPlayer。
|
||||
activePlaybackURL = nil
|
||||
cacheState = .downloading
|
||||
player.replaceCurrentItem(with: nil)
|
||||
tick()
|
||||
scheduleFullFileDownload(for: item)
|
||||
cacheState = .notRequired
|
||||
preparePlaybackItem(with: item.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,39 +478,8 @@ final class PlayerController: ObservableObject {
|
||||
pendingSeekPositionMs ?? currentPositionMs()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleFullFileDownload(for item: TrainingMediaItem) {
|
||||
let remoteURL = item.url
|
||||
cacheSwapTask = Task { [weak self] in
|
||||
let cachedURL: URL
|
||||
do {
|
||||
cachedURL = try await StreamingVideoCache.shared.prefetch(remoteURL)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
self?.cacheState = .failed(message: error.localizedDescription)
|
||||
self?.showTransientMessage("视频缓存失败:\(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
self?.prepareDownloadedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func prepareDownloadedFileIfNeeded(remoteURL: URL, cachedURL: URL) {
|
||||
guard !Task.isCancelled,
|
||||
mediaItem?.url == remoteURL,
|
||||
activePlaybackURL == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
cacheState = .ready
|
||||
preparePlaybackItem(with: cachedURL)
|
||||
showTransientMessage("视频已完整缓存,可以播放")
|
||||
}
|
||||
|
||||
private func preparePlaybackItem(with playbackURL: URL) {
|
||||
let newItem = AVPlayerItem(url: playbackURL)
|
||||
let newItem = makePlayerItem(url: playbackURL)
|
||||
activePlaybackURL = playbackURL
|
||||
attachItemObservers(to: newItem)
|
||||
player.replaceCurrentItem(with: newItem)
|
||||
@@ -524,6 +491,19 @@ final class PlayerController: ObservableObject {
|
||||
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(
|
||||
@@ -844,12 +824,57 @@ final class PlayerController: ObservableObject {
|
||||
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)
|
||||
|
||||
@@ -13,15 +13,20 @@ struct PlayerSurfaceView: UIViewRepresentable {
|
||||
|
||||
func makeUIView(context: Context) -> VideoSurfaceView {
|
||||
let view = VideoSurfaceView()
|
||||
view.playerLayer.player = controller.player
|
||||
view.controller = controller
|
||||
view.onVerticalSwipe = onVerticalSwipe
|
||||
view.attach(player: controller.player)
|
||||
view.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
|
||||
uiView.playerLayer.player = controller.player
|
||||
uiView.controller = controller
|
||||
uiView.onVerticalSwipe = onVerticalSwipe
|
||||
uiView.attach(player: controller.player)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +34,9 @@ final class VideoSurfaceView: UIView {
|
||||
weak var controller: PlayerController?
|
||||
var onVerticalSwipe: ((Bool) -> Void)?
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVPlayerLayer.self
|
||||
}
|
||||
private let videoLayer = AVPlayerLayer()
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
var playerLayer: AVPlayerLayer { videoLayer }
|
||||
|
||||
private let minSwipeDistance: CGFloat = 48
|
||||
private let minSwipeVelocity: CGFloat = 160
|
||||
@@ -55,7 +56,11 @@ final class VideoSurfaceView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .black
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
isOpaque = true
|
||||
clipsToBounds = true
|
||||
videoLayer.videoGravity = .resizeAspect
|
||||
videoLayer.backgroundColor = UIColor.black.cgColor
|
||||
layer.addSublayer(videoLayer)
|
||||
|
||||
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
@@ -82,6 +87,25 @@ final class VideoSurfaceView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
videoLayer.frame = bounds
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func attach(player: AVPlayer) {
|
||||
if videoLayer.player !== player {
|
||||
videoLayer.player = player
|
||||
}
|
||||
if videoLayer.superlayer !== layer {
|
||||
layer.addSublayer(videoLayer)
|
||||
}
|
||||
videoLayer.frame = bounds
|
||||
videoLayer.isHidden = false
|
||||
}
|
||||
|
||||
@objc private func handleTap() {
|
||||
controller?.handleGesture(.singleTap)
|
||||
}
|
||||
@@ -96,7 +120,6 @@ final class VideoSurfaceView: UIView {
|
||||
case .began:
|
||||
resetSwipeTracking()
|
||||
case .changed:
|
||||
// 在滑动达到阈值时立即派发,并且同一个手势只允许派发一次。
|
||||
evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
|
||||
case .ended, .cancelled, .failed:
|
||||
if !didDispatchSwipe {
|
||||
@@ -112,7 +135,6 @@ final class VideoSurfaceView: UIView {
|
||||
guard !didDispatchSwipe else { return }
|
||||
|
||||
if swipeAxis == nil {
|
||||
// 只有方向足够明确时锁定轴向,避免轻微斜滑被误判为上下滑。
|
||||
if abs(dx) > abs(dy) * 1.2 {
|
||||
swipeAxis = .horizontal
|
||||
} else if abs(dy) > abs(dx) * 1.2 {
|
||||
|
||||
Reference in New Issue
Block a user