Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { webrtcConnectionManager } from './utils/webrtc';
import store, { history } from './store';

import ErrorFallback from './components/ErrorFallback';
import { isDemoPath } from './demo';

const Explorer = lazy(() => import('./components/explorer'));
const AnonymousLanding = lazy(() => import('./components/anonymous'));
Expand Down Expand Up @@ -128,7 +129,10 @@ class App extends Component {
return this.renderLoading();
}

const showLogin = !MyCommaAuth.isAuthenticated() && !getZoom(window.location.pathname) && !getSegmentRange(window.location.pathname);
const showLogin = !MyCommaAuth.isAuthenticated()
&& !isDemoPath(window.location.pathname)
&& !getZoom(window.location.pathname)
&& !getSegmentRange(window.location.pathname);
let content = (
<Suspense fallback={this.renderLoading()}>
{ showLogin ? this.anonymousRoutes() : this.authRoutes() }
Expand Down
7 changes: 4 additions & 3 deletions src/actions/cached.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react';
import * as Types from './types';
import { reverseLookup } from '../utils/geocode';
import { toBool } from '../utils';
import { transformRouteCoords, transformRouteEvents } from '../dataSource';

const USE_LOCAL_COORDS_DATA = toBool(import.meta.env.VITE_APP_LOCAL_COORDS_DATA);
if (USE_LOCAL_COORDS_DATA) {
Expand Down Expand Up @@ -317,7 +318,7 @@ export function fetchEvents(route) {
return [];
}
const events = await resp.json();
return events;
return transformRouteEvents(route, j, events);
})(i));
}

Expand Down Expand Up @@ -488,13 +489,13 @@ export function fetchDriveCoords(route) {
return;
}

driveCoords = driveCoords.reduce((prev, curr) => ({
driveCoords = transformRouteCoords(route, driveCoords.reduce((prev, curr) => ({
...prev,
...curr.reduce((p, cs) => {
p[cs.t] = [cs.lng, cs.lat];
return p;
}, {}),
}), {});
}), {}));

dispatch({
type: Types.ACTION_UPDATE_ROUTE,
Expand Down
8 changes: 5 additions & 3 deletions src/actions/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { athena as Athena, devices as Devices, raw as Raw } from '@commaai/api';
import { updateDeviceOnline, fetchDeviceNetworkStatus } from '.';
import * as Types from './types';
import { deviceOnCellular, getDeviceFromState, deviceVersionAtLeast, asyncSleep } from '../utils';
import { getRouteFiles } from '../dataSource';

export const FILE_NAMES = {
qcameras: ['qcamera.ts'],
Expand Down Expand Up @@ -97,17 +98,18 @@ export function updateFiles(files) {

export function fetchFiles(routeName, nocache = false) {
return async (dispatch) => {
let files;
let routeFiles;
try {
files = await Raw.getRouteFiles(routeName, nocache);
routeFiles = await getRouteFiles(routeName, nocache);
} catch (err) {
console.error(err);
Sentry.captureException(err, { fingerprint: 'action_files_fetch_files' });
return;
}

const dongleId = routeName.split('|')[0];
const urlName = routeName.replace('|', '/');
const urlName = routeFiles.sourceRouteName.replace('|', '/');
const { files } = routeFiles;
const urls = Object
.keys(FILE_NAMES)
.filter((type) => files[type])
Expand Down
7 changes: 4 additions & 3 deletions src/actions/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { push } from 'connected-react-router';
import * as Sentry from '@sentry/react';
import document from 'global/document';
import { athena as Athena, billing as Billing, devices as Devices, drives as Drives } from '@commaai/api';
import { athena as Athena, billing as Billing, devices as Devices } from '@commaai/api';
import MyCommaAuth from '@commaai/my-comma-auth';

import * as Types from './types';
import { selectLoop } from '../timeline/playback';
import {hasRoutesData } from '../timeline/segments';
import { getDeviceFromState, deviceVersionAtLeast, deviceIsOnline } from '../utils';
import { webrtcConnectionManager } from '../utils/webrtc';
import { getRoutesSegments } from '../dataSource';

let routesRequest = null;
let routesRequestPromise = null;
Expand Down Expand Up @@ -36,12 +37,12 @@ export function checkRoutesData() {
// if requested segment range not in loaded routes, fetch it explicitly
if (state.segmentRange) {
routesRequest = {
req: Drives.getRoutesSegments(dongleId, undefined, undefined, undefined, `${dongleId}|${state.segmentRange.log_id}`),
req: getRoutesSegments(dongleId, undefined, undefined, undefined, `${dongleId}|${state.segmentRange.log_id}`),
dongleId,
};
} else {
routesRequest = {
req: Drives.getRoutesSegments(dongleId, fetchRange.start, fetchRange.end, state.limit),
req: getRoutesSegments(dongleId, fetchRange.start, fetchRange.end, state.limit),
dongleId,
};
}
Expand Down
16 changes: 11 additions & 5 deletions src/actions/startup.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import MyCommaAuth from '@commaai/my-comma-auth';

import { ACTION_STARTUP_DATA } from './types';
import { primeFetchSubscription, checkLastRoutesData, selectDevice, fetchSharedDevice } from '.';
import { getDemoStartupData } from '../demo';

async function initProfile() {
if (MyCommaAuth.isAuthenticated()) {
Expand Down Expand Up @@ -45,7 +46,10 @@ export default function init() {
dispatch(checkLastRoutesData());
}

const [profile, devices] = await Promise.all([initProfile(), initDevices()]);
const demoStartupData = getDemoStartupData(state.dongleId);
const [profile, devices] = demoStartupData
? [demoStartupData.profile, demoStartupData.devices]
: await Promise.all([initProfile(), initDevices()]);
state = getState();

if (profile) {
Expand All @@ -63,10 +67,12 @@ export default function init() {
}
const dongleId = getState().dongleId;
const device = devices.find((dev) => dev.dongle_id === dongleId);
if (device) {
dispatch(primeFetchSubscription(dongleId, device, profile));
} else if (dongleId) {
dispatch(fetchSharedDevice(dongleId));
if (!demoStartupData) {
if (device) {
dispatch(primeFetchSubscription(dongleId, device, profile));
} else if (dongleId) {
dispatch(fetchSharedDevice(dongleId));
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/Dashboard/DriveListItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const DriveListItem = (props) => {
<div className={classes.driveHeader} style={!small ? { padding: '18px 32px' } : { padding: 18 }}>
<Grid container>
<div className={classes.driveGridItem} style={gridStyle.date}>
<Typography className={classes.firstLine}>{startDate}</Typography>
<Typography className={classes.firstLine}>{drive.display_name || startDate}</Typography>
<Typography>{`${startTime} to ${endTime}`}</Typography>
</div>
<div className={`${classes.driveGridItem} ${small && classes.driveGridItemRightAlign}`} style={gridStyle.dur}>
Expand Down
25 changes: 20 additions & 5 deletions src/components/DriveVideo/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ import { CircularProgress, Typography } from '@material-ui/core';
import Obstruction from 'obstruction';
import ReactPlayer from 'react-player/file';

import { video as Video } from '@commaai/api';

import Colors from '../../colors';
import { ErrorOutline } from '../../icons';
import { bufferVideo, setPlaybackSpeed, resetPlayback, play, pause, seek } from '../../timeline/playback';
import { setVideoPlayer, seekVideoPlayer, getVideoPlayerCurrentTime } from '../../timeline/videoPlayer';
import { isIos } from '../../utils/browser.js';
import { getRouteVideoSource } from '../../dataSource';

const VideoOverlay = ({ loading, error }) => {
let content;
Expand Down Expand Up @@ -49,6 +48,7 @@ class DriveVideo extends Component {
this.onVideoPlaybackRateChange = this.onVideoPlaybackRateChange.bind(this);
this.onTimeUpdate = this.onTimeUpdate.bind(this);
this.firstSeek = true;
this.sourceRequest = 0;

this.videoPlayer = React.createRef();

Expand All @@ -75,6 +75,8 @@ class DriveVideo extends Component {
}

componentWillUnmount() {
this.sourceRequest += 1;
if (this.state.src?.startsWith('blob:')) URL.revokeObjectURL(this.state.src);
setVideoPlayer(null);
}

Expand Down Expand Up @@ -193,19 +195,32 @@ class DriveVideo extends Component {
dispatch(seek(videoTime));
}

updateVideoSource(prevProps) {
async updateVideoSource(prevProps) {
let { src } = this.state;
const { currentRoute } = this.props;
if (!currentRoute) {
this.sourceRequest += 1;
if (src !== '') {
if (src?.startsWith('blob:')) URL.revokeObjectURL(src);
this.setState({ src: '', videoError: null });
}
return;
}

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.sourceRequest += 1;
const sourceRequest = this.sourceRequest;
try {
src = await getRouteVideoSource(currentRoute);
if (sourceRequest !== this.sourceRequest) {
if (src.startsWith('blob:')) URL.revokeObjectURL(src);
return;
}
if (this.state.src?.startsWith('blob:')) URL.revokeObjectURL(this.state.src);
this.setState({ src, videoError: null });
} catch (err) {
if (sourceRequest === this.sourceRequest) this.setState({ src: '', videoError: err.message });
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/dataSource.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Production consumers use this boundary without knowing whether their data is
// backed by the API directly or adapted into a local demo scenario.
export {
getRouteFiles,
getRoutesSegments,
getRouteVideoSource,
transformRouteCoords,
transformRouteEvents,
} from './demo';
116 changes: 116 additions & 0 deletions src/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { drives as Drives, raw as Raw, video as Video } from '@commaai/api';

export const DEMO_DONGLE_ID = 'demo';
const DEMO_SOURCE_DONGLE_ID = '5beb9b58bd12b691';
const DEMO_SOURCE_LOG_ID = '0000010a--a51155e496';
export const DEMO_SOURCE_ROUTE = `${DEMO_SOURCE_DONGLE_ID}|${DEMO_SOURCE_LOG_ID}`;
const AVAILABLE_SEGMENTS = Symbol('demoAvailableSegments');
const MISSING_EVENT_SEGMENTS = Symbol('demoMissingEventSegments');
const NO_GPS = Symbol('demoNoGps');

export function isDemoDongle(dongleId) {
return dongleId === DEMO_DONGLE_ID;
}

export function isDemoPath(pathname) {
return pathname.split('/').filter(Boolean)[0] === DEMO_DONGLE_ID;
}

const demoDevice = {
alias: 'demo routes',
dongle_id: DEMO_DONGLE_ID,
is_owner: false,
shared: true,
prime: false,
};

const CASES = [
{ label: 'Control — complete route', logId: '0000010a--a51155e496', keep: () => true },
{ label: 'Missing first segment', logId: '0000010a--a51155e490', keep: (_, i) => i !== 0 },
{ label: 'Missing final three segments', logId: '0000010a--a51155e493', keep: (_, i, a) => i < a.length - 3 },
{ label: 'Missing events.json in first segment', logId: '0000010a--a51155e494', keep: () => true, missingEvents: segments => [segments[0]] },
{ label: 'Missing events.json in middle segment', logId: '0000010a--a51155e495', keep: () => true, missingEvents: segments => [segments[Math.floor(segments.length / 2)]] },
{ label: 'No GPS data', logId: '0000010a--a51155e497', keep: () => true, noGps: true },
];

export function createDemoRoutes(sourceRoute) {
return CASES.map(({ label, logId, keep, missingEvents, noGps }, caseIndex) => {
const availableSegments = sourceRoute.segment_numbers
.filter((segment, index, segments) => keep(segment, index, segments));
const listTimeOffset = caseIndex * 1000;
return {
...sourceRoute,
display_name: label,
dongle_id: DEMO_DONGLE_ID,
fullname: `${DEMO_DONGLE_ID}|${logId}`,
log_id: logId,
[AVAILABLE_SEGMENTS]: availableSegments,
[MISSING_EVENT_SEGMENTS]: missingEvents ? missingEvents(sourceRoute.segment_numbers) : [],
[NO_GPS]: Boolean(noGps),
start_lat: noGps ? null : sourceRoute.start_lat,
start_lng: noGps ? null : sourceRoute.start_lng,
end_lat: noGps ? null : sourceRoute.end_lat,
end_lng: noGps ? null : sourceRoute.end_lng,
create_time: sourceRoute.create_time - caseIndex,
start_time_utc_millis: sourceRoute.start_time_utc_millis - listTimeOffset,
end_time_utc_millis: sourceRoute.end_time_utc_millis - listTimeOffset,
};
});
}

function getSourceRouteName(routeName) {
return isDemoDongle(routeName.split('|')[0]) ? DEMO_SOURCE_ROUTE : routeName;
}

export function getDemoStartupData(dongleId) {
if (!isDemoDongle(dongleId)) return null;
return { profile: null, devices: [demoDevice] };
}

export function getRoutesSegments(dongleId, start, end, limit, routeName) {
if (!isDemoDongle(dongleId)) {
return Drives.getRoutesSegments(dongleId, start, end, limit, routeName);
}
return Drives.getRoutesSegments(
DEMO_SOURCE_DONGLE_ID,
undefined,
undefined,
undefined,
DEMO_SOURCE_ROUTE,
).then((routes) => createDemoRoutes(routes[0]));
}

export function transformRouteEvents(route, segment, events) {
return route[MISSING_EVENT_SEGMENTS]?.includes(segment) ? [] : events;
}

export function transformRouteCoords(route, coords) {
return route[NO_GPS] ? {} : coords;
}

export async function getRouteFiles(routeName, nocache = false) {
const sourceRouteName = getSourceRouteName(routeName);
const files = await Raw.getRouteFiles(sourceRouteName, nocache);
return { files, sourceRouteName };
}

export async function getRouteVideoSource(route) {
const sourceRouteName = getSourceRouteName(route.fullname);
const sourceUrl = Video.getQcameraStreamUrl(sourceRouteName, route.share_exp, route.share_sig);
if (!route[AVAILABLE_SEGMENTS]) return sourceUrl;

const response = await fetch(sourceUrl);
if (!response.ok) throw new Error(`Unable to load demo playlist (${response.status})`);
const playlist = rewriteDemoPlaylist(await response.text(), route[AVAILABLE_SEGMENTS]);
return URL.createObjectURL(new Blob([playlist], { type: 'application/vnd.apple.mpegurl' }));
}

export function rewriteDemoPlaylist(playlist, availableSegments) {
const available = new Set(availableSegments);
return playlist.split('\n').map((line) => {
const match = line.match(/\/(\d+)\/qcamera\.ts\?/);
if (!match || available.has(Number(match[1]))) return line;
// Keep the entry and duration in place, but make the media request fail.
return line.replace(/([?&]sig=)[^&]*/, '$1intentionally-missing');
}).join('\n');
}
Loading
Loading