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: - // MARK: -
private func setupEndObserver() { private func setupEndObserver() {
// queue: .main 线 Task
endObserverToken = NotificationCenter.default.addObserver( endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in ) { [weak self] notification in
guard let self = self else { return } 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() } Task { @MainActor in self.onPlaybackEnded() }
} }
} }

View File

@ -284,16 +284,17 @@ struct AudioDiagStats {
final class PlayerRecorder: NSObject { final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter? private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput? nonisolated(unsafe) private var videoInput: AVAssetWriterInput?
private var audioInput: 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 durationTimer: Timer?
private var startDate: Date? private var startDate: Date?
private weak var weakOutput: AVPlayerItemVideoOutput? nonisolated(unsafe) private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false nonisolated(unsafe) private var isRunning = false
private var lastPixelBuffer: CVPixelBuffer? nonisolated(unsafe) private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0 nonisolated(unsafe) private var captureFrameCount: Int = 0
// Audio Tap // Audio Tap
private var tapContext: AudioTapContext? private var tapContext: AudioTapContext?
@ -556,7 +557,7 @@ final class PlayerRecorder: NSObject {
isRecording = false isRecording = false
isRunning = false isRunning = false
RecorderState.isFinalizing = true RecorderState.isFinalizing = true
captureTimer?.invalidate() captureTimer?.cancel()
captureTimer = nil captureTimer = nil
stopDurationTimer() stopDurationTimer()
@ -667,17 +668,19 @@ final class PlayerRecorder: NSObject {
lastPixelBuffer = nil lastPixelBuffer = nil
} }
// MARK: - (30fps Timer) // MARK: - (30fps, 线)
private func startCaptureLoop() { private func startCaptureLoop() {
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in let timer = DispatchSource.makeTimerSource(queue: captureQueue)
Task { @MainActor in timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(2))
timer.setEventHandler { [weak self] in
self?.captureVideoFrame() self?.captureVideoFrame()
} }
} timer.resume()
captureTimer = timer
} }
private func captureVideoFrame() { nonisolated private func captureVideoFrame() {
guard isRunning, let output = weakOutput, let input = videoInput, guard isRunning, let output = weakOutput, let input = videoInput,
input.isReadyForMoreMediaData else { return } 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() var info = CMSampleTimingInfo()
info.presentationTimeStamp = time info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30) info.duration = CMTime(value: 1, timescale: 30)