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 (
-
+
{content}
); }; -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'); -}