diff --git a/src/actions/index.js b/src/actions/index.js index 0d67f00fc..892f7e672 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -5,7 +5,7 @@ import { athena as Athena, billing as Billing, devices as Devices, drives as Dri import MyCommaAuth from '@commaai/my-comma-auth'; 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'; @@ -163,7 +163,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 0bbe668f1..2637c5261 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -7,7 +7,7 @@ export const ACTION_STARTUP_DATA = 'ACTION_STARTUP_DATA'; // global state management export const ACTION_SELECT_DEVICE = 'ACTION_SELECT_DEVICE'; export const ACTION_SELECT_TIME_FILTER = 'ACTION_SELECT_TIME_FILTER'; -export const ACTION_UPDATE_ROUTE_LIMIT = 'ACTION_UPDATE_ROUTE_LIMIT' +export const ACTION_UPDATE_ROUTE_LIMIT = 'ACTION_UPDATE_ROUTE_LIMIT'; export const ACTION_UPDATE_DEVICES = 'ACTION_UPDATE_DEVICES'; export const ACTION_UPDATE_DEVICE = 'ACTION_UPDATE_DEVICE'; export const ACTION_UPDATE_ROUTE = 'ACTION_UPDATE_ROUTE'; @@ -26,9 +26,11 @@ 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'; // segments export const ACTION_UPDATE_SEGMENT_RANGE = 'update_segment_range'; diff --git a/src/components/DriveMap/index.jsx b/src/components/DriveMap/index.jsx index 7abcc0acc..3507ef661 100644 --- a/src/components/DriveMap/index.jsx +++ b/src/components/DriveMap/index.jsx @@ -6,7 +6,6 @@ import raf from 'raf'; import ReactMapGL, { LinearInterpolator } from 'react-map-gl'; import { fetchDriveCoords } from '../../actions/cached'; -import { currentOffset } from '../../timeline'; import { DEFAULT_LOCATION, MAPBOX_STYLE, MAPBOX_TOKEN } from '../../utils/geocode'; const INTERACTION_TIMEOUT = 5000; @@ -46,7 +45,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; @@ -57,10 +56,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; @@ -99,7 +94,7 @@ 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()); + const pos = this.posAtOffset(this.props.offset); if (pos && pos.some((coordinate, index) => coordinate != this.lastMapPos[index])) { this.lastMapPos = pos; markerSource.setData({ @@ -310,7 +305,6 @@ class DriveMap extends Component { const stateToProps = Obstruction({ offset: 'offset', currentRoute: 'currentRoute', - startTime: 'startTime', }); export default connect(stateToProps)(DriveMap); diff --git a/src/components/DriveVideo/index.jsx b/src/components/DriveVideo/index.jsx index d4eb61e27..5e3cd0347 100644 --- a/src/components/DriveVideo/index.jsx +++ b/src/components/DriveVideo/index.jsx @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { CircularProgress, Typography } from '@material-ui/core'; -import debounce from 'debounce'; import Obstruction from 'obstruction'; import ReactPlayer from 'react-player/file'; @@ -10,9 +9,9 @@ import { video as Video } from '../../api'; 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 { bufferVideo, setPlaybackSpeed, resetPlayback, play, pause, seek } from '../../timeline/playback'; +import { setVideoPlayer, seekVideoPlayer, getVideoPlayerCurrentTime } from '../../timeline/videoPlayer'; +import { isIos } from '../../utils/browser.js'; const VideoOverlay = ({ loading, error }) => { let content; @@ -37,34 +36,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 = debounce(this.syncVideo.bind(this), 200, true); + this.onVideoPlaybackRateChange = this.onVideoPlaybackRateChange.bind(this); + this.onTimeUpdate = this.onTimeUpdate.bind(this); this.firstSeek = true; this.videoPlayer = React.createRef(); @@ -76,69 +59,72 @@ 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.type === 'mediaError' && (e.details === 'bufferStalledError' || e.details === 'bufferNudgeOnStall')) { + if (!e.fatal) return; + else if (e.type === 'mediaError' && (e.details === 'bufferStalledError' || e.details === 'bufferNudgeOnStall')) { // buffer but no error return; - } - - if (e.type === 'networkError' && (e.response?.code === 404)) { + } else 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 }); @@ -155,6 +141,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 @@ -174,13 +168,31 @@ 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 >= loop.startTime + loop.duration) { + seekVideoPlayer(loop.startTime, currentRoute); + return; + } else if (videoTime < loop.startTime) { + seekVideoPlayer(loop.startTime, currentRoute); + return; + } + + dispatch(seek(videoTime)); } updateVideoSource(prevProps) { @@ -196,66 +208,11 @@ class DriveVideo extends Component { if (src === '' || !prevProps.currentRoute || prevProps.currentRoute?.fullname !== currentRoute.fullname) { src = Video.getQcameraStreamUrl(currentRoute.fullname, currentRoute.share_exp, currentRoute.share_sig); this.setState({ src, videoError: null }); - this.syncVideo(); - } - } - - 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; + this.firstSeek = true; } } - currentVideoTime(offset = currentOffset()) { + currentVideoTime(offset = this.props.offset) { const { currentRoute } = this.props; if (!currentRoute) { return 0; @@ -271,16 +228,24 @@ class DriveVideo extends Component { } render() { - const { desiredPlaySpeed, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted } = this.props; + const { isPlaying, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted } = 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; + } + 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'); @@ -295,29 +260,33 @@ class DriveVideo extends Component { }; return ( -