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 ( -
- - +
+
+ + +
); } @@ -360,6 +312,8 @@ const stateToProps = (state) => ({ isBufferingVideo: state.isBufferingVideo, routes: state.routes, currentRoute: state.currentRoute, + loop: state.loop, + isPlaying: state.isPlaying, }); export default connect(stateToProps)(DriveVideo); diff --git a/src/components/DriveView/Media.jsx b/src/components/DriveView/Media.jsx index f3620e807..64c029c4e 100644 --- a/src/components/DriveView/Media.jsx +++ b/src/components/DriveView/Media.jsx @@ -18,7 +18,7 @@ import { subscribeWindowSize } from '../../hooks/window'; import UploadQueue from '../Files/UploadQueue'; import ClipMenu from './ClipMenu'; import SwitchLoading from '../utils/SwitchLoading'; -import { bufferVideo } from '../../timeline/playback'; +import { bufferVideo, setHasAudio } from '../../timeline/playback'; import Colors from '../../colors'; import { InfoOutline } from '../../icons'; import { deviceIsOnline, deviceOnCellular, getSegmentNumber } from '../../utils'; @@ -226,7 +226,6 @@ class Media extends Component { dcamUploadInfo: null, routePreserved: null, isMuted: true, - hasAudio: false, clipsSupported: false, }; @@ -255,7 +254,7 @@ class Media extends Component { } handleAudioStatusChange(hasAudio) { - this.setState({ hasAudio }); + this.props.dispatch(setHasAudio(hasAudio)); } componentDidMount() { @@ -327,12 +326,12 @@ class Media extends Component { } async copySegmentName() { - const { currentRoute } = this.props; + const { currentRoute, offset } = this.props; if (!currentRoute || !navigator.clipboard) { return; } - await navigator.clipboard.writeText(`${currentRoute.fullname.replace('|', '/')}/${getSegmentNumber(currentRoute)}`); + await navigator.clipboard.writeText(`${currentRoute.fullname.replace('|', '/')}/${getSegmentNumber(currentRoute, offset)}`); this.setState({ moreInfoMenu: null }); } @@ -368,7 +367,7 @@ class Media extends Component { } async uploadFile(type) { - const { dongleId, currentRoute } = this.props; + const { dongleId, currentRoute, offset } = this.props; if (!currentRoute) { return; } @@ -378,7 +377,7 @@ class Media extends Component { })); const routeNoDongleId = currentRoute.fullname.split('|')[1]; - const fileName = `${dongleId}|${routeNoDongleId}--${getSegmentNumber(currentRoute)}/${type}`; + const fileName = `${dongleId}|${routeNoDongleId}--${getSegmentNumber(currentRoute, offset)}/${type}`; const uploading = {}; uploading[fileName] = { requested: true }; @@ -389,7 +388,7 @@ class Media extends Component { // request all possible file names for (const fn of FILE_NAMES[type]) { - const path = `${routeNoDongleId}--${getSegmentNumber(currentRoute)}/${fn}`; + const path = `${routeNoDongleId}--${getSegmentNumber(currentRoute, offset)}/${fn}`; paths.push(path); url_promises.push(fetchUploadUrls(dongleId, [path]).then(urls => urls[0])); } @@ -551,7 +550,8 @@ class Media extends Component { render() { const { classes } = this.props; - const { inView, windowWidth, isMuted, hasAudio } = this.state; + const { inView, windowWidth, isMuted } = this.state; + const { hasAudio } = this.props; if (this.props.menusOnly) { // for test return this.renderMenus(true); @@ -559,25 +559,23 @@ class Media extends Component { const showMapAlways = windowWidth >= 1536; const mediaContainerStyle = showMapAlways ? { width: '60%' } : { width: '100%' }; - const mapContainerStyle = showMapAlways - ? { width: '40%', marginBottom: 62, marginTop: 46, paddingLeft: 24 } - : { width: '100%' }; return (
{this.renderMediaOptions(showMapAlways)} - {inView === MediaType.VIDEO && ( +
+ {/* always mounted -> keeps playing + driving the clock, even under the map */} - )} - {(inView === MediaType.MAP && !showMapAlways) && ( -
- -
- )} + {!showMapAlways && ( +
+ +
+ )} +
{(inView === MediaType.VIDEO && showMapAlways) && ( -
+
)} @@ -655,7 +653,7 @@ class Media extends Component { } renderMenus(alwaysOpen = false) { - const { currentRoute, device, classes, files, profile } = this.props; + const { currentRoute, device, classes, files, offset, profile } = this.props; const { downloadMenu, clipMenu, moreInfoMenu, uploadModal, windowWidth, dcamUploadInfo, routePreserved } = this.state; if (!device) { @@ -665,7 +663,7 @@ class Media extends Component { let fcam = {}; let ecam = {}; let dcam = {}; let rlog = {}; if (files && currentRoute) { - const seg = `${currentRoute.fullname}--${getSegmentNumber(currentRoute)}`; + const seg = `${currentRoute.fullname}--${getSegmentNumber(currentRoute, offset)}`; fcam = files[`${seg}/cameras`] || {}; ecam = files[`${seg}/ecameras`] || {}; dcam = files[`${seg}/dcameras`] || {}; @@ -806,7 +804,7 @@ class Media extends Component { onClick={ this.copySegmentName } style={{ fontSize: windowWidth > 400 ? '0.8rem' : '0.7rem' }} > -
{ currentRoute ? `${currentRoute.fullname.replace('|', '/')}/${getSegmentNumber(currentRoute)}` : '---' }
+
{ currentRoute ? `${currentRoute.fullname.replace('|', '/')}/${getSegmentNumber(currentRoute, offset)}` : '---' }
{ typeof navigator.share !== 'undefined' @@ -943,11 +941,13 @@ const stateToProps = (state) => ({ device: state.device, routes: state.routes, currentRoute: state.currentRoute, + offset: state.offset, zoom: state.zoom, loop: state.loop, filter: state.filter, files: state.files, profile: state.profile, + hasAudio: state.hasAudio, isBufferingVideo: state.isBufferingVideo, }); diff --git a/src/components/TimeDisplay/index.jsx b/src/components/TimeDisplay/index.jsx index e084fa795..842fd645b 100644 --- a/src/components/TimeDisplay/index.jsx +++ b/src/components/TimeDisplay/index.jsx @@ -10,8 +10,8 @@ import VolumeOff from '@material-ui/icons/VolumeOff'; import { Tooltip } from '@material-ui/core'; import { DownArrow, Forward10, Pause, PlayArrow, Replay10, UpArrow } from '../../icons'; -import { currentOffset } from '../../timeline'; -import { seek, play, pause } from '../../timeline/playback'; +import { VideoStatus } from '../../timeline/playback'; +import { seekVideoPlayer, playVideo, pauseVideo, setVideoPlaybackRate, isVideoPaused } from '../../timeline/videoPlayer'; import { getSegmentNumber } from '../../utils'; import { isIos } from '../../utils/browser.js'; @@ -99,16 +99,6 @@ const styles = (theme) => ({ }); class TimeDisplay extends Component { - static getDerivedStateFromProps(props, state) { - if (props.desiredPlaySpeed !== 0 && props.desiredPlaySpeed !== state.desiredPlaySpeed) { - return { - ...state, - desiredPlaySpeed: props.desiredPlaySpeed, - }; - } - return state; - } - constructor(props) { super(props); @@ -122,7 +112,6 @@ class TimeDisplay extends Component { this.jumpForward = this.jumpForward.bind(this); this.state = { - desiredPlaySpeed: 1, displayTime: this.getDisplayTime(), }; } @@ -137,14 +126,13 @@ class TimeDisplay extends Component { } getDisplayTime() { - const offset = currentOffset(); - const { currentRoute } = this.props; + const { currentRoute, offset } = this.props; const now = new Date(offset + currentRoute.start_time_utc_millis); if (Number.isNaN(now.getTime())) { return '...'; } let dateString = dayjs(now).format('HH:mm:ss'); - const seg = getSegmentNumber(currentRoute); + const seg = getSegmentNumber(currentRoute, offset); if (seg !== null) { dateString = `${dateString} \u2013 ${seg}`; } @@ -153,11 +141,15 @@ class TimeDisplay extends Component { } jumpBack(amount) { - this.props.dispatch(seek(currentOffset() - amount)); + const { currentRoute } = this.props; + const offset = this.props.offset - amount; + seekVideoPlayer(offset, currentRoute); } jumpForward(amount) { - this.props.dispatch(seek(currentOffset() + amount)); + const { currentRoute } = this.props; + const offset = this.props.offset + amount; + seekVideoPlayer(offset, currentRoute); } updateTime() { @@ -174,18 +166,18 @@ class TimeDisplay extends Component { } decreaseSpeed() { - const { dispatch } = this.props; - const { desiredPlaySpeed } = this.state; + const { desiredPlaySpeed } = this.props; let curIndex = timerSteps.indexOf(desiredPlaySpeed); if (curIndex === -1) { curIndex = timerSteps.indexOf(1); } curIndex = Math.max(0, curIndex - 1); - dispatch(play(timerSteps[curIndex])); + const newSpeed = timerSteps[curIndex]; + setVideoPlaybackRate(newSpeed); } canDecreaseSpeed() { - const { desiredPlaySpeed } = this.state; + const { desiredPlaySpeed } = this.props; let curIndex = timerSteps.indexOf(desiredPlaySpeed); if (curIndex === -1) { curIndex = timerSteps.indexOf(1); @@ -194,18 +186,18 @@ class TimeDisplay extends Component { } increaseSpeed() { - const { dispatch } = this.props; - const { desiredPlaySpeed } = this.state; + const { desiredPlaySpeed } = this.props; let curIndex = timerSteps.indexOf(desiredPlaySpeed); if (curIndex === -1) { curIndex = timerSteps.indexOf(1); } curIndex = Math.min(timerSteps.length - 1, curIndex + 1); - dispatch(play(timerSteps[curIndex])); + const newSpeed = timerSteps[curIndex]; + setVideoPlaybackRate(newSpeed); } canIncreaseSpeed() { - const { desiredPlaySpeed } = this.state; + const { desiredPlaySpeed } = this.props; let curIndex = timerSteps.indexOf(desiredPlaySpeed); if (curIndex === -1) { curIndex = timerSteps.indexOf(1); @@ -214,27 +206,28 @@ class TimeDisplay extends Component { } togglePause() { - const { desiredPlaySpeed, dispatch } = this.props; - if (desiredPlaySpeed === 0) { - // eslint-disable-next-line react/destructuring-assignment - dispatch(play(this.state.desiredPlaySpeed)); + if (isVideoPaused()) { + playVideo(); } else { - dispatch(pause()); + pauseVideo(); } } render() { - const { classes, zoom, desiredPlaySpeed: videoPlaySpeed, isThin, onMuteToggle, isMuted, hasAudio } = this.props; - const { displayTime, desiredPlaySpeed } = this.state; - const isPaused = videoPlaySpeed === 0; + const { + classes, zoom, isThin, onMuteToggle, isMuted, hasAudio, desiredPlaySpeed, isPlaying, videoStatus, + } = this.props; + const { displayTime } = this.state; const isExpandedCls = zoom ? 'isExpanded' : ''; const isThinCls = isThin ? 'isThin' : ''; + const controlsDisabled = videoStatus === VideoStatus.FAILED; return (
this.jumpBack(10000) } + disabled={controlsDisabled} aria-label="Jump back 10 seconds" > @@ -244,6 +237,7 @@ class TimeDisplay extends Component { this.jumpForward(10000) } + disabled={controlsDisabled} aria-label="Jump forward 10 seconds" > @@ -262,7 +256,7 @@ class TimeDisplay extends Component { @@ -274,7 +268,7 @@ class TimeDisplay extends Component { @@ -287,7 +281,7 @@ class TimeDisplay extends Component { {isMuted @@ -300,9 +294,10 @@ class TimeDisplay extends Component {
- {isPaused + {!isPlaying ? () : ()} @@ -316,6 +311,9 @@ const stateToProps = (state) => ({ currentRoute: state.currentRoute, zoom: state.zoom, desiredPlaySpeed: state.desiredPlaySpeed, + isPlaying: state.isPlaying, + offset: state.offset, + videoStatus: state.videoStatus, }); export default connect(stateToProps)(withStyles(styles)(TimeDisplay)); diff --git a/src/components/Timeline/index.jsx b/src/components/Timeline/index.jsx index 0bfa8df63..d1b698e6f 100644 --- a/src/components/Timeline/index.jsx +++ b/src/components/Timeline/index.jsx @@ -10,9 +10,10 @@ import Thumbnails from './thumbnails'; import theme from '../../theme'; import { pushTimelineRange } from '../../actions'; import Colors from '../../colors'; -import { currentOffset } from '../../timeline'; -import { seek } from '../../timeline/playback'; +import { seek, VideoStatus } from '../../timeline/playback'; +import { getVideoPlayerCurrentTime, seekVideoPlayer } from '../../timeline/videoPlayer'; import { getSegmentNumber } from '../../utils'; +import { isIos } from '../../utils/browser.js'; const styles = () => ({ base: { @@ -153,6 +154,7 @@ class Timeline extends Component { this.handlePointerDown = this.handlePointerDown.bind(this); this.handlePointerUp = this.handlePointerUp.bind(this); this.handlePointerLeave = this.handlePointerLeave.bind(this); + this.seekToOffset = this.seekToOffset.bind(this); this.percentToOffset = this.percentToOffset.bind(this); this.segmentNum = this.segmentNum.bind(this); this.onRulerRef = this.onRulerRef.bind(this); @@ -164,6 +166,9 @@ class Timeline extends Component { this.hoverBead = React.createRef(); this.thumbnailsRef = React.createRef(); + this.currentOffset = null; + this.lastOffset = null; + const { zoomOverride, zoom } = this.props; this.state = { dragging: null, @@ -178,7 +183,7 @@ class Timeline extends Component { componentDidMount() { this.mounted = true; - requestAnimationFrame(this.getOffset); + this.rafId = requestAnimationFrame(this.getOffset); this.componentDidUpdate({}); if (typeof ResizeObserver !== 'undefined' && this.thumbnailsRef.current) { @@ -203,17 +208,31 @@ class Timeline extends Component { componentWillUnmount() { this.mounted = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } } + seekToOffset(offset) { + const { dispatch, route, videoStatus } = this.props; + if (videoStatus === VideoStatus.FAILED) { + dispatch(seek(offset)); + return; + } + seekVideoPlayer(offset, route); + } + handleClick(ev) { const { dragging } = this.state; if (!dragging || Math.abs(dragging[1] - dragging[0]) <= 3) { const percent = percentFromPointerEvent(ev); - this.props.dispatch(seek(this.percentToOffset(percent))); + const offset = this.percentToOffset(percent); + this.seekToOffset(offset); } } @@ -245,7 +264,7 @@ class Timeline extends Component { } handlePointerUp(ev) { - const { route } = this.props; + const { offset, route } = this.props; // prevent preventDefault for back(3) and forward(4) mouse buttons if (ev.button !== 3 && ev.button !== 4) { @@ -267,9 +286,8 @@ class Timeline extends Component { const endOffset = Math.round(this.percentToOffset(endPercent)); if (Math.abs(dragging[1] - dragging[0]) > 3) { - const offset = currentOffset(); if (offset < startOffset || offset > endOffset) { - this.props.dispatch(seek(startOffset)); + this.seekToOffset(startOffset); } const { dispatch } = this.props; const startTime = startOffset; @@ -296,17 +314,23 @@ class Timeline extends Component { if (!this.mounted) { return; } - requestAnimationFrame(this.getOffset); - let offset = currentOffset(); - if (this.seekIndex) { - offset = this.seekIndex; + let offset; + if (this.props.videoStatus === VideoStatus.FAILED || (this.props.hasAudio && isIos())) { + // video with audio doesn't report currentTime properly so we must use onTimeUpdate reported time + offset = this.props.offset; + } else { + offset = getVideoPlayerCurrentTime(this.props.route); + if (offset === null) { + offset = this.props.offset; + } } - offset = Math.floor(offset); - const percent = this.offsetToPercent(offset); + let percent = this.offsetToPercent(offset); + if (percent >= 1) percent = 1; if (this.rulerRemaining.current && this.rulerRemaining.current.parentElement) { this.rulerRemaining.current.style.left = `${Math.floor(10000 * percent) / 100}%`; this.rulerRemaining.current.style.width = `${100 - Math.floor(10000 * percent) / 100}%`; } + this.rafId = requestAnimationFrame(this.getOffset); } percentToOffset(perc) { @@ -458,8 +482,15 @@ class Timeline extends Component { } const stateToProps = (state) => ({ + offset: state.offset, zoom: state.zoom, loop: state.loop, + desiredPlaySpeed: state.desiredPlaySpeed, + isBufferingVideo: state.isBufferingVideo, + currentRoute: state.currentRoute, + isPlaying: state.isPlaying, + hasAudio: state.hasAudio, + videoStatus: state.videoStatus, }); export default connect(stateToProps)(withStyles(styles)(Timeline)); diff --git a/src/components/explorer.jsx b/src/components/explorer.jsx index 66795b03e..3f2d50978 100644 --- a/src/components/explorer.jsx +++ b/src/components/explorer.jsx @@ -17,7 +17,6 @@ import BodyTeleop from './BodyTeleop'; import { analyticsEvent, selectDevice, updateDevices, checkLastRoutesData, streamNav } from '../actions'; import init from '../actions/startup'; import Colors from '../colors'; -import { play, pause } from '../timeline/playback'; import { verifyPairToken, pairErrorToMessage } from '../utils'; import { subscribeWindowSize } from '../hooks/window'; @@ -147,19 +146,12 @@ class ExplorerApp extends Component { } componentDidUpdate(prevProps, prevState) { - const { pathname, zoom, dongleId, limit } = this.props; + const { pathname, dongleId, limit } = this.props; if (prevProps.pathname !== pathname) { this.setState({ drawerIsOpen: false }); } - if (!prevProps.zoom && zoom) { - this.props.dispatch(play()); - } - if (prevProps.zoom && !zoom) { - this.props.dispatch(pause()); - } - // this is necessary when user goes to explorer for the first time, dongleId is not populated in state yet // so init() will not successfully fetch routes data // when checkLastRoutesData is called within init(), it would set limit so we don't need to check again diff --git a/src/initialState.js b/src/initialState.js index 291531323..864c99e47 100644 --- a/src/initialState.js +++ b/src/initialState.js @@ -1,4 +1,5 @@ import { getDongleID, getSegmentRange, getPrimeNav, getStreamNav } from './url'; +import { VideoStatus } from './timeline/playback'; export function getDefaultFilter() { const d = new Date(); @@ -16,8 +17,9 @@ export function createInitialState(pathname = window.location.pathname) { desiredPlaySpeed: 1, // speed set by user isBufferingVideo: true, // if we're currently buffering for more data + isPlaying: true, // if the video is currently playing + videoStatus: VideoStatus.LOADING, offset: null, // in miliseconds, relative to state.zoom.start - startTime: Date.now(), // millisecond timestamp in which play began routes: null, routesMeta: { diff --git a/src/timeline/index.js b/src/timeline/index.js deleted file mode 100644 index 22724754f..000000000 --- a/src/timeline/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import store from '../store'; - -/** - * Get current playback offset - * - * @param {object} state - * @returns {number} - */ -export function currentOffset(state = null) { - if (!state) { - state = store.getState(); - } - - /** @type {number} */ - let offset; - if (state.offset === null && state.loop?.startTime) { - offset = state.loop.startTime; - } else { - const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed; - offset = state.offset + ((Date.now() - state.startTime) * playSpeed); - } - - if (offset !== null && state.loop?.startTime) { - // respect the loop - const loopOffset = state.loop.startTime; - if (offset < loopOffset) { - offset = loopOffset; - } else if (offset > loopOffset + state.loop.duration) { - offset = ((offset - loopOffset) % state.loop.duration) + loopOffset; - } - } - return offset; -} \ No newline at end of file diff --git a/src/timeline/playback.js b/src/timeline/playback.js index acb381f5c..f97a2168d 100644 --- a/src/timeline/playback.js +++ b/src/timeline/playback.js @@ -1,47 +1,37 @@ -// basic helper functions for controlling playback -// we shouldn't want to edit the raw state most of the time, helper functions are better import * as Types from '../actions/types'; -import { currentOffset } from '.'; + +export const VideoStatus = { + LOADING: 'loading', + READY: 'ready', + FAILED: 'failed', +}; export function reducer(_state, action) { let state = { ..._state }; - let loopOffset = null; - if (state.loop && state.loop.startTime !== null) { - loopOffset = state.loop.startTime; - } switch (action.type) { case Types.ACTION_SEEK: state = { ...state, offset: action.offset, - startTime: Date.now(), }; - - if (loopOffset !== null) { - if (state.offset < loopOffset) { - state.offset = loopOffset; - } else if (state.offset > (loopOffset + state.loop.duration)) { - state.offset = loopOffset + state.loop.duration; - } - } break; - case Types.ACTION_PAUSE: + case Types.ACTION_PLAYBACK_SPEED: state = { ...state, - offset: currentOffset(state), - startTime: Date.now(), - desiredPlaySpeed: 0, + desiredPlaySpeed: action.speed, }; break; case Types.ACTION_PLAY: - if (action.speed !== state.desiredPlaySpeed) { - state = { - ...state, - offset: currentOffset(state), - desiredPlaySpeed: action.speed, - startTime: Date.now(), - }; - } + state = { + ...state, + isPlaying: true, + }; + break; + case Types.ACTION_PAUSE: + state = { + ...state, + isPlaying: false, + }; break; case Types.ACTION_LOOP: if (action.start !== null && action.start !== undefined && action.end !== null && action.end !== undefined) { @@ -57,51 +47,33 @@ export function reducer(_state, action) { state = { ...state, isBufferingVideo: action.buffering, - offset: currentOffset(state), - startTime: Date.now(), }; break; case Types.ACTION_RESET: state = { ...state, - desiredPlaySpeed: 1, - isBufferingVideo: true, offset: 0, - startTime: Date.now(), + desiredPlaySpeed: 1, + hasAudio: false, + videoStatus: VideoStatus.LOADING, }; break; - default: + case Types.ACTION_HAS_AUDIO: + state = { + ...state, + hasAudio: action.hasAudio, + }; break; - } - - if (state.currentRoute && state.currentRoute.videoStartOffset && state.loop && state.zoom - && state.loop.startTime === state.zoom.start && state.zoom.start === 0) { - const loopRouteOffset = state.loop.startTime - state.zoom.start; - if (state.currentRoute.videoStartOffset > loopRouteOffset) { - state.loop = { - startTime: state.zoom.start + state.currentRoute.videoStartOffset, - duration: state.loop.duration - (state.currentRoute.videoStartOffset - loopRouteOffset), + case Types.ACTION_VIDEO_STATUS: + state = { + ...state, + videoStatus: action.status, }; - } - } - - // normalize over loop - if (state.offset !== null && state.loop?.startTime) { - const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed; - const offset = state.offset + (Date.now() - state.startTime) * playSpeed; - loopOffset = state.loop.startTime; - // has loop, trap offset within the loop - if (offset < loopOffset) { - state.startTime = Date.now(); - state.offset = loopOffset; - } else if (offset > loopOffset + state.loop.duration) { - state.offset = ((offset - loopOffset) % state.loop.duration) + loopOffset; - state.startTime = Date.now(); - } + break; + default: + break; } - state.isBufferingVideo = Boolean(state.isBufferingVideo); - return state; } @@ -113,19 +85,20 @@ export function seek(offset) { }; } -// pause the playback -export function pause() { +// change playback speed without changing play/pause state +export function setPlaybackSpeed(speed) { return { - type: Types.ACTION_PAUSE, + type: Types.ACTION_PLAYBACK_SPEED, + speed, }; } -// resume / change play speed -export function play(speed = 1) { - return { - type: Types.ACTION_PLAY, - speed, - }; +export function play() { + return { type: Types.ACTION_PLAY }; +} + +export function pause() { + return { type: Types.ACTION_PAUSE }; } export function selectLoop(start, end) { @@ -149,3 +122,17 @@ export function resetPlayback() { type: Types.ACTION_RESET, }; } + +export function setHasAudio(hasAudio) { + return { + type: Types.ACTION_HAS_AUDIO, + hasAudio, + }; +} + +export function setVideoStatus(status) { + return { + type: Types.ACTION_VIDEO_STATUS, + status, + }; +} diff --git a/src/timeline/playback.test.js b/src/timeline/playback.test.js index e88e561cd..7ee14b969 100644 --- a/src/timeline/playback.test.js +++ b/src/timeline/playback.test.js @@ -1,115 +1,86 @@ -import { asyncSleep } from '../utils'; -import { currentOffset } from '.'; -import { bufferVideo, pause, play, reducer, seek, selectLoop } from './playback'; +import { + bufferVideo, + reducer, + resetPlayback, + seek, + selectLoop, + setPlaybackSpeed, +} from './playback'; const makeDefaultStruct = function makeDefaultStruct() { return { desiredPlaySpeed: 1, // 0 = stopped, 1 = playing, 2 = 2x speed + isBufferingVideo: true, + isPlaying: true, offset: 0, // in miliseconds from the start - startTime: Date.now(), // millisecond timestamp in which play began - - isBuffering: true, + hasAudio: false, }; }; -// make Date.now super stable for tests -let mostRecentNow = Date.now(); -const oldNow = Date.now; -Date.now = function now() { - return mostRecentNow; -}; -function newNow() { - mostRecentNow = oldNow(); - return mostRecentNow; -} - describe('playback', () => { - it('has playback controls', async () => { - newNow(); + it('has playback controls', () => { let state = makeDefaultStruct(); - // should do nothing - state = reducer(state, pause()); + // stop playback + state = reducer(state, setPlaybackSpeed(0)); expect(state.desiredPlaySpeed).toEqual(0); - // start playing, should set start time and such - let playTime = newNow(); - state = reducer(state, play()); - // this is a (usually 1ms) race condition - expect(state.startTime).toEqual(playTime); + // start playing + state = reducer(state, setPlaybackSpeed(1)); expect(state.desiredPlaySpeed).toEqual(1); - await asyncSleep(100 + Math.random() * 200); - // should update offset - let ellapsed = newNow() - playTime; - state = reducer(state, pause()); - - expect(state.offset).toEqual(ellapsed); - - // start playing, should set start time and such - playTime = newNow(); - state = reducer(state, play(0.5)); - // this is a (usually 1ms) race condition - expect(state.startTime).toEqual(playTime); - expect(state.desiredPlaySpeed).toEqual(0.5); + // seek updates offset + state = reducer(state, seek(123)); + expect(state.offset).toEqual(123); - await asyncSleep(100 + Math.random() * 200); - // should update offset, playback speed 1/2 - ellapsed += (newNow() - playTime) / 2; - expect(currentOffset(state)).toEqual(ellapsed); - state = reducer(state, pause()); + // reset clears offset + state = reducer(state, resetPlayback()); + expect(state.offset).toEqual(0); + }); - expect(state.offset).toEqual(ellapsed); + it('should set loop start time and duration', () => { + let state = makeDefaultStruct(); - // seek! - newNow(); - state = reducer(state, seek(123)); - expect(state.offset).toEqual(123); - expect(state.startTime).toEqual(Date.now()); - expect(currentOffset(state)).toEqual(123); + state = reducer(state, selectLoop( + 1000, + 2000, + )); + expect(state.loop.startTime).toEqual(1000); + expect(state.loop.duration).toEqual(1000); }); - it('should clamp loop when seeked after loop end time', () => { - newNow(); + it('should not clamp offset when seeked after loop end time', () => { let state = makeDefaultStruct(); - // set up loop - state = reducer(state, play()); state = reducer(state, selectLoop( 1000, 2000, )); expect(state.loop.startTime).toEqual(1000); - // seek past loop end boundary a state = reducer(state, seek(3000)); expect(state.loop.startTime).toEqual(1000); - expect(state.offset).toEqual(2000); + expect(state.offset).toEqual(3000); }); - it('should clamp loop when seeked before loop start time', () => { - newNow(); + it('should not clamp offset when seeked before loop start time', () => { let state = makeDefaultStruct(); - // set up loop - state = reducer(state, play()); state = reducer(state, selectLoop( 1000, 2000, )); expect(state.loop.startTime).toEqual(1000); - // seek past loop end boundary a state = reducer(state, seek(0)); expect(state.loop.startTime).toEqual(1000); - expect(state.offset).toEqual(1000); + expect(state.offset).toEqual(0); }); - it('should buffer video and data', async () => { - newNow(); + it('should buffer video and data', () => { let state = makeDefaultStruct(); - state = reducer(state, play()); + state = reducer(state, setPlaybackSpeed(1)); expect(state.desiredPlaySpeed).toEqual(1); // claim the video is buffering @@ -117,17 +88,13 @@ describe('playback', () => { expect(state.desiredPlaySpeed).toEqual(1); expect(state.isBufferingVideo).toEqual(true); - state = reducer(state, play(0.5)); + state = reducer(state, setPlaybackSpeed(0.5)); expect(state.desiredPlaySpeed).toEqual(0.5); expect(state.isBufferingVideo).toEqual(true); - expect(state.desiredPlaySpeed).toEqual(0.5); - - state = reducer(state, play(2)); + state = reducer(state, setPlaybackSpeed(2)); state = reducer(state, bufferVideo(false)); expect(state.desiredPlaySpeed).toEqual(2); expect(state.isBufferingVideo).toEqual(false); - - expect(state.desiredPlaySpeed).toEqual(2); }); }); diff --git a/src/timeline/segments.test.js b/src/timeline/segments.test.js index cfd2d9b2b..82f90a734 100644 --- a/src/timeline/segments.test.js +++ b/src/timeline/segments.test.js @@ -28,7 +28,7 @@ const routes = [{ describe('segments', () => { it('finds current segment', async () => { const [route] = routes; - expect(getSegmentNumber(route)).toBe(0); + expect(getSegmentNumber(route, 0)).toBe(0); }); it('can check if it has segment metadata', () => { diff --git a/src/timeline/videoPlayer.js b/src/timeline/videoPlayer.js new file mode 100644 index 000000000..15da52214 --- /dev/null +++ b/src/timeline/videoPlayer.js @@ -0,0 +1,75 @@ +let videoPlayer = null; + +export function setVideoPlayer(player) { + videoPlayer = player; +} + +function getInternal() { + if (!videoPlayer || !videoPlayer.getInternalPlayer) { + return null; + } + return videoPlayer.getInternalPlayer(); +} + +export function seekVideoPlayer(offset, route) { + if (!videoPlayer || !videoPlayer.getInternalPlayer || !videoPlayer.getDuration()) { + return false; + } + const internal = getInternal(); + + let videoTime = offset; + if (route && route.videoStartOffset) { + videoTime -= route.videoStartOffset; + } + videoTime = Math.max(0, videoTime / 1000); + + internal.currentTime = videoTime; + return true; +} + +export function getVideoPlayerCurrentTime(route) { + const internal = getInternal(); + if (!internal) { + return null; + } + const videoStartOffset = (route && route.videoStartOffset) || 0; + return internal.currentTime * 1000 + videoStartOffset; +} + +export function isVideoPaused() { + const internal = getInternal(); + if (!internal) { + return true; + } + return internal.paused; +} + +export function playVideo() { + const internal = getInternal(); + if (!internal) { + return false; + } + const promise = internal.play(); + if (promise && typeof promise.catch === 'function') { + promise.catch(() => {}); + } + return true; +} + +export function pauseVideo() { + const internal = getInternal(); + if (!internal) { + return false; + } + internal.pause(); + return true; +} + +export function setVideoPlaybackRate(rate) { + const internal = getInternal(); + if (!internal) { + return false; + } + internal.playbackRate = rate; + return true; +} diff --git a/src/utils/browser.js b/src/utils/browser.js index cd4030e57..35dbbf79b 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'); -} diff --git a/src/utils/index.js b/src/utils/index.js index 37311df4f..2a2f95ded 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,8 +3,6 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import decodeJwt, { InvalidTokenError } from 'jwt-decode'; -import { currentOffset } from '../timeline'; - dayjs.extend(relativeTime); export const emptyDevice = { @@ -176,8 +174,8 @@ export function getSegmentNumber(route, offset) { if (!route) { return null; } - if (offset === undefined) { - offset = currentOffset(); + if (offset === undefined || offset === null) { + return null; } return Math.floor(offset / (60*1000));