diff --git a/src/actions/index.js b/src/actions/index.js
index c523161d..a24d5ec5 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -148,7 +148,7 @@ export function urlForState(dongleId, log_id, start, end, prime) {
if (log_id) {
path.push(log_id);
- if (start && end && start > 0) {
+ if (Number.isFinite(start) && Number.isFinite(end) && start !== end) {
path.push(start);
path.push(end);
}
@@ -160,14 +160,27 @@ 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) {
+ // When start/end aren't provided, loop the full route.
+ let loopStart = start;
+ let loopEnd = end;
+ if ((loopStart == null || loopEnd == null) && log_id) {
+ const route = state.routes?.find((r) => r.log_id === log_id);
+ if (route) {
+ loopStart = 0;
+ loopEnd = route.duration;
+ }
+ }
+
+ if (loopStart != null && loopEnd != null
+ && (!state.loop || state.loop.startTime !== loopStart || state.loop.duration !== loopEnd - loopStart)) {
dispatch(resetPlayback());
- dispatch(selectLoop(start, end));
+ dispatch(selectLoop(loopStart, loopEnd));
}
if (allowPathChange) {
- const desiredPath = urlForState(state.dongleId, log_id, Math.floor(start/1000), Math.floor(end/1000), false);
+ const startSec = Number.isFinite(start) ? Math.floor(start / 1000) : null;
+ const endSec = Number.isFinite(end) ? Math.floor(end / 1000) : null;
+ const desiredPath = urlForState(state.dongleId, log_id, startSec, endSec, false);
if (window.location.pathname !== desiredPath) {
dispatch(push(desiredPath));
}
diff --git a/src/actions/index.test.js b/src/actions/index.test.js
index 133accf5..8962fcfb 100644
--- a/src/actions/index.test.js
+++ b/src/actions/index.test.js
@@ -23,6 +23,6 @@ describe('timeline actions', () => {
zoom: {},
}));
actionThunk(dispatch, getState);
- expect(push).toBeCalledWith('/statedongle/log_id');
+ expect(push).toBeCalledWith('/statedongle/log_id/0/1');
});
});
\ No newline at end of file
diff --git a/src/components/Dashboard/DriveListItem.jsx b/src/components/Dashboard/DriveListItem.jsx
index 00f42cca..aa4204f7 100644
--- a/src/components/Dashboard/DriveListItem.jsx
+++ b/src/components/Dashboard/DriveListItem.jsx
@@ -84,7 +84,7 @@ const DriveListItem = (props) => {
}, [drive, dispatch, isVisible, el]);
const onClick = filterRegularClick(
- () => dispatch(pushTimelineRange(drive.log_id, 0, drive.duration, true)),
+ () => dispatch(pushTimelineRange(drive.log_id, null, null, true)),
);
const small = windowWidth < 580;
diff --git a/src/components/DriveVideo/index.jsx b/src/components/DriveVideo/index.jsx
index b35f95b7..3dd2f5f6 100644
--- a/src/components/DriveVideo/index.jsx
+++ b/src/components/DriveVideo/index.jsx
@@ -1,5 +1,5 @@
/* eslint-disable camelcase */
-import React, { Component } from 'react';
+import React, { Component, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { CircularProgress, Typography } from '@material-ui/core';
import debounce from 'debounce';
@@ -11,10 +11,26 @@ import { video as Video } from '@commaai/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 { seek, bufferVideo, selectLoop } from '../../timeline/playback';
+import { isIos } from '../../utils/browser.js';
+
+// Delay-show the spinner for a short window so brief stalls (e.g. a fast seek that
+// briefly fires the video element's `waiting` event before data lands) don't flash
+// a spinner. Hide is immediate. Error/missing overlays are NOT delayed.
+const SPINNER_DELAY_MS = 250;
+
+const VideoOverlay = ({ loading, error, missing, onSkip }) => {
+ const [showSpinner, setShowSpinner] = useState(false);
+
+ useEffect(() => {
+ if (!loading) {
+ setShowSpinner(false);
+ return undefined;
+ }
+ const id = setTimeout(() => setShowSpinner(true), SPINNER_DELAY_MS);
+ return () => clearTimeout(id);
+ }, [loading]);
-const VideoOverlay = ({ loading, error }) => {
let content;
if (error) {
content = (
@@ -23,47 +39,46 @@ const VideoOverlay = ({ loading, error }) => {
{error}
>
);
- } else if (loading) {
+ } else if (missing) {
+ content = (
+ <>
+
+ This video segment has not uploaded yet or has been deleted.
+ {onSkip && (
+
+ )}
+ >
+ );
+ } else if (showSpinner) {
content = ;
} else {
return null;
}
return (
-
);
};
-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);
+ this.onVideoDuration = this.onVideoDuration.bind(this);
this.syncVideo = debounce(this.syncVideo.bind(this), 200, true);
this.firstSeek = true;
@@ -72,6 +87,12 @@ class DriveVideo extends Component {
this.state = {
src: null,
videoError: null,
+ // Set of segment indices whose qcamera.ts returned 404. Tracked locally
+ // because it's pure UI / playback concern.
+ missingSegments: new Set(),
+ // Index of the segment we're currently inside, if it's missing.
+ // Mirrored into state so the overlay re-renders as the timeline crosses gap boundaries.
+ currentMissingSegment: null,
};
}
@@ -91,6 +112,7 @@ class DriveVideo extends Component {
}
componentWillUnmount() {
+ this.unmounted = true;
if (this.videoSyncIntv) {
clearTimeout(this.videoSyncIntv);
this.videoSyncIntv = null;
@@ -98,36 +120,63 @@ 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');
}
+ this.bufferStartedAt = Date.now();
+ console.debug('[DriveVideo] buffer start');
+ 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 });
+ const dur = this.bufferStartedAt ? Date.now() - this.bufferStartedAt : null;
+ this.bufferStartedAt = null;
+ console.debug('[DriveVideo] buffer end', dur != null ? `(${dur}ms)` : '');
+ if (isBufferingVideo) dispatch(bufferVideo(false));
}
/**
- * @param {Error} e
+ * @param {object} e HLS.js error payload (type/details/response/frag/fatal)
+ * @param {object} [hls] HLS.js instance, when available
*/
- onHlsError(e) {
+ onHlsError(e, hls) {
const { dispatch } = this.props;
- dispatch(bufferVideo(true));
if (e.type === 'mediaError' && (e.details === 'bufferStalledError' || e.details === 'bufferNudgeOnStall')) {
- // buffer but no error
+ // transient buffer hiccup, not a real error
return;
}
+ // Per-segment 404: that segment's qcamera.ts wasn't uploaded (or has been deleted).
+ // Record it so we can render the gap overlay, and recover so playback continues
+ // for the rest of the route instead of failing the whole player.
+ if (e.type === 'networkError' && e.details === 'fragLoadError' && e.response?.code === 404) {
+ const fragUrl = e.frag?.url || '';
+ const match = fragUrl.match(/\/(\d+)\/qcamera\.ts/);
+ if (match) {
+ const segIdx = parseInt(match[1], 10);
+ this.setState((s) => {
+ if (s.missingSegments.has(segIdx)) return null;
+ const next = new Set(s.missingSegments);
+ next.add(segIdx);
+ console.debug('[DriveVideo] missing segment', segIdx, 'total missing:', next.size);
+ return { missingSegments: next };
+ });
+ }
+ // If HLS marked it fatal, try to recover so we can keep playing the rest.
+ if (e.fatal && hls) {
+ try { hls.recoverMediaError(); } catch (err) { console.debug('[DriveVideo] recoverMediaError failed', err); }
+ }
+ return;
+ }
+
+ dispatch(bufferVideo(true));
if (e.type === 'networkError' && (e.response?.code === 404)) {
this.setState({ videoError: 'This video segment has not uploaded yet or has been deleted.' });
} else {
@@ -136,17 +185,18 @@ class DriveVideo extends Component {
}
/**
- * @param {Error} e
+ * @param {Error|string} e
* @param {any} [data]
+ * @param {any} [hls]
*/
- onVideoError(e, data) {
+ onVideoError(e, data, hls) {
if (!e) {
console.warn('Unknown video error', { e, data });
return;
}
if (e === 'hlsError') {
- this.onHlsError(data);
+ this.onHlsError(data, hls);
return;
}
@@ -183,6 +233,26 @@ class DriveVideo extends Component {
if (videoError) this.setState({ videoError: null });
}
+ /**
+ * @param {number} duration video duration in seconds
+ */
+ onVideoDuration(duration) {
+ const { currentRoute, loop, dispatch } = this.props;
+ if (!currentRoute || !loop || !Number.isFinite(duration) || duration <= 0) {
+ return;
+ }
+ // route.duration (wall-clock) often disagrees with HLS duration: last segment is
+ // a partial chunk because the user can turn the car off mid-segment, sometimes
+ // the camera stops a fraction before the logger does, etc. Shrink the loop end
+ // to match what the video can actually play so the loop wraps cleanly.
+ const videoStartOffset = currentRoute.videoStartOffset || 0;
+ const videoEndMs = videoStartOffset + (duration * 1000);
+ const loopEnd = loop.startTime + loop.duration;
+ if (loopEnd > videoEndMs + 100) {
+ dispatch(selectLoop(loop.startTime, videoEndMs));
+ }
+ }
+
updateVideoSource(prevProps) {
let { src } = this.state;
const { currentRoute } = this.props;
@@ -195,64 +265,143 @@ 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.setState({
+ src,
+ videoError: null,
+ missingSegments: new Set(),
+ currentMissingSegment: null,
+ });
+ this.firstSeek = true;
+ this.hls = null;
this.syncVideo();
}
}
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 internalPlayer = videoPlayer.getInternalPlayer();
+ const duration = videoPlayer.getDuration();
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');
+ const videoStartOffset = currentRoute?.videoStartOffset || 0;
+ const videoAtEnd = internalPlayer.ended || curVideoTime >= duration - 0.1;
+
+ // If the timeline is inside a known-missing segment, hold the video on its last
+ // good frame, render the gap overlay, and let the timeline keep advancing on the
+ // virtual clock. When timeline exits the gap, the normal push branch will seek
+ // the video to the right spot.
+ const missingSeg = this.missingSegmentAt();
+ if (missingSeg !== this.state.currentMissingSegment) {
+ this.setState({ currentMissingSegment: missingSeg });
+ }
+ if (missingSeg !== null) {
+ if (!internalPlayer.paused) internalPlayer.pause();
+ console.debug('[DriveVideo] sync', {
+ cur: curVideoTime.toFixed(2),
+ desired: desiredVideoTime.toFixed(2),
+ action: 'in-missing-segment',
+ seg: missingSeg,
+ });
+ return;
}
- // 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
+ // HTML5 spec: play() on an ended media element auto-seeks to 0.
+ // If the loop extends past the video (e.g., currentRoute.duration > video.duration
+ // because the route is still uploading), our auto-resume play() rewinds to 0 just
+ // for the next tick to push back past the end and clamp — a 1 fps stutter loop.
+ // Force the timeline wrap ourselves so the next push goes to the loop start cleanly.
+ if (internalPlayer.ended && desiredPlaySpeed > 0 && desiredVideoTime >= duration - 0.5) {
+ dispatch(seek(videoStartOffset));
+ console.debug('[DriveVideo] sync', {
+ cur: curVideoTime.toFixed(2),
+ desired: desiredVideoTime.toFixed(2),
+ duration: duration.toFixed(2),
+ action: 'force-wrap-on-ended',
+ });
+ return;
}
- 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'));
+ let action = 'noop';
+ if (Math.abs(timeDiff) > 0.5) {
+ if (desiredVideoTime === 0 && timeDiff < 0 && curVideoTime !== duration) {
+ // logs start earlier than the video, snap timeline forward to where video begins
+ dispatch(seek(currentOffset() - (timeDiff * 1000)));
+ action = 'snap-timeline-forward';
+ } else {
+ // user seek, loop wrap, or initial sync: push to video
+ videoPlayer.seekTo(desiredVideoTime, 'seconds');
+ action = 'push-video';
+ // After EOS (video reached end), seekTo alone doesn't wake HLS up:
+ // the media clock keeps ticking but no frames are decoded. The user's
+ // manual pause+play workaround recovers it, so we do the same here:
+ // tell hls.js to actively start loading from the new position, then
+ // cycle pause→play on the media element to force a clean resume.
+ if (videoAtEnd) {
+ if (this.hls) {
+ try {
+ this.hls.startLoad(desiredVideoTime);
+ console.debug('[DriveVideo] hls.startLoad after wrap', desiredVideoTime.toFixed(2));
+ } catch (err) {
+ console.debug('[DriveVideo] hls.startLoad failed', err && err.message);
+ }
+ }
+ internalPlayer.pause();
+ // Defer the play() to the next microtask so pause() takes effect first.
+ // The auto-resume block below would also call play(), but doing it inline
+ // here means we don't wait an entire 500ms sync tick to recover.
+ Promise.resolve().then(() => {
+ if (this.unmounted) return;
+ if (this.props.desiredPlaySpeed > 0 && internalPlayer.paused) {
+ const playRes = internalPlayer.play();
+ if (playRes) {
+ playRes
+ .then(() => console.debug('[DriveVideo] post-wrap play resolved'))
+ .catch((err) => console.debug('[DriveVideo] post-wrap play rejected:', err && err.message));
+ }
+ }
+ });
}
}
- } 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 && !videoAtEnd) {
+ // let the video play freely and pull the timeline to match, instead of fudging the playback rate.
+ // skip when the video is stuck at its end so the timeline can advance into the loop wrap.
+ dispatch(seek(curVideoTime * 1000 + videoStartOffset));
+ action = 'pull-timeline';
}
+
+ // ReactPlayer's `playing` prop only triggers play() on change, so it won't auto-resume
+ // after the video element pauses itself at the end of a buffered range.
+ let playResult = 'skip';
+ if (desiredPlaySpeed > 0 && internalPlayer.paused && !internalPlayer.seeking) {
+ playResult = 'called';
+ const playRes = internalPlayer.play();
+ if (playRes) {
+ playRes
+ .then(() => console.debug('[DriveVideo] play resolved'))
+ .catch((err) => console.debug('[DriveVideo] play rejected:', err && err.message));
+ }
+ }
+
+ console.debug('[DriveVideo] sync', {
+ cur: curVideoTime.toFixed(2),
+ desired: desiredVideoTime.toFixed(2),
+ diff: timeDiff.toFixed(2),
+ duration: duration.toFixed(2),
+ paused: internalPlayer.paused,
+ ended: internalPlayer.ended,
+ seeking: internalPlayer.seeking,
+ readyState: internalPlayer.readyState,
+ buffering: isBufferingVideo,
+ atEnd: videoAtEnd,
+ action,
+ play: playResult,
+ });
}
currentVideoTime(offset = currentOffset()) {
@@ -270,9 +419,43 @@ class DriveVideo extends Component {
return Math.max(0, offset);
}
+ // Returns the segment index the timeline is currently inside if it's a known missing
+ // segment, or null otherwise.
+ missingSegmentAt(offset = currentOffset()) {
+ const { currentRoute } = this.props;
+ const { missingSegments } = this.state;
+ if (!currentRoute || missingSegments.size === 0) {
+ return null;
+ }
+ const videoStartOffset = currentRoute.videoStartOffset || 0;
+ const segIdx = Math.floor((offset - videoStartOffset) / 60000);
+ return missingSegments.has(segIdx) ? segIdx : null;
+ }
+
+ // Route-time (ms) of the next segment after `fromSeg` whose qcamera is available, or
+ // null if the rest of the route is missing.
+ nextAvailableSegmentTime(fromSeg) {
+ const { currentRoute } = this.props;
+ const { missingSegments } = this.state;
+ if (!currentRoute || fromSeg == null) return null;
+ const videoStartOffset = currentRoute.videoStartOffset || 0;
+ const segNumbers = currentRoute.segment_numbers || [];
+ const maxSeg = segNumbers.length ? Math.max(...segNumbers) : fromSeg;
+ for (let i = fromSeg + 1; i <= maxSeg; i++) {
+ if (!missingSegments.has(i)) {
+ return videoStartOffset + (i * 60000);
+ }
+ }
+ return null;
+ }
+
render() {
- const { desiredPlaySpeed, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted } = this.props;
- const { src, videoError } = this.state;
+ const { desiredPlaySpeed, isBufferingVideo, currentRoute, onAudioStatusChange, isMuted, dispatch } = this.props;
+ const { src, videoError, currentMissingSegment } = this.state;
+
+ const inMissing = currentMissingSegment !== null;
+ const skipTarget = inMissing ? this.nextAvailableSegmentTime(currentMissingSegment) : null;
+ const onSkip = (skipTarget != null) ? () => dispatch(seek(skipTarget)) : null;
const onPlayerReady = (player) => {
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
@@ -285,6 +468,9 @@ class DriveVideo extends Component {
} else { // on other platforms, inspect audio tracks before hls.js changes things
const hlsPlayer = player.getInternalPlayer('hls');
if (hlsPlayer) {
+ // Stash the hls instance so syncVideo can poke its loader directly when the
+ // video element gets stuck after end-of-stream → seek-back.
+ this.hls = hlsPlayer;
hlsPlayer.on('hlsBufferCodecs', (event, data) => {
if (onAudioStatusChange) {
onAudioStatusChange(!!data.audio);
@@ -296,7 +482,12 @@ class DriveVideo extends Component {
return (
-
+
@@ -331,6 +525,7 @@ const stateToProps = Obstruction({
isBufferingVideo: 'isBufferingVideo',
routes: 'routes',
currentRoute: 'currentRoute',
+ loop: 'loop',
});
export default connect(stateToProps)(DriveVideo);
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/components/Timeline/index.jsx b/src/components/Timeline/index.jsx
index a3b7c66d..31d4e8f7 100644
--- a/src/components/Timeline/index.jsx
+++ b/src/components/Timeline/index.jsx
@@ -251,8 +251,16 @@ class Timeline extends Component {
const rulerBounds = this.rulerRef.current.getBoundingClientRect();
const startPercent = (Math.min(dragging[0], dragging[1]) - rulerBounds.x) / rulerBounds.width;
const endPercent = (Math.max(dragging[0], dragging[1]) - rulerBounds.x) / rulerBounds.width;
- const startOffset = Math.round(this.percentToOffset(startPercent));
- const endOffset = Math.round(this.percentToOffset(endPercent));
+ let startOffset = Math.round(this.percentToOffset(startPercent));
+ let endOffset = Math.round(this.percentToOffset(endPercent));
+ const { zoom } = this.state;
+ if (endOffset - startOffset < 1000) {
+ endOffset = startOffset + 1000;
+ if (endOffset > zoom.end) {
+ endOffset = zoom.end;
+ startOffset = Math.max(zoom.start, endOffset - 1000);
+ }
+ }
if (Math.abs(dragging[1] - dragging[0]) > 3) {
const offset = currentOffset();
diff --git a/src/reducers/globalState.js b/src/reducers/globalState.js
index e0a394c3..82afce71 100644
--- a/src/reducers/globalState.js
+++ b/src/reducers/globalState.js
@@ -289,7 +289,7 @@ export default function reducer(_state, action) {
}
break;
case Types.TIMELINE_PUSH_SELECTION: {
- if (!state.zoom || !action.start || !action.end || action.start < state.zoom.start || action.end > state.zoom.end) {
+ if (!state.zoom || action.start == null || action.end == null || action.start < state.zoom.start || action.end > state.zoom.end) {
state.files = null;
}
@@ -300,7 +300,7 @@ export default function reducer(_state, action) {
const r = state.routes?.find((route) => route.log_id === action.log_id);
if (action.log_id && r) {
state.currentRoute = r;
- if (!action.start) {
+ if (action.start == null) {
state.zoom = {
start: 0,
end: state.currentRoute.duration,
@@ -387,7 +387,7 @@ export default function reducer(_state, action) {
state.currentRoute = {
...curr,
};
- if (state.segmentRange.start && state.segmentRange.end) {
+ if (state.segmentRange.start != null && state.segmentRange.end != null) {
state.zoom = {
start: state.segmentRange.start,
end: state.segmentRange.end,
@@ -405,7 +405,7 @@ export default function reducer(_state, action) {
end: state.currentRoute.end_time_utc_millis,
};
- if (!state.loop || !state.loop.startTime || !state.loop.duration) {
+ if (!state.loop || state.loop.startTime == null || !state.loop.duration) {
state.loop = {
startTime: state.zoom.start,
duration: state.zoom.end - state.zoom.start,
diff --git a/src/timeline/index.js b/src/timeline/index.js
index 22724754..af303fe3 100644
--- a/src/timeline/index.js
+++ b/src/timeline/index.js
@@ -13,14 +13,14 @@ export function currentOffset(state = null) {
/** @type {number} */
let offset;
- if (state.offset === null && state.loop?.startTime) {
+ if (state.offset === null && state.loop != null) {
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) {
+ if (offset !== null && state.loop != null) {
// respect the loop
const loopOffset = state.loop.startTime;
if (offset < loopOffset) {
diff --git a/src/timeline/playback.js b/src/timeline/playback.js
index acb381f5..a01f0636 100644
--- a/src/timeline/playback.js
+++ b/src/timeline/playback.js
@@ -86,7 +86,7 @@ export function reducer(_state, action) {
}
// normalize over loop
- if (state.offset !== null && state.loop?.startTime) {
+ if (state.offset !== null && state.loop != null) {
const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed;
const offset = state.offset + (Date.now() - state.startTime) * playSpeed;
loopOffset = state.loop.startTime;
diff --git a/src/url.js b/src/url.js
index 75b8d592..e7b6f3ff 100644
--- a/src/url.js
+++ b/src/url.js
@@ -29,10 +29,12 @@ export function getSegmentRange(pathname) {
parts = parts.filter((m) => m.length);
if (parts.length >= 2 && logIdRegex.test(parts[1])) {
+ const startSec = Number(parts[2]);
+ const endSec = Number(parts[3]);
return {
log_id: parts[1],
- start: Number(parts[2]) * 1000,
- end: Number(parts[3]) * 1000,
+ start: Number.isFinite(startSec) ? startSec * 1000 : null,
+ end: Number.isFinite(endSec) ? endSec * 1000 : null,
};
}
return null;
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');
-}