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)