fix: move video capture off main thread + filter stale end-of-playback notifications

Two fixes for video freeze during recording:

1. Video capture loop (30fps) moved from main-thread Timer to a
   dedicated DispatchSourceTimer on 'miniplayer.videoCapture' queue.
   copyPixelBuffer() on the main thread was competing with AVPlayer's
   rendering pipeline for CPU time, causing frame stalls.

2. AVPlayerItemDidPlayToEndTime observer now checks notification.object
   against player.currentItem. Previously object:nil caught stale
   notifications from replaced items, causing spurious playNext() calls
   that interrupted playback (visible as duplicate playIndex in logs).
This commit is contained in:
yumoqing 2026-06-28 10:34:50 +08:00
parent 9160b3ce2b
commit f68476d57f
2 changed files with 20 additions and 15 deletions

View File

@ -215,11 +215,13 @@ final class PlayerBridge: ObservableObject {
// MARK: - 播放结束
private func setupEndObserver() {
// queue: .main → 回调已在主线程,直接调用,不创建 Task
endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
) { [weak self] notification in
guard let self = self else { return }
// 只处理当前 item 的结束通知,忽略旧 item 被替换时发出的残留通知
guard let endedItem = notification.object as? AVPlayerItem,
endedItem === self.player.currentItem else { return }
Task { @MainActor in self.onPlaybackEnded() }
}
}

View File

@ -284,16 +284,17 @@ struct AudioDiagStats {
final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
nonisolated(unsafe) private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var captureTimer: Timer?
private var captureTimer: DispatchSourceTimer?
private let captureQueue = DispatchQueue(label: "miniplayer.videoCapture", qos: .userInteractive)
private var durationTimer: Timer?
private var startDate: Date?
private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false
private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0
nonisolated(unsafe) private var lastPixelBuffer: CVPixelBuffer?
nonisolated(unsafe) private var captureFrameCount: Int = 0
// Audio Tap
private var tapContext: AudioTapContext?
@ -556,7 +557,7 @@ final class PlayerRecorder: NSObject {
isRecording = false
isRunning = false
RecorderState.isFinalizing = true
captureTimer?.invalidate()
captureTimer?.cancel()
captureTimer = nil
stopDurationTimer()
@ -667,17 +668,19 @@ final class PlayerRecorder: NSObject {
lastPixelBuffer = nil
}
// MARK: - 视频抓帧 (30fps Timer)
// MARK: - 视频抓帧 (30fps, 独立队列避免阻塞主线程渲染)
private func startCaptureLoop() {
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureVideoFrame()
}
let timer = DispatchSource.makeTimerSource(queue: captureQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(2))
timer.setEventHandler { [weak self] in
self?.captureVideoFrame()
}
timer.resume()
captureTimer = timer
}
private func captureVideoFrame() {
nonisolated private func captureVideoFrame() {
guard isRunning, let output = weakOutput, let input = videoInput,
input.isReadyForMoreMediaData else { return }
@ -700,7 +703,7 @@ final class PlayerRecorder: NSObject {
}
}
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
nonisolated private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
var info = CMSampleTimingInfo()
info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30)