How to prevent periodicTimeObserver updating the progressBar while user drags the slider manually? - swift

I have a periodicTimeObserver and it updates the elapsed and remaining timeLabels in the way I want, but the slider is jumping. How to prevent periodicTimeObserver updating the UISlider while user drags the slider manually?
This is my UISlider
private lazy var progressBar: UISlider = {
let v = UISlider()
v.translatesAutoresizingMaskIntoConstraints = false
//v.minimumTrackTintColor = UIColor(named: "PlayerColors")
v.isContinuous = false
return v
}()
Periodic time observer which updates the UISlider and the elapsed and remaining time labels.
player = AVPlayer(playerItem: playerItem)
player!.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1, preferredTimescale: 1), queue: DispatchQueue.main) { (CMTime) -> Void in
if self.player!.currentItem?.status == .readyToPlay {
let currentTime : Float64 = CMTimeGetSeconds(self.player!.currentTime());
let totalTime : Float64 = CMTimeGetSeconds(self.player!.currentItem!.duration);
self.progressBar.value = Float(currentTime)
self.progressBar.minimumValue = 0
self.progressBar.maximumValue = Float(totalTime)
self.elapsedTimeLabel.text = self.stringFromTimeInterval(interval: currentTime)
self.remainingTimeLabel.text = self.stringFromTimeIntervalRemaining(interval: totalTime - currentTime)
The function that should seek to a point of the audio and update the time labels.
#objc func progressScrubbed(_ :UISlider) {
let seconds : Int64 = Int64(self.progressBar.value)
let targetTime:CMTime = CMTimeMake(value: seconds, timescale: 1)
player!.seek(to: targetTime)
if player!.rate == 0
{
play()
}
}

You need to know if user is interacting with a slider in order to ignore PeriodicTimeObserver. Moreover you need to reset PeriodicTimeObserver on each seek. So let's create a custom UISlider and override a one method:
class MySlider: UISlider {
var onTouchesBegan: (() -> ())?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
onTouchesBegan?()
}
}
Now you can create a parameter which will track if slider is touched or not and set it in closures of MySlider:
private var isTouchingSlider: Bool = false
private lazy var progressBar: MySlider = {
let v = MySlider()
v.translatesAutoresizingMaskIntoConstraints = false
v.isContinuous = false
v.onTouchesBegan = { [weak self] in
self?.isTouchingSlider = true
}
return v
}()
And your periodic observer methods would look like this:
var periodicObserverToken: Any?
func addPeriodicTimeObserver() {
let interval = CMTime(
seconds: 1,
preferredTimescale: CMTimeScale(NSEC_PER_SEC)
)
periodicObserverToken = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
guard let `self` = self, let player = self.player, player.currentItem?.status == .readyToPlay, let currentItem = self.player.currentItem, !self.isTouchingSlider else { return }
let currentTime : Float64 = CMTimeGetSeconds(player.currentTime());
let totalTime : Float64 = CMTimeGetSeconds(currentItem.duration);
self.progressBar.value = Float(currentTime)
self.progressBar.minimumValue = 0
self.progressBar.maximumValue = Float(totalTime)
}
}
private func removePeriodicTimeObserver() {
guard let periodicObserverToken = periodicObserverToken else { return }
player?.removeTimeObserver(periodicObserverToken)
self.playerPeriodicTimeObserver = nil
}
You need to make all the necessary updates when slider is updated:
#objc func progressScrubbed(_ :UISlider) {
let seconds : Int64 = Int64(self.progressBar.value)
let targetTime:CMTime = CMTimeMake(value: seconds, timescale: 1)
removePeriodicTimeObserver()
isTouchingSlider = false
player!.seek(to: targetTime)
addPeriodicTimeObserver()
if player!.rate == 0 {
play()
}
}

Related

SwiftUI UIViewRepresentable AVPlayer crashing due to "periodTimeObserver"

I have a SwiftUI application which has a carousel of videos. I'm using an AVPlayer with UIViewRepresentable and I'm creating the carousel with a ForEach loop of my custom UIViewRepresentable view. I want to have a "periodicTimeObserver" on the active AVPlayer, but it crashes and says
"An instance of AVPlayer cannot remove a time observer that was added
by a different instance of AVPlayer SwiftUI"
My question is how can I remove the periodicTimeObserver of an AVPlayer inside of a UIViewRepresentable inside of a UIView, without causing the app to crash?
Here is my code:
ForEach(videosArray.indices, id: \.self) { i in
let videoURL = videosArray[i]
ZStack {
VStack {
VideoView.init(viewModel: viewModel, videoURL: URL(string: videoURL)!, videoIndex: i)
}
}
}
struct VideoView: UIViewRepresentable {
#ObservedObject var viewModel = viewModel.init()
var videoURL:URL
var previewLength:Double?
var videoIndex: Int
func makeUIView(context: Context) -> UIView {
return PlayerView.init(frame: .zero, url: videoURL, previewLength: previewLength ?? 6)
}
func updateUIView(_ uiView: UIView, context: Context) {
if videoIndex == viewModel.currentIndexSelected {
if let playerView = uiView as? PlayerView {
if !viewModel.isPlaying {
playerView.pause()
} else {
playerView.play(customStartTime: viewModel.newStartTime, customEndTime: viewModel.newEndTime)
}
}
} else {
if let playerView = uiView as? PlayerView {
playerView.pause()
}
}
}
}
public class ViewModel: ObservableObject {
#Published public var currentIndexSelected: Int = 0
#Published public var isPlaying: Bool = true
#Published public var newStartTime = 0.0
#Published public var newEndTime = 30.0
}
class PlayerView: UIView {
private let playerLayer = AVPlayerLayer()
private var previewTimer:Timer?
var previewLength:Double
var player: AVPlayer?
var timeObserver: Any? = nil
init(frame: CGRect, url: URL, previewLength:Double) {
self.previewLength = previewLength
super.init(frame: frame)
player = AVPlayer(url: url)
player!.volume = 0
player!.play()
playerLayer.player = player
playerLayer.videoGravity = .resizeAspectFill
playerLayer.backgroundColor = UIColor.black.cgColor
layer.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
self.previewLength = 15
super.init(coder: coder)
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
func pause() {
if let timeObserver = timeObserver {
self.player?.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
player?.pause()
}
#objc func replayFinishedItem(noti: NSNotification) {
print("REPLAY FINISHED NOTIIIII: \(noti)")
if let timeDict = noti.object as? [String: Any], let startTime = timeDict["startTime"] as? Double, let endTime = timeDict["endTime"] as? Double/*, let player = timeDict["player"] as? AVPlayer, let observer = timeDict["timeObserver"]*/ {
self.removeTheTimeObserver()
self.play(customStartTime: startTime, customEndTime: endTime)
}
}
#objc func removeTheTimeObserver() {
print("ATTEMPT TO REMOVE IT!")
if let timeObserver = timeObserver {
self.player?.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
}
func play(at playPosition: Double = 0.0, customStartTime: Double = 0.0, customEndTime: Double = 15.0) {
var startTime = customStartTime
var endTime = customEndTime
if customStartTime > customEndTime {
startTime = customEndTime
endTime = customStartTime
}
if playPosition != 0.0 {
player?.seek(to: CMTime(seconds: playPosition, preferredTimescale: CMTimeScale(1)))
} else {
player?.seek(to: CMTime(seconds: startTime, preferredTimescale: CMTimeScale(1)))
}
player?.play()
var timeDict: [String: Any] = ["startTime": startTime, "endTime": endTime]
NotificationCenter.default.addObserver(self, selector: #selector(self.replayFinishedItem(noti:)), name: .customAVPlayerShouldReplayNotification, object: nil)
self.timeObserver = self.player?.addPeriodicTimeObserver(forInterval: CMTime.init(value: 1, timescale: 100), queue: DispatchQueue.main, using: { [weak self] time in
guard let strongSelf = self else {
return
}
let currentTime = CMTimeGetSeconds(strongSelf.player!.currentTime())
let currentTimeStr = String(currentTime)
if let currentTimeDouble = Double(currentTimeStr) {
let userDefaults = UserDefaults.standard
userDefaults.set(currentTimeDouble, forKey: "currentTimeDouble")
NotificationCenter.default.post(name: .currentTimeDouble, object: currentTimeDouble)
if currentTimeDouble >= endTime {
if let timeObserver = strongSelf.timeObserver {
strongSelf.player?.removeTimeObserver(timeObserver)
strongSelf.timeObserver = nil
}
strongSelf.player?.pause()
NotificationCenter.default.post(name: .customAVPlayerShouldReplayNotification, object: timeDict)
} else if let currentItem = strongSelf.player?.currentItem {
let seconds = currentItem.duration.seconds
if currentTimeDouble >= seconds {
if let timeObserver = strongSelf.timeObserver {
strongSelf.player?.removeTimeObserver(timeObserver)
strongSelf.timeObserver = nil
}
NotificationCenter.default.post(name: .customAVPlayerShouldReplayNotification, object: timeDict)
}
}
}
})
}
}

Persisting CABasicAnimation

I'm using an extension I found online to persist a CABasicAnimation that I'm using for my app; the code for that is below. It works and the animation does persist in the sense that the animation layer is not totally removed from the screen, but the issue I'm having is that if the timer is running and the countdown animation has begun and the user leaves the app at 10 seconds lets say and enters back at 15 seconds, the animation continues from 10 seconds but the actual count is ahead.
public class LayerPersistentHelper {
private var persistentAnimations: [String: CAAnimation] = [:]
private var persistentSpeed: Float = 0.0
private weak var layer: CALayer?
public init(with layer: CALayer) {
self.layer = layer
addNotificationObservers()
}
deinit {
removeNotificationObservers()
}}
private extension LayerPersistentHelper {
func addNotificationObservers() {
let center = NotificationCenter.default
let enterForeground = UIApplication.willEnterForegroundNotification
let enterBackground = UIApplication.didEnterBackgroundNotification
center.addObserver(self, selector: #selector(didBecomeActive), name: enterForeground, object: nil)
center.addObserver(self, selector: #selector(willResignActive), name: enterBackground, object: nil)
}
func removeNotificationObservers() {
NotificationCenter.default.removeObserver(self)
}
func persistAnimations(with keys: [String]?) {
guard let layer = self.layer else { return }
keys?.forEach { (key) in
if let animation = layer.animation(forKey: key) {
persistentAnimations[key] = animation
}
}
}
func restoreAnimations(with keys: [String]?) {
guard let layer = self.layer else { return }
keys?.forEach { (key) in
if let animation = persistentAnimations[key] {
layer.add(animation, forKey: key)
}
}
}}
#objc extension LayerPersistentHelper {
func didBecomeActive() {
guard let layer = self.layer else { return }
restoreAnimations(with: Array(persistentAnimations.keys))
persistentAnimations.removeAll()
if persistentSpeed == 1.0 { // if layer was playing before background, resume it
layer.resumeAnimations()
}
}
func willResignActive() {
guard let layer = self.layer else { return }
persistentSpeed = layer.speed
layer.speed = 1.0 // in case layer was paused from outside, set speed to 1.0 to get all animations
persistAnimations(with: layer.animationKeys())
layer.speed = persistentSpeed // restore original speed
layer.pauseAnimations()
}}
public extension CALayer {
var isAnimationsPaused: Bool {
return speed == 0.0
}
static var timeElapsed: Double = 0
func pauseAnimations() {
if !isAnimationsPaused {
let currentTime = CACurrentMediaTime()
let pausedTime = convertTime(currentTime, from: nil)
speed = 0.0
timeOffset = pausedTime
}
}
func resumeAnimations() {
let pausedTime = timeOffset
speed = 1.0
timeOffset = 0.0
beginTime = 0.0
let currentTime = CACurrentMediaTime()
let timeSincePause = convertTime(currentTime, from: nil) - pausedTime
beginTime = timeSincePause
}}
extension CALayer. {
static private var persistentHelperKey = "progressAnim"
func makeAnimationsPersistent() {
var object = objc_getAssociatedObject(self, &CALayer.persistentHelperKey)
if object == nil {
object = LayerPersistentHelper(with: self)
let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
objc_setAssociatedObject(self, &CALayer.persistentHelperKey, object, nonatomic)
}
}}
Any Ideas?
If anyone has this issue I found that setting the begin time equal to
beginTime = convertTime(timeOffset, from: nil)
does the trick

Spawning different nodes randomly

I'm currently in the process of creating a new game that will feature two different balls/bubbles (a player and a enemy). I'm trying to get both of these to spawn randomly at different times kinda like this (ex. player, enemy, player, player, etc.(not in any order)) but random as the game goes on and after the player restarts the game. I'm having trouble trying to find a solution. I've tried a switch case but only either the player or enemy will spawn per game. Does anyone have a solution? If any other code is needed, I will provide it.
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
/* Called when a touch begins */
if (gamestarted == false) {
gamestarted = true
/*Player and Enemy random spawner*/
ballSwitchCase()
/*Tap to start*/
tts.removeFromParent()
} else {
/*removed unnecessary code for question*/
}
func mainBallSpawner() {
/*Main ball*/
let spawn = SKAction.runBlock({
()in
self.creatingTheBall()
})
let delay = SKAction.waitForDuration(3.0)
let spawnDelay = SKAction.sequence([spawn, delay])
let spawnDelayForever = SKAction.repeatActionForever(spawnDelay)
self.runAction(spawnDelayForever, withKey: "spawnDelayForever")
let distance = CGFloat(self.frame.height + 170 + gameBall.frame.width)
let moveBalls = SKAction.moveToY(-distance, duration: NSTimeInterval(0.008 * distance))
let removeBalls = SKAction.removeFromParent()
moveAndRemoveBalls = SKAction.sequence([moveBalls, removeBalls])
}
func enemySpawner() {
/*Enemy*/
let spawnEnemy = SKAction.runBlock({
()in
self.creatingEnemyBall()
})
let delayEnemy = SKAction.waitForDuration(3.0)
let spawnDelayEnemy = SKAction.sequence([spawnEnemy, delayEnemy])
let spawnDelayEnemyForever = SKAction.repeatActionForever(spawnDelayEnemy)
self.runAction(spawnDelayEnemyForever, withKey: "spawnDelayEnemyForever")
let enemyDistance = CGFloat(self.frame.height + 170 + enemyBall.frame.width)
let moveEnemy = SKAction.moveToY(-enemyDistance, duration: NSTimeInterval(0.008 * enemyDistance))
let removeEnemy = SKAction.removeFromParent()
moveAndRemoveEnemy = SKAction.sequence([moveEnemy, removeEnemy])
}
func ballSwitchCase() {
let spawnRandomBall = arc4random_uniform(2)
switch spawnRandomBall {
case 0:
mainBallSpawner()
break
case 1:
enemySpawner()
break
default:
break
}
}
Your code can be much simpler
import SpriteKit
class GameScene: SKScene {
private var gameStarted = false
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
guard !gameStarted else { return }
gameStarted = true
beginCreatingSprites()
}
private func beginCreatingSprites() {
let addSomething = SKAction.runBlock { [weak self] in
if arc4random_uniform(2) == 0 {
self?.addBall()
} else {
self?.addEnemy()
}
}
let wait = SKAction.waitForDuration(3)
let sequence = SKAction.sequence([addSomething, wait])
let repeatForever = SKAction.repeatActionForever(sequence)
self.runAction(repeatForever)
}
private func addBall() {
let ball = SKSpriteNode(imageNamed: "ball")
// set position etc...
self.addChild(ball)
}
private func addEnemy() {
let enemy = SKSpriteNode(imageNamed: "enemy")
// set position etc...
self.addChild(enemy)
}
}

Using NSCore and NSKeyedArchiver with SWIFT

I been trying to get persistent data on my app to have a history of user entries. After I store my data in to array I want to archive it, and after I unarchive it i get weird value instead of what i want to see.
Here is my class for where i store my data
import Foundation
class MyHistory: NSObject, NSCoding {
var kicksNumber: Int
var durationNumber: Int
init(kicksNumber: Int,durationNumber: Int) {
self.kicksNumber = kicksNumber
self.durationNumber = durationNumber
}
required init(coder decoder: NSCoder) {
kicksNumber = decoder.decodeObjectForKey("kicksNumber") as! Int
durationNumber = decoder.decodeObjectForKey("durationNumber") as! Int
}
func encodeWithCoder(coder: NSCoder) {
coder.encodeObject(self.kicksNumber, forKey: "kicksNumber")
coder.encodeObject(self.durationNumber, forKey: "durationNumber")
}
}
Then here is my class where things happen, And where I am testing out the save and load process.
class Kicks: UIViewController {
var myHistoryArray: [MyHistory] = []
var currentMyHistory: MyHistory!
var newHistory = [MyHistory]()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.view.backgroundColor = UIColor(patternImage: UIImage(named: "background13.png")!)
let defaults = NSUserDefaults.standardUserDefaults()
if let savedPeople = defaults.objectForKey("MyHistory") as? NSData {
newHistory = NSKeyedUnarchiver.unarchiveObjectWithData(savedPeople) as! [MyHistory]
//print("this is archived ", newHistory[0])
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
var count = 0 as Int
var countKicks = 0 as Int
var kickReached = false as Bool
var pressedOnce = true as Bool
var timer = NSTimer()
var test: MyHistory!
#IBOutlet var timerLabel: UITextField!
#IBOutlet var kicksLabel: UITextField!
#IBAction func kickButton() {
//currentMyHistory.kicksNumber = 5
if pressedOnce {
pressedOnce = false
timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: Selector("counter"), userInfo: nil, repeats: true)
} else if kickReached {
// let date = NSDate()
// let calendar = NSCalendar.currentCalendar()
// let timer_total = calendar.components([ .Hour, .Minute, .Second], fromDate: date)
} else if !pressedOnce {
countKicks++
kicksLabel.text = "\(countKicks)"
if countKicks == 10 {
kickReached = true
timer.invalidate()
congratsAlert()
currentMyHistory = MyHistory(kicksNumber: 5, durationNumber: 10)
print("this is currentMyHistory", currentMyHistory.kicksNumber )
myHistoryArray.append(currentMyHistory)
test = myHistoryArray[0]
print("this is myHistoryArray0", test.kicksNumber)
//save data
let savedData = NSKeyedArchiver.archivedDataWithRootObject(myHistoryArray)
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setObject(savedData, forKey: "MyHistory")
//load data
//let defaults = NSUserDefaults.standardUserDefaults()
// let person = people[indexPath.item]
//let historyUnarchived = NSKeyedUnarchiver.unarchiveObjectWithFile("/path/to/archive") as? [MyHistory]
// let data1 = NSUserDefaults.standardUserDefaults().objectForKey("myHistoryArray")
print("this is unrachived",newHistory[0])
clear()
}
}
}
// save countKicks, count, and stamp i
func congratsAlert() {
let alert = UIAlertController(title: "Congratulation", message: "Yay!!! Angelina kicked 10 times in less than 2 hours.",preferredStyle: .Alert)
let okAction = UIAlertAction(title: "Ok",style: .Default,handler:{(action:UIAlertAction) -> Void in})
alert.addAction(okAction)
presentViewController(alert,animated: true,completion: nil)
}
func clear() {
count = 0
countKicks = 0
kickReached = false
pressedOnce = true
timerLabel.text = "00:00:0\(count)"
kicksLabel.text = "\(countKicks)"
}
func counter() {
++count
let (hour,minutes,seconds) = secondsToHoursMinutesSeconds(count)
if seconds < 10 && minutes < 10 {
timerLabel.text = "0\(hour):0\(minutes):0\(seconds)"
} else if seconds > 9 && minutes < 10 {
timerLabel.text = "0\(hour):0\(minutes):\(seconds)"
} else if seconds > 9 && minutes > 9 {
timerLabel.text = "0\(hour):\(minutes):\(seconds)"
} else if seconds < 10 && minutes > 9 {
timerLabel.text = "0\(hour):\(minutes):0\(seconds)"
}
}
func secondsToHoursMinutesSeconds (seconds : Int) -> (Int, Int, Int) {
return (seconds / 3600, (seconds % 3600) / 60, (seconds % 3600) % 60)
}
/*
func savePlaces() {
let placesArray = [myHistory(kicksNumber: 420, durationNumber: 89)]
let placesData = NSKeyedArchiver.archivedDataWithRootObject(placesArray)
NSUserDefaults.standardUserDefaults().setObject(placesData, forKey: "kicks")
}
func loadPlaces() {
let placesData = NSUserDefaults.standardUserDefaults().objectForKey("kicks") as? NSData
if let placesData = placesData {
let placesArray = NSKeyedUnarchiver.unarchiveObjectWithData(placesData) as? [myHistory]
if let placesArray = placesArray {
// do something…
}
}
}*/
}
My output is like this:
this is currentMyHistory 5
this is myHistoryArray0 5
this is unrachived
Message from debugger: Terminated due to signal 15
why is unarchived is weird value?
In your MyHistory class you are using ints, so in your encodeWithCoder function you should be using
coder.encodeInteger(self.kicksNumber, forKey: "kicksNumber")
coder.encodeInteger(self.durationNumber, forKey: "durationNumber")
Likewise for your decoder you should be using decodeIntForKey, not decodeObjectForKey.
kicksNumber = decoder.decodeIntegerForKey("kicksNumber")
durationNumber = decoder.decodeIntegerForKey("durationNumber")

UISlider to control AVAudioPlayer

I'm trying to implement a little function in my app. I am currently playing sounds as AVAudioPlayers and that works fine. What I would like to add is to control the sound's position (currentTime) with an UISlider: is there a simple way to do it ?
I looked at an Apple project but it was quite messy....have you got samples or suggestions ?
Thanks to everyone in advance
Shouldn't be a problem - just set the slider to continuous and set the max value to your player's duration after loading your sound file.
Edit
I just did this and it works for me...
- (IBAction)slide {
player.currentTime = slider.value;
}
- (void)updateTime:(NSTimer *)timer {
slider.value = player.currentTime;
}
- (IBAction)play:(id)sender {
NSURL *url = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:#"sound.caf" ofType:nil]];
NSError *error;
player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&error];
if (!player) NSLog(#"Error: %#", error);
[player prepareToPlay];
slider.maximumValue = [player duration];
slider.value = 0.0;
[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:#selector(updateTime:) userInfo:nil repeats:YES];
[player play];
}
The slider is configured in IB, as is a button to start playing.
Swift 3.0 Update:
var player: AVAudioPlayer!
var sliderr: UISlider!
#IBAction func play(_ sender: Any) {
var url = URL(fileURLWithPath: Bundle.main.path(forResource: "sound.caf", ofType: nil)!)
var error: Error?
do {
player = try AVAudioPlayer(contentsOf: url)
}
catch let error {
}
if player == nil {
print("Error: \(error)")
}
player.prepareToPlay()
sliderr.maximumValue = Float(player.duration)
sliderr.value = 0.0
Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self.updateTime), userInfo: nil, repeats: true)
player.play()
}
func updateTime(_ timer: Timer) {
sliderr.value = Float(player.currentTime)
}
#IBAction func slide(_ slider: UISlider) {
player.currentTime = TimeInterval(slider.value)
}
To extend on paull's answer, you'd set the slider to be continuous with a maximum value of your audio player's duration, then add some object of yours (probably the view controller) as a target for the slider's UIControlEventValueChanged event; when you receive the action message, you'd then set the AVAudioPlayer's currentTime property to the slider's value.
You might also want to use an NSTimer to update the slider's value as the audio player plays; +scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: is the easiest way to do that.
I needed to adapt the above answer a bit to get it to work. The issue is that using
slider.maximumValue = [player duration];
slider.value = player.currentTime;
player.currentTime = slider.value;
Do not work because the slider expects a float and the player currentTime and dration return CMTime. To make these work, I adapted them to read:
slider.maximumValue = CMTimeGetSeconds([player duration]);
slider.value = CMTimeGetSeconds(player.currentTime);
player.currentTime = CMTimeMakeWithSeconds((int)slider.value,1);
If you don't need any data in between drag, then you should simply set:
mySlider.isContinuous = false
Otherwise, try below code to controller each phase of touch.
// audio slider bar
private lazy var slider: UISlider = {
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
slider.minimumTrackTintColor = .red
slider.maximumTrackTintColor = .white
slider.setThumbImage(UIImage(named: "sliderThumb"), for: .normal)
slider.addTarget(self, action: #selector(onSliderValChanged(slider:event:)), for: .valueChanged)
// slider.isContinuous = false
return slider
}()
#objc func onSliderValChanged(slider: UISlider, event: UIEvent) {
guard let player = AudioPlayer.shared.player else { return }
if let touchEvent = event.allTouches?.first {
switch touchEvent.phase {
case .began:
// handle drag began
// I would stop the timer when drag begin
timer.invalidate()
case .moved:
// handle drag moved
// Update label's text for current playing time
case .ended:
// update the player's currTime and re-create the timer when drag is done.
player.currentTime = TimeInterval(slider.value)
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(updateTime(_:)), userInfo: nil, repeats: true)
default:
break
}
}
}
Problems that I've faced during playing an audio file and show start/end time and controlling the song with the UISlider.
Not playing audio directly without downloading it in temp folder.
UISlider got crashed on main thread in lower iOS version i.e 12.4/13.1
Smooth Scrolling of UISlider.
Calculating and updating the start/end time of the song.
This answer needs some editing, but it will work without any doubt.
//UISlider init
lazy var slider: UISlider = {
let progress = UISlider()
progress.minimumValue = 0.0
progress.maximumValue = 100.0
progress.tintColor = UIColor.init(named: "ApplicationColor")
return progress
}()
var audioPlayer : AVAudioPlayer?
//First I've downloaded the audio and then playing it.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(trackAudio), userInfo: nil, repeats: true)
if let audioURLString = audioURL{
let urlstring = URL(string: audioURLString)!
downloadFromURL(url: urlstring) { (localURL, response, error) in
if let localURL = localURL{
self.playAudioFile(url: localURL)
}
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopTimer()
}
// Stop TimeInterval After View disappear
func stopTimer() {
if timer != nil {
timer?.invalidate()
audioPlayer?.stop()
audioPlayer = nil
timer = nil
}
}
#objc func sliderSelected(_ sender : UISlider){
if audioPlayer != nil{
if !isPlaying{
self.audioPlayer?.play()
playButton.setImage(UIImage.init(named: "AudioPause"), for: .normal)
isPlaying = true
}else{
self.audioPlayer?.currentTime = TimeInterval(Float(sender.value) * Float(self.audioPlayer!.duration) / 100.0)
if (sender.value / 100.0 == 1.0){
//Do something if audio ends while dragging the UISlider.
}
}
}
}
func downloadFromURL(url:URL,completion: #escaping((_ downladedURL: URL?,_ response :URLResponse?,_ error: Error?) -> Void)){
var downloadTask:URLSessionDownloadTask
downloadTask = URLSession.shared.downloadTask(with: url) {(URL, response, error) in
if let url = URL{
completion(url,nil,nil)
}else if let response = response{
completion(nil,response,nil)
}
if let error = error{
completion(nil,nil,error)
}
}
downloadTask.resume()
}
func playAudioFile(url:URL){
do{
self.audioPlayer = try AVAudioPlayer(contentsOf: url)
self.audioPlayer?.prepareToPlay()
self.audioPlayer?.delegate = self
self.audioPlayer?.play()
let audioDuration = audioPlayer?.duration
let audioDurationSeconds = audioDuration
minutes = Int(audioDurationSeconds!/60);
seconds = Int(audioDurationSeconds!.truncatingRemainder(dividingBy: 60))
} catch{
print("AVAudioPlayer init failed")
}
}
#objc func trackAudio() {
if audioPlayer != nil{
DispatchQueue.main.async {
print("HI")
let normalizedTime = Float(self.audioPlayer!.currentTime * 100.0 / self.audioPlayer!.duration)
self.slider.setValue(normalizedTime, animated: true)
let currentTime = self.audioPlayer?.currentTime
self.currentMinutes = Int(currentTime!/60);
self.currentSeconds = Int(currentTime!.truncatingRemainder(dividingBy: 60))
self.startTimeLabel.text = String(format: "%02i:%02i", self.currentMinutes, self.currentSeconds)
self.endTimeLabel.text = String(format: "%02i:%02i", self.minutes, self.seconds)
}
}
}
If anyone was looking for a simple TouchDown and TouchUp on UI slider then this turns out to be as simple as :
slider.addTarget(self, action: #selector(changeVlaue(_:)), for: .valueChanged)
slider.addTarget(self, action: #selector(sliderTapped), for: .touchDown)
slider.addTarget(self, action: #selector(sliderUntouched), for: .touchUpInside)
Here's the entire setup for an AVAudioPlayer. Some of the code in handleScrubbing() and fingerLiftedFromSlider() is duplicated but whatever...
This will let you show what's on the currentTimeLabel (usually on the left) and the totalDurationLabel (usually on the right) with the scrubber/slider in the the middle of them. When you slide the slider the currentTime will update to show wherever the slider is.
There is something to be aware about. If the the player was playing before you touch the slider, while you slide the slider, the player is still playing. In .began you need to check if the player was playing and if so pause it and set a variable like wasAudioPlayerPlayingBeforeSliderWasTouched to true so that when your finger is lifted it will continue playing from wherever you lift your finger. If you don't pause the player then the slider isn't going to slide smoothly.
When you lift your finger there is a check in onFingerLiftedStartAudioPlayerIfItWasPlayingBeforeTouchesBegan() to see if the slider is at its endTime. If it is instead of playing it'll run the code in audioEndTimeStopEverything().
In the startAudioPlayer method, I used an AVURLAsset get a set the actual url's duration. I got it from this answer which has a great explanation.
I used this code with a local url, not sure how this will work with a remote url.
import UIKit
import AVFoundation
class MyAudioController: UIViewController {
lazy var currentTimeLabel ... { ... }()
lazy var totalDurationLabel ... { ... }()
lazy vay pausePlayButton ... { ... }()
lazy var fastForwardButton ... { ... }()
lazy var rewindButton ... { ... }()
lazy var slider: UISlider = {
let s = UISlider()
s.translatesAutoresizingMaskIntoConstraints = false
s.isContinuous = true
s.minimumTrackTintColor = UIColor.red
s.maximumTrackTintColor = UIColor.blue
s.setThumbImage(UIImage(named: "circleIcon"), for: .normal)
s.addTarget(self, action: #selector(sliderValChanged(slider:event:)), for: .valueChanged)
return s
}()
weak var timer: Timer? // *** MAKE SURE THIS IS WEAK ***
var audioPlayer: AVAudioPlayer?
var wasAudioPlayerPlayingBeforeSliderWasTouched = false
override func viewDidLoad() {
super.viewDidLoad()
guard let myAudioUrl = URL(string: "...") else { return }
setAudio(with: myAudioUrl)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print(error)
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAudioPlayer()
}
// 1. init your AVAudioPlayer here
func setAudioPlayer(with audioTrack: URL) {
do {
stopAudioPlayer() // if something was previously playing
audioPlayer = try AVAudioPlayer(contentsOf: audioTrack)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
audioPlayer?.volume = audioVolume
startAudioPlayer()
} catch let err as NSError {
print(err.localizedDescription)
}
}
// 2. Audio PLAYER - start / stop funcs
stopAudioPlayer() {
stopAudioTimer()
audioPlayer?.pause()
audioPlayer?.stop()
}
func startAudioPlayer() {
if let audioPlayer = audioPlayer, audioPlayer.isPlaying {
audioPlayer.pause()
}
audioPlayer?.currentTime = 0
audioPlayer?.play()
pausePlayButton.setImage(UIImage(named: "pauseIcon"), for: .normal)
startAudioTimer()
}
func startAudioTimer() {
stopAudioTimer()
slider.value = 0
currentTimeLabel.text = "00:00"
totalDurationLabel.text = "00:00"
guard let url = audioPlayer?.url else { return }
let assetOpts = [AVURLAssetPreferPreciseDurationAndTimingKey: true]
let asset = AVURLAsset(url: url, options: assetOpts)
let assetDuration: CMTime = asset.duration
let assetDurationInSecs: Float64 = CMTimeGetSeconds(assetDuration)
slider.maximumValue = Float(assetDurationInSecs)
totalDurationLabel.text = strFromTimeInterval(interval: TimeInterval(assetDurationInSecs))
runAudioTimer()
}
// 3. TIMER funcs
func runAudioTimer() {
if timer == nil {
timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true, block: { [weak self](_) in
self?.audioTimerIsRunning()
})
}
}
func audioTimerIsRunning() {
guard let audioPlayer = audioPlayer else { return }
let currentTime = audioPlayer.currentTime
if Float(currentTime) >= Float(slider.maximumValue) {
stopAudioTimer()
}
currentTimeLabel.text = strFromTimeInterval(interval: currentTime)
slider.value = Float(currentTime)
}
func stopAudioTimer() {
if timer != nil {
timer?.invalidate()
timer = nil
}
}
// slider funcs
#objc func sliderValChanged(slider: UISlider, event: UIEvent) {
if let touchEvent = event.allTouches?.first {
switch touchEvent.phase {
case .began:
checkIfAudioPlayerWasPlayingWhenSliderIsFirstTouched()
stopAudioTimer()
print("Finger Touched")
case .moved:
handleScrubbing()
print("Finger is Moving Scrubber")
case .ended:
print("Finger Lifted")
onFingerLiftedStartAudioPlayerIfItWasPlayingBeforeTouchesBegan()
fingerLiftedFromSlider()
default:
print("Something Else Happened In Slider")
}
}
}
func checkIfAudioPlayerWasPlayingWhenSliderIsFirstTouched() {
guard let audioPlayer = audioPlayer else { return }
if audioPlayer.isPlaying {
audioPlayer.pause()
wasAudioPlayerPlayingBeforeSliderWasTouched = true
}
}
func handleScrubbing() {
guard let audioPlayer = audioPlayer else { return }
let sliderValue = TimeInterval(slider.value)
currentTimeLabel.text = strFromTimeInterval(interval: sliderValue)
audioPlayer.currentTime = sliderValue
if audioPlayer.currentTime >= audioPlayer.duration {
audioEndTimeStopEverything()
}
}
func onFingerLiftedStartAudioPlayerIfItWasPlayingBeforeTouchesBegan() {
if wasAudioPlayerPlayingBeforeSliderWasTouched {
wasAudioPlayerPlayingBeforeSliderWasTouched = false
guard let audioPlayer = audioPlayer else { return }
if slider.value >= slider.maximumValue {
audioEndTimeStopEverything()
} else {
audioPlayer.play()
}
}
}
func fingerLiftedFromSlider() {
guard let audioPlayer = audioPlayer else { return }
if !audioPlayer.isPlaying { // this check is necessary because if you paused the audioPlayer, then started sliding, it should still be paused when you lift you finger up. It it's paused there is no need for the timer function to run.
let sliderValue = TimeInterval(slider.value)
currentTimeLabel.text = strFromTimeInterval(interval: sliderValue)
audioPlayer.currentTime = sliderValue
return
}
runAudioTimer()
}
func audioEndTimeStopEverything() {
stopAudioPlayer()
pausePlayButton.setImage(UIImage("named: playIcon"), for: .normal)
guard let audioPlayer = audioPlayer else { return }
// for some reason when the audioPlayer would reach its end time it kept resetting its currentTime property to zero. I don't know if that's meant to happen or a bug but the currentTime would be zero and the slider would be at the end. To rectify the issue I set them both to their end times
audioPlayer.currentTime = audioPlayer.duration
slider.value = slider.maximumValue
}
}
extension MyAudioController: AVAudioPlayerDelegate {
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
audioEndTimeStopEverything()
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
if let error = error {
print(error.localizedDescription)
}
}
}
Here is the strFromTimeInterval(interval: ) function that I got from here. I only used it because I didn't want to bother with milliseconds. The code above was ran using audio files with minutes and seconds, not hours. If you have any problems with hours you can also swap this function out for this answer
extension MyAudioController {
func strFromTimeInterval(interval: TimeInterval) -> String {
let time = NSInteger(interval)
let seconds = time % 60
let minutes = (time / 60) % 60
let hours = (time / 3600)
var formatString = ""
if hours == 0 {
if (minutes < 10) {
formatString = "%2d:%0.2d"
} else {
formatString = "%0.2d:%0.2d"
}
return String(format: formatString,minutes,seconds)
} else {
formatString = "%2d:%0.2d:%0.2d"
return String(format: formatString,hours,minutes,seconds)
}
}
}