Skip to content

Instantly share code, notes, and snippets.

@shaps80
Last active April 18, 2024 22:31
Show Gist options
  • Star 45 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save shaps80/ac16b906938ad256e1f47b52b4809512 to your computer and use it in GitHub Desktop.
Save shaps80/ac16b906938ad256e1f47b52b4809512 to your computer and use it in GitHub Desktop.
Enables smooth frame-by-frame scrubbing (in both directions) – similar to Apple's applications.
public enum Direction {
case forward
case backward
}
internal var player: AVPlayer?
private var isSeekInProgress = false
private var chaseTime = kCMTimeZero
private var preferredFrameRate: Float = 23.98
public func seek(to time: CMTime) {
seekSmoothlyToTime(newChaseTime: time)
}
public func stepByFrame(in direction: Direction) {
let frameRate = preferredFrameRate
?? player?.currentItem?.tracks
.first(where: { $0.assetTrack.mediaType == .video })?
.currentVideoFrameRate
?? -1
let time = player?.currentItem?.currentTime() ?? kCMTimeZero
let seconds = Double(1) / Double(frameRate)
let timescale = Double(seconds) / Double(time.timescale) < 1 ? 600 : time.timescale
let oneFrame = CMTime(seconds: seconds, preferredTimescale: timescale)
let next = direction == .forward
? CMTimeAdd(time, oneFrame)
: CMTimeSubtract(time, oneFrame)
seekSmoothlyToTime(newChaseTime: next)
}
private func seekSmoothlyToTime(newChaseTime: CMTime) {
if CMTimeCompare(newChaseTime, chaseTime) != 0 {
chaseTime = newChaseTime
if !isSeekInProgress {
trySeekToChaseTime()
}
}
}
private func trySeekToChaseTime() {
guard player?.status == .readyToPlay else { return }
actuallySeekToTime()
}
private func actuallySeekToTime() {
isSeekInProgress = true
let seekTimeInProgress = chaseTime
player?.seek(to: seekTimeInProgress, toleranceBefore: kCMTimeZero, toleranceAfter: kCMTimeZero) { [weak self] _ in
guard let `self` = self else { return }
if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 {
self.isSeekInProgress = false
} else {
self.trySeekToChaseTime()
}
}
}
@fitsyu
Copy link

fitsyu commented Oct 26, 2019

Thank you!
This works but need to update few things:

  • kCMTimeZero => CMTime.Zero
  • Direction enum and stepByFrame function are not used
    
    lazy internal var player: AVPlayer? = { return videoView.player }()
    
    private var isSeekInProgress = false
    private var chaseTime = CMTime.zero
    
    public func seek(to time: CMTime) {
        seekSmoothlyToTime(newChaseTime: time)
    }
    
    private func seekSmoothlyToTime(newChaseTime: CMTime) {
        if CMTimeCompare(newChaseTime, chaseTime) != 0 {
            chaseTime = newChaseTime
            
            if !isSeekInProgress {
                trySeekToChaseTime()
            }
        }
    }
    
    private func trySeekToChaseTime() {
        guard player?.status == .readyToPlay else { return }
        actuallySeekToTime()
    }
    
    private func actuallySeekToTime() {
        isSeekInProgress = true
        let seekTimeInProgress = chaseTime
        
        player?.seek(to: seekTimeInProgress, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
            guard let `self` = self else { return }
            
            if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 {
                self.isSeekInProgress = false
            } else {
                self.trySeekToChaseTime()
            }
        }
    }

@shaps80
Copy link
Author

shaps80 commented Nov 4, 2019

Its a convenience function so you don't need all that faff in your own code ;)
The CMTime stuff agreed, needs to be updated. I actually have other improvements to this in the real code anyway. This was just a nice starter for anyone having the same problem. Thanks anyway.

@shaps80
Copy link
Author

shaps80 commented Nov 4, 2019

Also, you've introduced a dependency videoView. A gist should be considered drop-in code only.

@colinhumber
Copy link

Thank you for this! I was looking for some way to smooth out my seeking. This is working brilliantly.

@shaps80
Copy link
Author

shaps80 commented Apr 6, 2020

Thank you for this! I was looking for some way to smooth out my seeking. This is working brilliantly.

Great! 👍

@Talhasahi
Copy link

I have scrubbing issue .My salider is jumpy I implemented your code but still same issue .
Can you help me ?

@Talhasahi
Copy link

import UIKit
import AVFoundation
import UIKit
import AVKit
import CoreMedia
class ViewController: UIViewController {

public enum Direction {
    case forward
    case backward
}
private var isSeekInProgress = false
private var chaseTime = CMTime.zero
private var preferredFrameRate: Float = 23.98







 var timeObserverToken: Any?
let playerLayer: AVPlayerLayer = AVPlayerLayer()

var videoPlayer: AVPlayer?
@IBOutlet weak var VedioView: UIView!
@IBOutlet weak var videoSlider: UISlider!
override func viewDidLoad() {

    VedioView.backgroundColor = .red
    let videoAsset: [AVAsset] = [
    AVAsset( url: Bundle.main.url(forResource: "1-2", withExtension: "mp4")! ),
    AVAsset( url: Bundle.main.url(forResource: "2-3", withExtension: "mp4")! ),
    AVAsset( url: Bundle.main.url(forResource: "3-4", withExtension: "mp4")! ),
    AVAsset( url: Bundle.main.url(forResource: "4-5", withExtension: "mp4")! ),
    AVAsset( url: Bundle.main.url(forResource: "5-1", withExtension: "mp4")! )]



   
     
    let videoComposition = AVMutableComposition()
    var playerItem: AVPlayerItem!
   
    var lastTime: CMTime = CMTime.zero

    let videoCompositionTrack = videoComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid))
    for clipIndex in videoAsset {

        do {
            try videoCompositionTrack?.insertTimeRange(CMTimeRangeMake(start: CMTime.zero, duration: clipIndex.duration),
                                                       of: clipIndex.tracks(withMediaType: AVMediaType.video)[0] ,
                                                       at: lastTime)
            
            lastTime = CMTimeAdd(lastTime, clipIndex.duration)
            
            
        } catch {
            print("Failed to insert track")
        }
    }
    playerItem = AVPlayerItem(asset: videoComposition)
    let timeRange = CMTimeRangeMake(start: CMTime.zero, duration: CMTime(seconds:playerItem.duration.seconds, preferredTimescale: 1))
    
    videoComposition.scaleTimeRange(timeRange, toDuration: CMTimeMultiplyByFloat64(playerItem.duration, multiplier: Float64(0.5)))
    
   videoPlayer = AVPlayer(playerItem: playerItem)
   
    playerLayer.player = videoPlayer
    playerLayer.frame = self.VedioView.bounds //bounds of
    playerLayer.videoGravity = .resizeAspectFill
    self.VedioView.layer.addSublayer(playerLayer)
    videoPlayer?.volume = 0.0
    videoPlayer?.play()
    
   addPeriodicTimeObserver()
   NotificationCenter.default.addObserver(self,
         selector: #selector(playerItemDidReachEnd(notification:)),
         name: .AVPlayerItemDidPlayToEndTime,
         object: videoPlayer?.currentItem)
   videoSlider.addTarget(self, action: #selector(handleSliderChange), for: .valueChanged)
    videoSlider.addTarget(self, action: #selector(onSlidertouch), for: .touchDown)
    videoSlider.addTarget(self, action: #selector(onSlidertouchUp), for:.touchUpInside )
    videoSlider.addTarget(self, action: #selector(saliderMoving), for: .touchUpOutside )
}
@objc func playerItemDidReachEnd(notification: Notification) {
   if let playerItem = notification.object as? AVPlayerItem {
         playerItem.seek(to: CMTime.zero, completionHandler: nil)
    videoPlayer?.play()
     }
  
}
@IBAction func saliderAction(_ sender: Any) {
}
@objc func onSlidertouch(){
    
    removePeriodicTimeObserver()
   // timer.invalidate()
    videoPlayer?.pause()
}
@objc func onSlidertouchUp(){
  
    addPeriodicTimeObserver()

    videoPlayer?.play()
}
@objc func saliderMoving(){
addPeriodicTimeObserver()

    videoPlayer?.play()
         
}
func removePeriodicTimeObserver() {
       if let timeObserverToken = timeObserverToken {
        videoPlayer?.removeTimeObserver(timeObserverToken)
           self.timeObserverToken = nil
       }
   }
@objc func handleSliderChange() {
    if let duration = videoPlayer?.currentItem?.duration {
                      let totalSeconds = CMTimeGetSeconds(duration)
                    
                      let value = Float64(videoSlider.value) * totalSeconds
                 
        let seekTime = CMTime(value: Int64(value), timescale: CMTimeScale(1))

// videoPlayer.seek(to: seekTime, completionHandler: { (completedSeek) in
//
//
// })
seek( to: seekTime)

              }
        
    }

func addPeriodicTimeObserver() {

         // Notify every half second
         let interval = CMTime(value: 1, timescale: 100)
timeObserverToken =  videoPlayer?.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main, using: { (progressTime) in
    let seconds = CMTimeGetSeconds(progressTime)
              
    if let duration = self.videoPlayer?.currentItem?.duration {
                            let durationSeconds = CMTimeGetSeconds(duration)
                      
        let currentTime = Double((self.videoPlayer?.currentTime().seconds)!)
 
self.videoSlider.value = Float(seconds / durationSeconds)
}
    })
     }

@IBAction func pLAY(_ sender: UIButton) {
    videoPlayer?.play()
}
@IBAction func pAUSE(_ sender: UIButton) {
    videoPlayer?.pause()
}

public func seek(to time: CMTime) {
seekSmoothlyToTime(newChaseTime: time)
}

 public func stepByFrame(in direction: Direction) {
     let frameRate = preferredFrameRate
        ?? videoPlayer?.currentItem?.tracks
            .first(where: { $0.assetTrack?.mediaType == .video })?
             .currentVideoFrameRate
         ?? -1

    let time = videoPlayer?.currentItem?.currentTime() ?? CMTime.zero
     let seconds = Double(1) / Double(frameRate)
     let timescale = Double(seconds) / Double(time.timescale) < 1 ? 600 : time.timescale
     let oneFrame = CMTime(seconds: seconds, preferredTimescale: timescale)
     let next = direction == .forward
         ? CMTimeAdd(time, oneFrame)
         : CMTimeSubtract(time, oneFrame)

     seekSmoothlyToTime(newChaseTime: next)
 }

 private func seekSmoothlyToTime(newChaseTime: CMTime) {
     if CMTimeCompare(newChaseTime, chaseTime) != 0 {
         chaseTime = newChaseTime

         if !isSeekInProgress {
             trySeekToChaseTime()
         }
     }
 }

 private func trySeekToChaseTime() {
    guard videoPlayer?.status == .readyToPlay else { return }
     actuallySeekToTime()
 }

 private func actuallySeekToTime() {
     isSeekInProgress = true
     let seekTimeInProgress = chaseTime

    videoPlayer?.seek(to: seekTimeInProgress, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { [weak self] _ in
         guard let `self` = self else { return }

         if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 {
             self.isSeekInProgress = false
         } else {
             self.trySeekToChaseTime()
         }
     }
 }

}

//This is my code which have jumpy Scrubbing

@standinga
Copy link

standinga commented Mar 10, 2022

@Talhasahi Did you solve your issue? I have the same problem when I'm using AVComposition.
Btw. looking briefly at your code:
CMTime(seconds:playerItem.duration.seconds, preferredTimescale: 1) this might fail if the duration is not exactly seconds as the preferredTimescale is 1 (ie. duration of 3.5 seconds will fail)

@beezital
Copy link

@shaps80
Copy link
Author

shaps80 commented Dec 14, 2022

https://stackoverflow.com/a/17331242/2742007

avPlayer.currentItem?.step(byCount: isForward ? 1 : -1)

https://developer.apple.com/documentation/avfoundation/avplayeritem/1387968-step

This is not relevant I'm afraid. Or more importantly it's incomplete. If you read (or even try) that API out yourself, you'll note that it's not performant in most user-driven cases and cannot chase smoothly. This is because it doesn't cancel previous seeks.

The provided example solves this problem for high performance cases. The API you're suggesting is fine for non-interactive scenarios though 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment