diff --git a/src/App.test.jsx b/src/App.test.jsx index cb53f4ecd..4ac637bef 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -6,7 +6,7 @@ import App from './App'; import { createInitialState } from './initialState'; import { createAppStore } from './store'; -const mocks = vi.hoisted(() => ({ authenticated: true, options: {}, requests: [], hardNavigate: vi.fn() })); +const mocks = vi.hoisted(() => ({ authenticated: true, options: {}, requests: [], hardNavigate: vi.fn(), playerProps: null })); vi.mock('@commaai/my-comma-auth', () => ({ default: { @@ -41,6 +41,7 @@ vi.mock('react-map-gl', () => ({ })); vi.mock('react-player/file', () => ({ default: React.forwardRef((_props, ref) => { + mocks.playerProps = _props; React.useImperativeHandle(ref, () => ({ getCurrentTime: () => 0, getDuration: () => 60, @@ -154,6 +155,7 @@ describe('whole-app behavior', () => { localStorage.clear(); sessionStorage.clear(); mocks.hardNavigate.mockClear(); + mocks.playerProps = null; }); test('root uses a valid stored device and keeps the selection', async () => { @@ -271,4 +273,22 @@ describe('whole-app behavior', () => { fireEvent.click(within(document.body).getByRole('button', { name: 'Close' })); await waitFor(() => expect(history.location.pathname).toBe(`/${FIRST}`)); }); + + test('a failed video leaves timeline navigation owned by Redux', async () => { + const { store } = await renderApp(`/${FIRST}/${LOG}`); + const timeline = await screen.findByRole('slider', { name: 'Drive timeline' }); + + act(() => { + mocks.playerProps.onError('hlsError', { + fatal: true, + type: 'networkError', + response: { code: 404 }, + }); + }); + expect(store.getState().videoStatus).toBe('failed'); + + fireEvent.pointerDown(timeline, { button: 0, clientX: 500, pageX: 500 }); + fireEvent.pointerUp(timeline, { button: 0, clientX: 500, pageX: 500 }); + expect(store.getState().offset).toBe(30000); + }); }); diff --git a/src/actions/cached.js b/src/actions/cached.js index aec490749..6d060ef53 100644 --- a/src/actions/cached.js +++ b/src/actions/cached.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/react'; import * as Types from './types'; +import { api } from '../api/backend'; import { reverseLookup } from '../utils/geocode'; const USE_LOCAL_COORDS_DATA = import.meta.env.VITE_APP_LOCAL_COORDS_DATA === 'true'; diff --git a/src/actions/index.js b/src/actions/index.js index bbdf18168..ea2ff6d6e 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -4,7 +4,7 @@ import { athena as Athena, billing as Billing } from '../api'; import { api } from '../api/backend'; import * as Types from './types'; -import { resetPlayback, selectLoop } from '../timeline/playback'; +import { selectLoop } from '../timeline/playback'; import {hasRoutesData } from '../timeline/segments'; import { getDeviceFromState, deviceVersionAtLeast, deviceIsOnline } from '../utils'; import { webrtcConnectionManager } from '../utils/webrtc'; @@ -165,7 +165,6 @@ export function urlForState(dongleId, log_id, start, end, prime) { function updateTimeline(state, dispatch, log_id, start, end, allowPathChange) { if (!state.loop || !state.loop.startTime || !state.loop.duration || state.loop.startTime < start || state.loop.startTime + state.loop.duration > end || state.loop.duration < end - start) { - dispatch(resetPlayback()); dispatch(selectLoop(start, end)); } diff --git a/src/actions/types.js b/src/actions/types.js index 74c08fa7f..f816cbf29 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -26,9 +26,12 @@ export const ACTION_PRIME_SUBSCRIBE_INFO = 'ACTION_PRIME_SUBSCRIBE_INFO'; export const ACTION_SEEK = 'ACTION_SEEK'; export const ACTION_PAUSE = 'ACTION_PAUSE'; export const ACTION_PLAY = 'ACTION_PLAY'; +export const ACTION_PLAYBACK_SPEED = 'ACTION_PLAYBACK_SPEED'; export const ACTION_LOOP = 'ACTION_LOOP'; export const ACTION_BUFFER_VIDEO = 'ACTION_BUFFER_VIDEO'; export const ACTION_RESET = 'ACTION_RESET'; +export const ACTION_HAS_AUDIO = 'ACTION_HAS_AUDIO'; +export const ACTION_VIDEO_STATUS = 'ACTION_VIDEO_STATUS'; // segments export const ACTION_UPDATE_SEGMENT_RANGE = 'ACTION_UPDATE_SEGMENT_RANGE'; diff --git a/src/api/demo.js b/src/api/demo.js index 5de89a49c..ea11efae3 100644 --- a/src/api/demo.js +++ b/src/api/demo.js @@ -32,8 +32,6 @@ const DEMO_PROFILE = { const AFFECTED_SEGMENT = 1; -// One clone per test case. Each case mutates a fresh clone of the real public -// data on its way into the frontend. const MISSING_DATA_CASES = [ { title: 'Epoch date/time (no clock)', @@ -102,16 +100,21 @@ const MISSING_DATA_CASES = [ }, ]; -// Keep two full-length routes for every case: one where the whole route is -// affected and one where only a single segment is affected. -const TEST_CASES = MISSING_DATA_CASES.flatMap((testCase) => [ - testCase, + +const TEST_CASES = [ { - ...testCase, - title: `${testCase.title} (1 segment)`, - affectedSegment: AFFECTED_SEGMENT, + title: 'Public route (no issues)', + route() {}, }, -]); + ...MISSING_DATA_CASES.flatMap((testCase) => [ + testCase, + { + ...testCase, + title: `${testCase.title} (1 segment)`, + affectedSegment: AFFECTED_SEGMENT, + }, + ]), +]; function fileSegmentNumber(file) { const pathParts = new URL(file).pathname.split('/'); @@ -168,7 +171,7 @@ export function createDemoBackend(realBackend) { } // Clone the cached public route into fresh demo routes on every call, each - // with a unique demo route ID and one mutation per test case. + // with a unique demo route ID and its test case's mutation, if any. async function listDemoRoutes(routeStr) { const publicRoute = await fetchPublicRoute(); const routes = TEST_CASES.map((testCase, index) => { diff --git a/src/components/DriveMap/index.jsx b/src/components/DriveMap/index.jsx index 3e51b0b3f..11b84bca3 100644 --- a/src/components/DriveMap/index.jsx +++ b/src/components/DriveMap/index.jsx @@ -4,7 +4,9 @@ import { connect } from 'react-redux'; import ReactMapGL, { LinearInterpolator } from 'react-map-gl'; import { fetchDriveCoords } from '../../actions/cached'; -import { currentOffset } from '../../timeline'; +import { VideoStatus } from '../../timeline/playback'; +import { getVideoPlayerCurrentTime } from '../../timeline/videoPlayer'; +import { isIos } from '../../utils/browser'; import { DEFAULT_LOCATION, MAPBOX_STYLE, MAPBOX_TOKEN } from '../../utils/geocode'; const INTERACTION_TIMEOUT = 5000; @@ -44,7 +46,7 @@ class DriveMap extends Component { } componentDidUpdate(prevProps) { - const { dispatch, currentRoute, startTime } = this.props; + const { dispatch, currentRoute } = this.props; const prevRoute = prevProps.currentRoute?.fullname || null; const route = currentRoute?.fullname || null; @@ -55,10 +57,6 @@ class DriveMap extends Component { } } - if (prevProps.startTime && prevProps.startTime !== startTime) { - this.shouldFlyTo = true; - } - if (currentRoute && prevProps.currentRoute && currentRoute.driveCoords && prevProps.currentRoute.driveCoords !== currentRoute.driveCoords) { this.shouldFlyTo = false; @@ -73,6 +71,10 @@ class DriveMap extends Component { componentWillUnmount() { this.mounted = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } } onInteraction(ev) { @@ -97,7 +99,16 @@ class DriveMap extends Component { const markerSource = this.map && this.map.getMap().getSource('seekPoint'); if (markerSource) { if (this.props.currentRoute && this.props.currentRoute.driveCoords) { - const pos = this.posAtOffset(currentOffset()); + let offset; + if (this.props.videoStatus === VideoStatus.FAILED || (this.props.hasAudio && isIos())) { + offset = this.props.offset; + } else { + offset = getVideoPlayerCurrentTime(this.props.currentRoute); + if (offset === null) { + offset = this.props.offset; + } + } + const pos = this.posAtOffset(offset); if (pos && pos.some((coordinate, index) => coordinate != this.lastMapPos[index])) { this.lastMapPos = pos; markerSource.setData({ @@ -116,7 +127,7 @@ class DriveMap extends Component { } } - requestAnimationFrame(this.updateMarkerPos); + this.rafId = requestAnimationFrame(this.updateMarkerPos); } moveViewportTo(pos) { @@ -207,7 +218,7 @@ class DriveMap extends Component { } initMap(mapComponent) { - if (!mapComponent) { + if (!mapComponent || typeof mapComponent.getMap !== 'function') { this.map = null; return; } @@ -308,7 +319,8 @@ class DriveMap extends Component { const stateToProps = (state) => ({ offset: state.offset, currentRoute: state.currentRoute, - startTime: state.startTime, + hasAudio: state.hasAudio, + videoStatus: state.videoStatus, }); export default connect(stateToProps)(DriveMap); diff --git a/src/components/DriveVideo/index.jsx b/src/components/DriveVideo/index.jsx index 921edac18..1a18dc9f7 100644 --- a/src/components/DriveVideo/index.jsx +++ b/src/components/DriveVideo/index.jsx @@ -8,40 +8,11 @@ import { api } from '../../api/backend'; 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'; - -// Leading-edge debounce: run immediately, then ignore calls until `wait` ms after the last one. -function debounceLeading(func, wait) { - let timeout = null; - let args; - let context; - let timestamp; - - function later() { - const last = Date.now() - timestamp; - if (last < wait && last >= 0) { - timeout = setTimeout(later, wait - last); - } else { - timeout = null; - } - } - - return function debounced(...nextArgs) { - context = this; - args = nextArgs; - timestamp = Date.now(); - const callNow = !timeout; - if (!timeout) { - timeout = setTimeout(later, wait); - } - if (callNow) { - return func.apply(context, args); - } - return undefined; - }; -} +import { + bufferVideo, setPlaybackSpeed, resetPlayback, play, pause, seek, setVideoStatus, VideoStatus, +} from '../../timeline/playback'; +import { setVideoPlayer, seekVideoPlayer, getVideoPlayerCurrentTime } from '../../timeline/videoPlayer'; +import { isIos } from '../../utils/browser.js'; const VideoOverlay = ({ loading, error }) => { let content; @@ -66,34 +37,18 @@ 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.onVideoPlay = this.onVideoPlay.bind(this); + this.onVideoPause = this.onVideoPause.bind(this); this.onHlsError = this.onHlsError.bind(this); this.onVideoError = this.onVideoError.bind(this); - this.onVideoResume = this.onVideoResume.bind(this); - this.syncVideo = debounceLeading(this.syncVideo.bind(this), 200); + this.onVideoPlaybackRateChange = this.onVideoPlaybackRateChange.bind(this); + this.onTimeUpdate = this.onTimeUpdate.bind(this); this.firstSeek = true; this.videoPlayer = React.createRef(); @@ -105,69 +60,76 @@ class DriveVideo extends Component { } componentDidMount() { - const { playSpeed } = this.props; + const { dispatch } = this.props; + dispatch(resetPlayback()); + setVideoPlayer(this.videoPlayer.current); if (this.videoPlayer.current) { - this.videoPlayer.current.playbackRate = playSpeed || 1; + const internal = this.videoPlayer.current.getInternalPlayer(); + if (internal) { + internal.playbackRate = 1; + } } this.updateVideoSource({}); - this.syncVideo(); - this.videoSyncIntv = setInterval(this.syncVideo, 500); } componentDidUpdate(prevProps) { + const videoPlayer = this.videoPlayer.current; + setVideoPlayer(videoPlayer); this.updateVideoSource(prevProps); - this.syncVideo(); } componentWillUnmount() { - if (this.videoSyncIntv) { - clearTimeout(this.videoSyncIntv); - this.videoSyncIntv = null; - } + setVideoPlayer(null); } onVideoBuffering() { - const { dispatch, currentRoute } = this.props; - const videoPlayer = this.videoPlayer.current; - if (!videoPlayer || !currentRoute || !videoPlayer.getDuration()) { - dispatch(bufferVideo(true)); - } + const { dispatch } = this.props; + dispatch(bufferVideo(true)); + } - if (this.firstSeek) { - this.firstSeek = false; - videoPlayer.seekTo(this.currentVideoTime(), 'seconds'); - } + onVideoBufferEnd() { + const { dispatch } = this.props; + const { videoError } = this.state; + if (videoError) this.setState({ videoError: null }); + dispatch(bufferVideo(false)); + } + + onVideoPlay() { + const { dispatch } = this.props; + dispatch(play()); + dispatch(bufferVideo(false)); + } - const { hasLoaded } = getVideoState(videoPlayer); - const { readyState } = videoPlayer.getInternalPlayer(); - if (!hasLoaded || readyState < 2) { - dispatch(bufferVideo(true)); - } + onVideoPause() { + const { dispatch } = this.props; + dispatch(pause()); } - /** - * @param {Error} e - */ onHlsError(e) { const { dispatch } = this.props; dispatch(bufferVideo(true)); + if (!e.fatal) { + return; + } if (e.type === 'mediaError' && (e.details === 'bufferStalledError' || e.details === 'bufferNudgeOnStall')) { // buffer but no error return; } - + dispatch(setVideoStatus(VideoStatus.FAILED)); if (e.type === 'networkError' && (e.response?.code === 404)) { this.setState({ videoError: 'This video segment has not uploaded yet or has been deleted.' }); } else { - this.setState({ videoError: 'Unable to load video' }); + const message = + e.reason || + e.response?.text || + e.error?.message || + e.details || + 'Unknown playback error'; + this.setState({ videoError: message }); } } - /** - * @param {Error} e - * @param {any} [data] - */ onVideoError(e, data) { if (!e) { console.warn('Unknown video error', { e, data }); @@ -184,6 +146,14 @@ class DriveVideo extends Component { return; } + if (e.name === 'NotAllowedError') { + // autoplay was blocked (e.g. iOS after backgrounding/returning to the app) + const { dispatch } = this.props; + dispatch(bufferVideo(false)); + dispatch(pause()) + return; + } + if (e.target?.src?.startsWith(window.location.origin) && e.target.src.endsWith('undefined')) { // TODO: figure out why the src isn't set properly // Sometimes an error will be thrown because we try to play @@ -194,6 +164,7 @@ class DriveVideo extends Component { const { dispatch } = this.props; dispatch(bufferVideo(true)); + dispatch(setVideoStatus(VideoStatus.FAILED)); if (e.type === 'networkError') { console.error('Network error', { e, data }); @@ -203,88 +174,56 @@ class DriveVideo extends Component { const videoError = e.response?.code === 404 ? 'This video segment has not uploaded yet or has been deleted.' - : (e.response?.text || 'Unable to load video'); + : (e.response?.text || e.message || 'Unable to load video'); this.setState({ videoError }); } - onVideoResume() { - const { videoError } = this.state; - if (videoError) this.setState({ videoError: null }); + onVideoPlaybackRateChange(rate) { + const { dispatch } = this.props; + dispatch(setPlaybackSpeed(rate)); + } + + onTimeUpdate(event) { + const { currentRoute, loop, dispatch } = this.props; + if (!currentRoute) { + return; + } + + const videoTime = getVideoPlayerCurrentTime(currentRoute); + if (videoTime === null) { + return; + } + if (videoTime >= loop.startTime + loop.duration) { + seekVideoPlayer(loop.startTime, currentRoute); + return; + } else if (videoTime < loop.startTime) { + seekVideoPlayer(loop.startTime, currentRoute); + return; + } + + dispatch(seek(videoTime)); } updateVideoSource(prevProps) { let { src } = this.state; - const { currentRoute } = this.props; + const { currentRoute, dispatch } = this.props; if (!currentRoute) { if (src !== '') { + dispatch(setVideoStatus(VideoStatus.LOADING)); this.setState({ src: '', videoError: null }); } return; } if (src === '' || !prevProps.currentRoute || prevProps.currentRoute?.fullname !== currentRoute.fullname) { + dispatch(setVideoStatus(VideoStatus.LOADING)); src = api.video.getQcameraStreamUrl(currentRoute.fullname, currentRoute.share_exp, currentRoute.share_sig); this.setState({ src, videoError: null }); - this.syncVideo(); + this.firstSeek = true; } } - syncVideo() { - const { dispatch, isBufferingVideo, isMuted } = 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); - } - } 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; - } - } - - currentVideoTime(offset = currentOffset()) { + currentVideoTime(offset = this.props.offset) { const { currentRoute } = this.props; if (!currentRoute) { return 0; @@ -300,16 +239,25 @@ class DriveVideo extends Component { } render() { - const { desiredPlaySpeed, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted } = this.props; + const { isPlaying, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted, dispatch } = this.props; const { src, videoError } = this.state; const onPlayerReady = (player) => { + if (this.firstSeek) { + const video = player.getInternalPlayer(); + const startSeconds = this.currentVideoTime( + this.props.loop?.startTime || 0 + ); + video.currentTime = startSeconds; + this.firstSeek = false; + } + dispatch(setVideoStatus(VideoStatus.READY)); + if (isIos()) { // ios does not support hls.js and on other browsers hls.js does not directly play the m3u8 so audioTracks are not visible const videoElement = player.getInternalPlayer(); - if (videoElement && videoElement.audioTracks && videoElement.audioTracks.length > 0) { - if (onAudioStatusChange) { - onAudioStatusChange(true); - } + const hasAudio = Boolean(videoElement && videoElement.audioTracks && videoElement.audioTracks.length > 0); + if (onAudioStatusChange) { + onAudioStatusChange(hasAudio); } } else { // on other platforms, inspect audio tracks before hls.js changes things const hlsPlayer = player.getInternalPlayer('hls'); @@ -324,29 +272,33 @@ class DriveVideo extends Component { }; return ( -