From 8b86064fb3055df529fb5ee17e1af7e6672584a2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 30 Apr 2026 05:48:48 -0700 Subject: [PATCH] fix: stop fudging the playback rate to sync video to timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The video player was kept in sync with a virtual timeline clock by nudging videoElement.playbackRate by ±0.1 every 200-500ms whenever they drifted. Each playbackRate write hits the audio decoder, so on audio-sensitive paths (iOS Safari, CarPlay, AirPlay/Bluetooth, macOS audio routes) the audio crackled, stuttered, and dropped. Several layers of workarounds had grown around it: - iOS hid the playback speed UI entirely. - iOS skipped the rate-fudge step (so iOS audio was OK, but iOS video drifted from the timeline and `readyState` got pumped via a force-pause hack to "unstick" the buffer). - Firefox capped at 8x because it mutes audio above 8x. - HLS.js had a custom pause/play orchestration to work around the rate writes interfering with playback. This change inverts the model: the video plays at its declared rate and the timeline follows it, instead of the timeline driving the video. syncVideo now only acts on real divergence: - |diff| > 0.5s → push to video (user seek, loop wrap, initial sync). - |diff| > 0.05s while playing → pull the timeline to the video by dispatching a seek action (Redux only, no media-element touch). Buffering state switches to the browser's own waiting/playing events via ReactPlayer's onBuffer/onBufferEnd, replacing the readyState polling and the iOS-specific force-pause "fix". Drops the 16x cap and the Firefox 8x branch — playback rate is now whatever the user actually selected, not a moving target. Restores the speed UI on iOS, drops the now-unused isFirefox helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/DriveVideo/index.jsx | 101 +++++++-------------------- src/components/TimeDisplay/index.jsx | 47 ++++++------- src/utils/browser.js | 4 -- 3 files changed, 49 insertions(+), 103 deletions(-) diff --git a/src/components/DriveVideo/index.jsx b/src/components/DriveVideo/index.jsx index b35f95b7..f28606aa 100644 --- a/src/components/DriveVideo/index.jsx +++ b/src/components/DriveVideo/index.jsx @@ -12,7 +12,7 @@ import Colors from '../../colors'; import { ErrorOutline } from '../../icons'; import { currentOffset } from '../../timeline'; import { seek, bufferVideo } from '../../timeline/playback'; -import { isIos, isFirefox } from '../../utils/browser.js'; +import { isIos } from '../../utils/browser.js'; const VideoOverlay = ({ loading, error }) => { let content; @@ -37,30 +37,12 @@ const VideoOverlay = ({ loading, error }) => { ); }; -const getVideoState = (videoPlayer) => { - const currentTime = videoPlayer.getCurrentTime(); - const { buffered } = videoPlayer.getInternalPlayer(); - - let bufferRemaining = -1; - for (let i = 0; i < buffered.length; i++) { - const end = buffered.end(i); - if (currentTime >= buffered.start(i) && currentTime <= end) { - bufferRemaining = end - currentTime; - break; - } - } - - return { - bufferRemaining, - hasLoaded: bufferRemaining > 0, - }; -}; - class DriveVideo extends Component { constructor(props) { super(props); this.onVideoBuffering = this.onVideoBuffering.bind(this); + this.onVideoBufferEnd = this.onVideoBufferEnd.bind(this); this.onHlsError = this.onHlsError.bind(this); this.onVideoError = this.onVideoError.bind(this); this.onVideoResume = this.onVideoResume.bind(this); @@ -98,22 +80,20 @@ class DriveVideo extends Component { } onVideoBuffering() { - const { dispatch, currentRoute } = this.props; + const { dispatch } = this.props; const videoPlayer = this.videoPlayer.current; - if (!videoPlayer || !currentRoute || !videoPlayer.getDuration()) { - dispatch(bufferVideo(true)); - } - - if (this.firstSeek) { + if (this.firstSeek && videoPlayer) { this.firstSeek = false; videoPlayer.seekTo(this.currentVideoTime(), 'seconds'); } + dispatch(bufferVideo(true)); + } - const { hasLoaded } = getVideoState(videoPlayer); - const { readyState } = videoPlayer.getInternalPlayer(); - if (!hasLoaded || readyState < 2) { - dispatch(bufferVideo(true)); - } + onVideoBufferEnd() { + const { dispatch, isBufferingVideo } = this.props; + const { videoError } = this.state; + if (videoError) this.setState({ videoError: null }); + if (isBufferingVideo) dispatch(bufferVideo(false)); } /** @@ -201,57 +181,30 @@ class DriveVideo extends Component { } syncVideo() { - const { dispatch, isBufferingVideo, isMuted } = this.props; + const { dispatch, isBufferingVideo, desiredPlaySpeed, currentRoute } = this.props; const videoPlayer = this.videoPlayer.current; if (!videoPlayer || !videoPlayer.getInternalPlayer() || !videoPlayer.getDuration()) { return; } - let { desiredPlaySpeed: newPlaybackRate } = this.props; const desiredVideoTime = this.currentVideoTime(); const curVideoTime = videoPlayer.getCurrentTime(); const timeDiff = desiredVideoTime - curVideoTime; - - if (Math.abs(timeDiff) <= Math.max(0.1, 0.5 * newPlaybackRate)) { // newPlaybackRate = 0 when paused, set minimum 0.1 to prevent seeking when paused - if (!isIos()) { - newPlaybackRate = Math.max(0, newPlaybackRate + Math.round(timeDiff * 10) / 10); + const videoStartOffset = currentRoute?.videoStartOffset || 0; + + if (Math.abs(timeDiff) > 0.5) { + if (desiredVideoTime === 0 && timeDiff < 0 && curVideoTime !== videoPlayer.getDuration()) { + // logs start earlier than the video, snap timeline forward to where video begins + dispatch(seek(currentOffset() - (timeDiff * 1000))); + } else { + // user seek, loop wrap, or initial sync: push to video + videoPlayer.seekTo(desiredVideoTime, 'seconds'); } - } else if (desiredVideoTime === 0 && timeDiff < 0 && curVideoTime !== videoPlayer.getDuration()) { - // logs start earlier than video, so skip to video ts 0 - dispatch(seek(currentOffset() - (timeDiff * 1000))); - } else { - videoPlayer.seekTo(desiredVideoTime, 'seconds'); - } - // most browsers don't support more than 16x playback rate, firefox mutes audio above 8x causing audio to cut in and out with timeDiff rate shifts - newPlaybackRate = Math.max(0, Math.min((isFirefox() && !isMuted) ? 8 : 16, newPlaybackRate)); - - const internalPlayer = videoPlayer.getInternalPlayer(); - - const { hasLoaded } = getVideoState(videoPlayer); - if (isBufferingVideo && internalPlayer.readyState >= 4) { - dispatch(bufferVideo(false)); - } else if (isBufferingVideo || !hasLoaded || internalPlayer.readyState < 2) { - if (!isBufferingVideo) { - dispatch(bufferVideo(true)); - } - newPlaybackRate = 0; // in some circumstances, iOS won't update readyState unless temporarily paused - } - - if (videoPlayer.getInternalPlayer('hls')) { - if (!internalPlayer.paused && newPlaybackRate === 0) { - internalPlayer.pause(); - } else if (internalPlayer.playbackRate !== newPlaybackRate && newPlaybackRate !== 0) { - internalPlayer.playbackRate = newPlaybackRate; - } - if (internalPlayer.paused && newPlaybackRate !== 0) { - const playRes = internalPlayer.play(); - if (playRes) { - playRes.catch(() => console.debug('[DriveVideo] play interrupted by pause')); - } - } - } else { - // TODO: fix iOS bug where video doesn't stop buffering while paused - internalPlayer.playbackRate = newPlaybackRate; + } else if (Math.abs(timeDiff) > 0.05 && desiredPlaySpeed > 0 && !isBufferingVideo) { + // let the video play freely and pull the timeline to match, instead of fudging the playback rate. + // this is the core of the fix — fudging the rate every tick is what crackled audio on iOS, + // CarPlay, and macOS audio routes. + dispatch(seek(curVideoTime * 1000 + videoStartOffset)); } } @@ -314,7 +267,7 @@ class DriveVideo extends Component { }} playbackRate={desiredPlaySpeed} onBuffer={this.onVideoBuffering} - onBufferEnd={this.onVideoResume} + onBufferEnd={this.onVideoBufferEnd} onPlay={this.onVideoResume} onError={this.onVideoError} /> diff --git a/src/components/TimeDisplay/index.jsx b/src/components/TimeDisplay/index.jsx index 1a340b1a..87c9a994 100644 --- a/src/components/TimeDisplay/index.jsx +++ b/src/components/TimeDisplay/index.jsx @@ -15,7 +15,6 @@ import { DownArrow, Forward10, Pause, PlayArrow, Replay10, UpArrow } from '../.. import { currentOffset } from '../../timeline'; import { seek, play, pause } from '../../timeline/playback'; import { getSegmentNumber } from '../../utils'; -import { isIos } from '../../utils/browser.js'; const timerSteps = [ 0.1, @@ -259,30 +258,28 @@ class TimeDisplay extends Component { { displayTime } - {!isIos() && ( -
- - - - - {desiredPlaySpeed} - × - - - - -
- )} +
+ + + + + {desiredPlaySpeed} + × + + + + +
diff --git a/src/utils/browser.js b/src/utils/browser.js index cd4030e5..35dbbf79 100644 --- a/src/utils/browser.js +++ b/src/utils/browser.js @@ -1,7 +1,3 @@ export function isIos() { return /iphone|ipad|ipod/i.test(navigator.userAgent); } - -export function isFirefox() { - return navigator.userAgent.toLowerCase().includes('firefox'); -}