UISlider jumps when updating for AVPlayer - ios

I try to implement simple player with UISlider to indicate at what time is current audio file.
In code I have added two observers:
slider.rx.value.subscribe(onNext: { value in
let totalTime = Float(CMTimeGetSeconds(self.player.currentItem!.duration))
let seconds = value * totalTime
let time = CMTime(seconds: Double(seconds), preferredTimescale: CMTimeScale(NSEC_PER_SEC))
self.player.seek(to: time)
}).disposed(by: bag)
let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { [weak self] time in
self?.updateSlider(with: time)
}
with one private function:
private func updateSlider(with time: CMTime) {
let currentTime = CMTimeGetSeconds(time)
var totalTime = CMTimeGetSeconds(player.currentItem!.duration)
if totalTime.isNaN {
totalTime = 0
}
startLabel.text = Int(currentTime).descriptiveDuration
endLabel.text = Int(totalTime).descriptiveDuration
slider.value = Float(currentTime / totalTime)
}
When audio plays, everything is fine and slider is pretty much updated. The problem occurs when I try to move slider manually while audio is playing, then it jumps. Why?
UPDATE:
I know why actually. Because I update it twice: manually and from player observer, but how to prevent from this behaviour? I have no idea;) please, help.

One simple way to go about this would be to prevent addPeriodicTimeObserver from calling self?.updateSlider(with: time) when the slider is being touched.
This can be determined via the UISliders isTracking property:
isTracking
A Boolean value indicating whether the control is currently tracking
touch events.
While tracking of a touch event is in progress, the control sets the
value of this property to true. When tracking ends or is cancelled for
any reason, it sets this property to false.
Ref: https://developer.apple.com/documentation/uikit/uicontrol/1618210-istracking
This is present in all UIControl elements which you can use in this way:
player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { [weak self] time in
//check if slider is being touched/tracked
guard self?.slider.isTracking == false else { return }
//if slider is not being touched, then update the slider from here
self?.updateSlider(with: time)
}
Generic Example:
#IBOutlet var slider: UISlider!
//...
func startSlider() {
slider.value = 0
slider.maximumValue = 10
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] (timer) in
print("Slider at: \(self?.slider.value)")
guard self?.slider.isTracking == false else { return }
self?.updateSlider(to: self!.slider.value + 0.1)
}
}
private func updateSlider(to value: Float) {
slider.value = value
}
I'm sure there are other (better) ways out there but I haven't done much in RxSwift (yet).
I hope this is good enough for now.

Related

Cancel a timer / DispatchQueue.main.asyncAfter in SwiftUI Tap gesture

I am trying to cancel a delayed execution of a function running on the main queue, in a tap gesture, I found a way to create a cancellable DispatchWorkItem, but the issue I have is that it's getting created every time while tapping, and then when I cancel the execution, I actually cancel the new delayed execution and not the first one.
Here is a simpler example with a Timer instead of a DispatchQueue.main.asyncAfter:
.onTapGesture {
isDeleting.toggle()
let timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in
completeTask()
}
if !isDeleting {
timer.invalidate()
}
}
completeTask:
private func completeTask() {
tasksViewModel.deleteTask(task: task) // task is declared above this func at the top level of the struct and so is tasksViewModel, etc.
guard let userID = userViewModel.id?.uuidString else { return }
Task {
//do some async stuff
}
}
As you can see if I click it once the timer fires, but if I click it again, another timer fires and straight away invalidates, but the first timer is still running.
So I have to find a way to create only one instance of that timer.
I tried putting it in the top level of the struct and not inside the var body but the issue now is that I can't use completeTask() because it uses variables that are declared at the same scope.
Also, can't use a lazy initialization because it is an immutable struct.
My goal is to eventually let the user cancel a timed task and reactivate it at will on tapping a button/view. Also, the timed task should use variables that are declared at the top level of the struct.
First of all you need to create a strong reference of timer on local context like so:
var timer: Timer?
and then, set the timer value on onTapGesture closure:
.onTapGesture {
isDeleting.toggle()
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in
completeTask()
}
if !isDeleting {
timer.invalidate()
}
}
and after that you can invalidate this Timer whenever you need by accessing the local variable timer like this:
func doSomething() {
timer?.invalidate()
}
that is my solution mb can help you
var timer: Timer?
private func produceWorkItem(withDelay: Double = 3) {
scrollItem?.cancel()
timer?.invalidate()
scrollItem = DispatchWorkItem.init { [weak self] in
self?.timer = Timer.scheduledTimer(withTimeInterval: withDelay, repeats: false) { [weak self] _ in
self?.goToNextPage(animated: true, completion: { [weak self] _ in self?.produceWorkItem() })
guard let currentVC = self?.viewControllers?.first,
let index = self?.pages.firstIndex(of: currentVC) else {
return
}
self?.pageControl.currentPage = index
}
}
scrollItem?.perform()
}
for stop use scrollItem?.cancel()
for start call func

How to keep two timers in sync

I'm still pretty new to coding and Swift. So bear with me.
Problem Statement : I've got a stopwatch style app that has two concurrent timers start at the same time and display in a mm:ss.SS format, but one is designed to reset to 0 at specific intervals automatically while the other keeps going and tracks total time.
Similar to a "lap" function but it does it automatically. The problem I've encountered is that occasionally the timers aren't perfectly synced up when the user pauses the timers. Since the reset happens at an exact second, both timers should have identical hundredths of a second, while the seconds and minutes will obviously be different. But sometimes the hundredths will be off by .01 or more.
Now, I know Timer isn't designed to be perfectly accurate, and in practice on my app this isn't even a huge deal. My timer doesn't even need to be accurate to the hundredth of a second, and while running it's not noticeably off at all, only while paused. I could display fewer decimal places or none at all, but I prefer the style of showing the hundredths since it fits in well with the stock timer app style.
So if there's a way to make this work, I'd like to keep it.
Screenshot : screenshot
What I tried :
#IBAction func playPauseTapped(_ sender: Any) {
if timerState == .new {
//start new timer
startCurrentTimer()
startTotalTimer()
currentStartTime = Date.timeIntervalSinceReferenceDate
totalStartTime = Date.timeIntervalSinceReferenceDate
timerState = .running
//some ui updates
} else if timerState == .running {
//pause timer
totalTimer.invalidate()
currentTimer.invalidate()
timerState = .paused
pausedTime = Date()
//other ui updates
} else if timerState == .paused {
//resume paused timer
let pausedInterval = Date().timeIntervalSince(pausedTime!)
pausedIntervals.append(pausedInterval)
pausedIntervalsCurrent.append(pausedInterval)
pausedTime = nil
startCurrentTimer()
startTotalTimer()
timerState = .running
//other ui updates
}
}
func startTotalTimer() {
totalTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(runTotalTimer), userInfo: nil, repeats: true)
}
func startCurrentTimer() {
currentTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(runCurrentTimer), userInfo: nil, repeats: true)
}
func resetCurrentTimer() {
currentTimer.invalidate()
currentStartTime = Date.timeIntervalSinceReferenceDate
pausedIntervalsCurrent.removeAll()
startCurrentTimer()
}
#objc func runCurrentTimer() {
let currentTime = Date.timeIntervalSinceReferenceDate
//calculate total paused time
var pausedSeconds = pausedIntervalsCurrent.reduce(0) { $0 + $1 }
if let pausedTime = pausedTime {
pausedSeconds += Date().timeIntervalSince(pausedTime)
}
let currentElapsedTime: TimeInterval = currentTime - currentStartTime - pausedSeconds
currentStepTimeLabel.text = format(time: currentElapsedTime)
if currentElapsedTime >= recipeInterval {
if recipeIndex < recipeTime.count - 1 {
recipeIndex += 1
//ui updates
//reset timer to 0
resetCurrentTimer()
} else {
//last step
currentTimer.invalidate()
}
}
}
#objc func runTotalTimer() {
let currentTime = Date.timeIntervalSinceReferenceDate
//calculate total paused time
var pausedSeconds = pausedIntervals.reduce(0) { $0 + $1 }
if let pausedTime = pausedTime {
pausedSeconds += Date().timeIntervalSince(pausedTime)
}
let totalElapsedTime: TimeInterval = currentTime - totalStartTime - pausedSeconds
totalTimeLabel.text = format(time: totalElapsedTime)
if totalElapsedTime >= recipeTotalTime {
totalTimer.invalidate()
currentTimer.invalidate()
//ui updates
}
}
func format(time: TimeInterval) -> String {
//formats TimeInterval into mm:ss.SS
let formater = DateFormatter()
formater.dateFormat = "mm:ss.SS"
let date = Date(timeIntervalSinceReferenceDate: time)
return formater.string(from: date)
}
You should use a single timer. And when you need a reset to zero, save the current time to a variable.
When presenting the time in the UI, calculate the difference between the running total timer, and the time you saved previously.

Labels displaying countdown sometimes out of sync after pausing. Rounding errors?

I have an app that does a countdown with a Timer. The countdown tracks multiple steps (all at the same intervals) as well as the total time left, and updates 2 separate UILabels accordingly. Occasionally, the labels will be out of sync.
I can't say for sure, but I think it might be only happening when I pause the countdown sometimes, and usually on steps later than the first step. It's most apparent on the last step when the two labels should be displaying the same exact thing, but will sometimes be 1 second off.
The other tricky thing is that sometimes pausing and resuming after the time has gone out of sync will get it back in sync.
My guess is I'm getting something weird happening in the pause code and/or the moving between steps, or maybe the calculating and formatting of TimeIntervals. Also I'm using rounded() on the calculated TimeIntervals because I noticed only updating the timer every 1s the labels would freeze and skip seconds a lot. But I'm unsure if that's the best way to solve this problem.
Here's the relevant code. (still need to work on refactoring but hopefully it's easy to follow, I'm still a beginner)
#IBAction func playPauseTapped(_ sender: Any) {
if timerState == .running {
//pause timer
pauseAnimation()
timer.invalidate()
timerState = .paused
pausedTime = Date()
playPauseButton.setImage(UIImage(systemName: "play.circle"), for: .normal)
} else if timerState == .paused {
//resume paused timer
guard let pause = pausedTime else { return }
let pausedInterval = Date().timeIntervalSince(pause)
startTime = startTime?.addingTimeInterval(pausedInterval)
endTime = endTime?.addingTimeInterval(pausedInterval)
currentStepEndTime = currentStepEndTime?.addingTimeInterval(pausedInterval)
pausedTime = nil
startTimer()
resumeAnimation()
timerState = .running
playPauseButton.setImage(UIImage(systemName: "pause.circle"), for: .normal)
} else {
//first run of brand new timer
startTimer()
startProgressBar()
startTime = Date()
if let totalTime = totalTime {
endTime = startTime?.addingTimeInterval(totalTime)
}
currentStepEndTime = Date().addingTimeInterval(recipeInterval)
timerState = .running
playPauseButton.setImage(UIImage(systemName: "pause.circle"), for: .normal)
currentWater += recipeWater[recipeIndex]
currentWeightLabel.text = "\(currentWater)g"
}
}
func startTimer() {
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(runTimer), userInfo: nil, repeats: true)
}
#objc func runTimer() {
let currentTime = Date()
guard let totalTimeLeft = endTime?.timeIntervalSince(currentTime).rounded() else { return }
guard let currentInterval = currentStepEndTime?.timeIntervalSince(currentTime).rounded() else { return }
//end of current step
if currentInterval <= 0 {
//check if end of recipe
if recipeIndex < recipeWater.count - 1 {
//move to next step
totalTimeLabel.text = totalTimeLeft.stringFromTimeInterval()
currentStepEndTime = Date().addingTimeInterval(recipeInterval)
startProgressBar()
currentStepTimeLabel.text = recipeInterval.stringFromTimeInterval()
stepsTime += recipeInterval
recipeIndex += 1
//update some ui
} else {
//last step
currentStepTimeLabel.text = "00:00"
totalTimeLabel.text = "00:00"
timer.invalidate()
//alert controller saying finished
}
} else {
//update time labels
currentStepTimeLabel.text = currentInterval.stringFromTimeInterval()
totalTimeLabel.text = totalTimeLeft.stringFromTimeInterval()
}
}
extension TimeInterval {
func stringFromTimeInterval() -> String {
let time = NSInteger(self)
let seconds = time % 60
let minutes = (time / 60) % 60
return String(format: "%0.2d:%0.2d",minutes,seconds)
}
}
EDIT UPDATE: I tried a few different things but still kept having the same issue. I started testing with printing the TimeInterval and the formatted string to compare and see what's off. It's definitely some sort of rounding error.
Total - 173.50678288936615 / 02:54
Step - 39.00026595592499 / 00:39
Total - 172.5073879957199 / 02:53
Step - 38.00087106227875 / 00:38
Total - 171.1903439760208 / 02:51
Step - 36.68382704257965 / 00:37
Total - 170.19031596183777 / 02:50
Step - 35.683799028396606 / 00:36
As you can see, the total time skips from 2:53 to 2:51, but the step timer remains consistent. The reason is the TimeInterval for total goes from 172.5 which gets rounded up, to 171.19 which gets rounded down.
I've also watched the timer count down without touching pause, and it remains in sync reliably. So I've narrowed it down to my pause code.
Fixed my issue and posting here for posterity. I ended up making my totalTimeLeft and currentInterval global properties. Then, on pause and resume, instead of tracking the paused time and adding it to endTime, I just used the totalTimeLeft and currentInterval values that are still stored from the last Timer firing and doing endTime = Date().addingTimeInterval(totalTimeLeft) and the same with the interval time. This got rid of the paused time adding weird amounts that would mess up the rounding.

iOS equivalent of Androids ValueAnimator

I would like something that mimics the behaviour in iOS of the ValueAnimator in Android. Under normal circumstances I would use a UIView animation, but unfortunately the objects value im trying to interpolate over time isnt working with a normal animation.
In my particular case im using a Lottie animation, and trying to change the progress of the animation via a UIView animation, but instead of the animation changing the value of the progress over time, it just jumps to the final value. eg:
let lottieAnim = ...
lottieAnim.animationProgress = 0
UIView.animate(withDuration: 2) {
lottieAnim.animationProgress = 1
}
This example does not animate the lottie animation over time, but simply jumps to the end. I know lottie has methods to play the animation, but Im trying to use a custom animation curve to set the progress (its a progress animation that has no finite duration) which is why I need to use a UIView animation to interpolate the value.
I need something that updates intermittently to mimic an animation, the first thing that comes to mind is Android ValueAnimator class.
I put together a class that will mimic a ValueAnimator from android somewhat, and will allow you to specify your own animation curve if need be (default is just linear)
Simple usage
let valueAnimator = ValueAnimator(duration: 2) { value in
animationView.animationProgress = value
}
valueAnimator.start()
Advanced usage
let valueAnimator = ValueAnimator(from: 0, to: 100, duration: 60, animationCurveFunction: { time, duration in
return atan(time)*2/Double.pi
}, valueUpdater: { value in
animationView.animationProgress = value
})
valueAnimator.start()
You can cancel it at any point as well using:
valueAnimator.cancel()
Value Animator class in Swift 5
// swiftlint:disable:next private_over_fileprivate
fileprivate var defaultFunction: (TimeInterval, TimeInterval) -> (Double) = { time, duration in
return time / duration
}
class ValueAnimator {
let from: Double
let to: Double
var duration: TimeInterval = 0
var startTime: Date!
var displayLink: CADisplayLink?
var animationCurveFunction: (TimeInterval, TimeInterval) -> (Double)
var valueUpdater: (Double) -> Void
init (from: Double = 0, to: Double = 1, duration: TimeInterval, animationCurveFunction: #escaping (TimeInterval, TimeInterval) -> (Double) = defaultFunction, valueUpdater: #escaping (Double) -> Void) {
self.from = from
self.to = to
self.duration = duration
self.animationCurveFunction = animationCurveFunction
self.valueUpdater = valueUpdater
}
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink?.add(to: .current, forMode: .default)
}
#objc
private func update() {
if startTime == nil {
startTime = Date()
valueUpdater(from + (to - from) * animationCurveFunction(0, duration))
return
}
var timeElapsed = Date().timeIntervalSince(startTime)
var stop = false
if timeElapsed > duration {
timeElapsed = duration
stop = true
}
valueUpdater(from + (to - from) * animationCurveFunction(timeElapsed, duration))
if stop {
cancel()
}
}
func cancel() {
self.displayLink?.remove(from: .current, forMode: .default)
self.displayLink = nil
}
}
You could probably improve this to be more generic but this serves my purpose.

How to Update player slider handle current value by AVAudioPlayer current time

Now I'm using the MSCircularSlider library as a Cocoapod. Its slider.value is slider.currentValue and when I try to do simple UISlider (try to equal slider.value to player.currentTime), it's working well, but when I try to do it in CircularSlider, the handle is not moving to the AVAudioPlayer.currentTime.
How to solve it?
Please, help me!
let player = AVAudioPlayer()
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let cell = collectionView.cellForItem(at: indexPath) as! CustomCollectionCell
print("tapped")
guard let url = Bundle.main.url(forResource: songsData[indexPath.row].name, withExtension: "mp3") else { return }
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, with: .mixWithOthers)
try AVAudioSession.sharedInstance().setActive(true)
player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
} catch let error {
print(error.localizedDescription)
}
player.prepareToPlay()
timerLabel.text = String(player.currentTime)
slider.alpha = 1
slider.maximumValue = Float(player.duration)
slider.currentValue = 0.0
Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)
player.play()
}
#objc func updateTime(_ timer: Timer) {
let currentTime = player.currentTime
var elapsedTime: TimeInterval = currentTime
let minutes = UInt8(elapsedTime / 60.0)
elapsedTime -= (TimeInterval(minutes) * 60)
let seconds = UInt8(elapsedTime)
elapsedTime -= TimeInterval(seconds)
let strMinutes = String(format: "%02d", minutes)
let strSeconds = String(format: "%02d", seconds)
timerLabel.text = "\(strMinutes):\(strSeconds)"
timer.invalidate()
self.slider.currentValue = Float(self.player.currentTime)
timer.invalidate()
}
Your updateTime(_:) code invalidates your timer. (Twice!) I would expect it to fire once after .01 seconds, then never again, and as a result you probably won't see a visible change in the slider value.
Get rid of the calls to timer.invalidate() except when the sound is finished playing.
I haven't used that particular framework before so I don't know for sure if it works as you're using it but a quick glance at the README file suggests that you are.
On another note, Timer objects are a little crude, and have a resolution of about 0.02 seconds at best, so a timer with an interval of 0.01 seconds isn't likely to fire that often. Also, the refresh rate on iOS device screens is 1/60th of a second, so there's no point in trying to update the screen more often than that.
If you really need smooth drawing that updates on every screen update you should look at using a CADisplayLink timer, but for something as simple as this a timer interval of 1/30 of a second or so should be fine.

Resources