Search code examples
javascriptvue.jssettimeoutrequestanimationframe

How to fix setTimeout/requestAnimationFrame accuracy


For demonstration of the issue please see here:

https://gyazo.com/06e423d07afecfa2fbdb06a6da77f66a

I'm getting a jumping behavior on un-pausing the notification. This is also influenced by how long the mouse stays on the notification and how close the progress is to the end.

I've tried so many things, I'm not sure anymore if the problem is truly with setTimeout.

It is like as if since the calculation of this.timerFinishesAt to the first iteration of requestAnimationFrame the progress jumps due to waiting on cpu time? But then again, why would it be influenced by the hover time and progress.

How do I mitigate the jumping behavior?

I read/tried to implement the fix from the following resources amongst looking at other stackoverflow questions:

https://gist.github.com/tanepiper/4215634

How to create an accurate timer in javascript?

What is the reason JavaScript setTimeout is so inaccurate?

https://www.sitepoint.com/creating-accurate-timers-in-javascript/

https://codepen.io/sayes2x/embed/GYdLqL?default-tabs=js%2Cresult&height=600&host=https%3A%2F%2Fcodepen.io&referrer=https%3A%2F%2Fmedium.com%2Fmedia%2Fb90251c55fe9ac7717ae8451081f6366%3FpostId%3D255f3f5cf50c&slug-hash=GYdLqL

https://github.com/Falc/Tock.js/tree/master

https://github.com/philipyoungg/timer

https://github.com/Aaronik/accurate_timer

https://github.com/husa/timer.js

timerStart(){
   // new future date = future date + elapsed time since pausing
   this.timerFinishesAt = new Date( this.timerFinishesAt.getTime() + (Date.now() - this.timerPausedAt.getTime()) );
   // set new timeout
   this.timerId = window.setTimeout(this.toggleVisibility, (this.timerFinishesAt.getTime() - Date.now()));
   // animation start
   this.progressId = requestAnimationFrame(this.progressBar);
},
timerPause(){
   // stop notification from closing
   window.clearTimeout(this.timerId);
   // set to null so animation won't stay in a loop
   this.timerId = null;
   // stop loader animation from progressing
   cancelAnimationFrame(this.progressId);
   this.progressId = null;

   this.timerPausedAt = new Date();
},
progressBar(){
   if (this.progress < 100) {
     let elapsed = Date.now() - this.timerStarted.getTime();
     let wholeTime = this.timerFinishesAt.getTime() - this.timerStarted.getTime();
     this.progress = Math.ceil((elapsed / wholeTime) * 100);

     if (this.timerId) {
       this.progressId = requestAnimationFrame(this.progressBar);
     }

   } else {
     this.progressId = cancelAnimationFrame(this.progressId);
   }
}

Solution

  • When you do calculate the current progress of your timer, you are not taking the pause time into consideration. Hence the jumps: This part of your code is only aware of the startTime and currentTime, it won't be affected by the pauses.

    To circumvent it, you can either accumulate all this pause times in the startTimer function

    class Timer {
      constructor() {
        this.progress = 0;
        this.totalPauseDuration = 0;
        const d = this.timerFinishesAt = new Date(Date.now() + 10000);
        this.timerStarted = new Date();
        this.timerPausedAt = new Date();
      }
      timerStart() {
        const pauseDuration = (Date.now() - this.timerPausedAt.getTime())
    
        this.totalPauseDuration += pauseDuration;
    
        // new future date = future date + elapsed time since pausing
        this.timerFinishesAt = new Date(this.timerFinishesAt.getTime() + pauseDuration);
        // set new timeout
        this.timerId = window.setTimeout(this.toggleVisibility.bind(this), (this.timerFinishesAt.getTime() - Date.now()));
        // animation start
        this.progressId = requestAnimationFrame(this.progressBar.bind(this));
      }
      timerPause() {
        // stop notification from closing
        window.clearTimeout(this.timerId);
        // set to null so animation won't stay in a loop
        this.timerId = null;
        // stop loader animation from progressing
        cancelAnimationFrame(this.progressId);
        this.progressId = null;
    
        this.timerPausedAt = new Date();
      }
      progressBar() {
        if (this.progress < 100) {
          let elapsed = (Date.now() - this.timerStarted.getTime()) - this.totalPauseDuration;
          let wholeTime = this.timerFinishesAt.getTime() - this.timerStarted.getTime();
          this.progress = Math.ceil((elapsed / wholeTime) * 100);
          
          log.textContent = this.progress;
          
          if (this.timerId) {
            this.progressId = requestAnimationFrame(this.progressBar.bind(this));
          }
    
        } else {
          this.progressId = cancelAnimationFrame(this.progressId);
        }
      }
      toggleVisibility() {
        console.log("done");
      }
    };
    
    const timer = new Timer();
    
    btn.onclick = e => {
      if (timer.timerId) timer.timerPause();
      else timer.timerStart();
    };
    <pre id="log"></pre>
    
    <button id="btn">toggle</button>

    or update the startTime, which seems to be more reliable:

    class Timer {
      constructor() {
        this.progress = 0;
        const d = this.timerFinishesAt = new Date(Date.now() + 10000);
        this.timerStarted = new Date();
        this.timerPausedAt = new Date();
      }
      timerStart() {
        const pauseDuration = (Date.now() - this.timerPausedAt.getTime())
    
        // update timerStarted
        this.timerStarted = new Date(this.timerStarted.getTime() + pauseDuration);
    
        // new future date = future date + elapsed time since pausing
        this.timerFinishesAt = new Date(this.timerFinishesAt.getTime() + pauseDuration);
        // set new timeout
        this.timerId = window.setTimeout(this.toggleVisibility.bind(this), (this.timerFinishesAt.getTime() - Date.now()));
        // animation start
        this.progressId = requestAnimationFrame(this.progressBar.bind(this));
      }
      timerPause() {
        // stop notification from closing
        window.clearTimeout(this.timerId);
        // set to null so animation won't stay in a loop
        this.timerId = null;
        // stop loader animation from progressing
        cancelAnimationFrame(this.progressId);
        this.progressId = null;
    
        this.timerPausedAt = new Date();
      }
      progressBar() {
        if (this.progress < 100) {
          let elapsed = Date.now() - this.timerStarted.getTime();
          let wholeTime = this.timerFinishesAt.getTime() - this.timerStarted.getTime();
          this.progress = Math.ceil((elapsed / wholeTime) * 100);
          
          log.textContent = this.progress;
          
          if (this.timerId) {
            this.progressId = requestAnimationFrame(this.progressBar.bind(this));
          }
    
        } else {
          this.progressId = cancelAnimationFrame(this.progressId);
        }
      }
      toggleVisibility() {
        console.log("done");
      }
    };
    
    const timer = new Timer();
    
    btn.onclick = e => {
      if (timer.timerId) timer.timerPause();
      else timer.timerStart();
    };
    <pre id="log"></pre>
    
    <button id="btn">toggle</button>

    As to the final gap, not seeing how this code is linked with your UI, it's hard to tell what happens.