Skip to content

Commit 66681aa

Browse files
committed
Polish route playback and upload feedback
1 parent 3337ca2 commit 66681aa

8 files changed

Lines changed: 127 additions & 46 deletions

File tree

src/App.browser.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
22
import { configure, fireEvent, render, waitFor } from '@solidjs/testing-library'
3+
import { QueryClientProvider } from '@tanstack/solid-query'
4+
import type { ParentComponent } from 'solid-js'
35

46
import { setAccessToken, signOut } from '~/api/auth/client'
7+
import { getAppQueryClient } from '~/api/query-client'
58
import * as Demo from '~/api/auth/demo'
69
import { AppLayout, Routes } from './App'
710

811
const DEMO_LOG_ID = '000000dd--455f14369d'
912

10-
const renderApp = (location: string) => render(() => <Routes />, { location, wrapper: AppLayout })
13+
const renderApp = (location: string) => {
14+
const queryClient = getAppQueryClient()
15+
const Wrapper: ParentComponent = (props) => (
16+
<QueryClientProvider client={queryClient}>
17+
<AppLayout>{props.children}</AppLayout>
18+
</QueryClientProvider>
19+
)
20+
return render(() => <Routes />, { location, wrapper: Wrapper })
21+
}
1122

1223
beforeAll(() => configure({ asyncUtilTimeout: 3000 }))
1324
beforeEach(() => signOut())
@@ -31,6 +42,8 @@ describe('Demo mode', () => {
3142
const video = (await findByTestId('route-video')) as HTMLVideoElement
3243
await waitFor(() => expect(video.src).toBeTruthy())
3344
expect(video.muted).toBe(true)
45+
expect(await findByLabelText('Skip back 10 seconds')).toBeTruthy()
46+
expect(await findByLabelText('Skip forward 10 seconds')).toBeTruthy()
3447
await fireEvent.click(await findByLabelText('Unmute'))
3548
expect(video.muted).toBe(false)
3649
expect(await findByLabelText('Mute')).toBeTruthy()

src/components/RouteUploadButtons.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,19 @@ const UploadButton: VoidComponent<UploadButtonProps> = (props) => {
4040
success: 'check',
4141
error: 'error',
4242
}
43+
const color = () => {
44+
if (state() === 'success') return 'secondary'
45+
if (state() === 'error') return 'error'
46+
return 'primary'
47+
}
4348

4449
return (
4550
<Button
4651
onClick={() => handleUpload()}
4752
class="px-2 md:px-3"
4853
disabled={disabled()}
4954
leading={<Icon class={clsx(state() === 'loading' && 'animate-spin')} name={stateToIcon[state()]} size="20" />}
50-
color="primary"
55+
color={color()}
5156
>
5257
<span class="flex items-center gap-1 font-mono">{props.text}</span>
5358
</Button>
@@ -66,6 +71,12 @@ const RouteUploadButtons: VoidComponent<RouteUploadButtonsProps> = (props) => {
6671
route: 'idle',
6772
})
6873
const [abortController, setAbortController] = createSignal(new AbortController())
74+
const statusMessage = () => {
75+
if (Object.values(uploadStore).includes('loading')) return 'Submitting upload request...'
76+
if (Object.values(uploadStore).includes('error')) return 'Some uploads failed to start. Try again.'
77+
if (Object.values(uploadStore).includes('success')) return 'Upload request accepted. Track progress in the device upload status.'
78+
return 'Choose which files to request from this route.'
79+
}
6980

7081
createEffect(
7182
on(
@@ -115,6 +126,7 @@ const RouteUploadButtons: VoidComponent<RouteUploadButtonsProps> = (props) => {
115126
<UploadButton text="Logs" icon="description" state={uploadStore.logs} onClick={() => handleUpload('logs')} />
116127
<UploadButton text="All" icon="upload" state={uploadStore.route} onClick={() => handleUpload('route')} />
117128
</div>
129+
<p class="mt-3 text-sm text-on-surface-variant">{statusMessage()}</p>
118130
</div>
119131
)
120132
}

src/components/RouteVideoPlayer.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
3030
const [duration, setDuration] = createSignal(0)
3131
const [videoLoading, setVideoLoading] = createSignal(true)
3232
const [errorMessage, setErrorMessage] = createSignal<string>('')
33+
const selectionEndTime = () => props.selection.endTime ?? duration()
34+
35+
const clampTime = (nextTime: number) => {
36+
const startTime = props.selection.startTime
37+
const endTime = selectionEndTime()
38+
return Math.max(startTime, Math.min(nextTime, endTime))
39+
}
40+
41+
const seekTo = (nextTime: number) => {
42+
const clampedTime = clampTime(nextTime)
43+
video.currentTime = clampedTime
44+
setCurrentTime(clampedTime)
45+
props.onProgress?.(clampedTime)
46+
}
3347

3448
const onLoadedData = () => {
3549
setVideoLoading(false)
@@ -65,6 +79,11 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
6579
video.pause()
6680
}
6781
}
82+
const skipBy = (seconds: number) => (e: Event) => {
83+
e.preventDefault()
84+
e.stopPropagation()
85+
seekTo(video.currentTime + seconds)
86+
}
6887
const toggleMuted = (e: Event) => {
6988
e.preventDefault()
7089
e.stopPropagation()
@@ -76,18 +95,17 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
7695
}
7796

7897
const onTimeUpdate = (e: Event) => {
79-
setCurrentTime((e.currentTarget as HTMLVideoElement).currentTime)
98+
const nextCurrentTime = (e.currentTarget as HTMLVideoElement).currentTime
99+
setCurrentTime(nextCurrentTime)
80100

81101
// If there is a selection, loop within it
82-
if (currentTime() < props.selection.startTime) {
83-
video.currentTime = props.selection.startTime
84-
} else if (props.selection.endTime !== undefined) {
85-
if (currentTime() > props.selection.endTime) {
86-
video.currentTime = props.selection.startTime
87-
}
102+
if (nextCurrentTime < props.selection.startTime) {
103+
seekTo(props.selection.startTime)
104+
} else if (props.selection.endTime !== undefined && nextCurrentTime > props.selection.endTime) {
105+
seekTo(props.selection.startTime)
106+
} else if (video.paused) {
107+
updateProgress()
88108
}
89-
90-
if (video.paused) updateProgress()
91109
}
92110
const onLoadedMetadata = () => setDuration(Math.ceil(video.duration))
93111
const onPlay = () => {
@@ -103,7 +121,7 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
103121

104122
onMount(() => {
105123
if (props.selection.startTime > 0) {
106-
video.currentTime = props.selection.startTime
124+
seekTo(props.selection.startTime)
107125
}
108126

109127
video.defaultMuted = true
@@ -225,7 +243,9 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
225243

226244
{/* Controls container */}
227245
<div class="relative flex w-full items-center gap-3 pb-3 px-2">
228-
<IconButton name={isPlaying() ? 'pause' : 'play_arrow'} filled />
246+
<IconButton name="replay_10" aria-label="Skip back 10 seconds" onClick={skipBy(-10)} />
247+
<IconButton aria-label={isPlaying() ? 'Pause' : 'Play'} name={isPlaying() ? 'pause' : 'play_arrow'} filled />
248+
<IconButton name="forward_10" aria-label="Skip forward 10 seconds" onClick={skipBy(10)} />
229249

230250
<div class="font-mono text-sm text-on-surface">
231251
{formatVideoTime(currentTime())} / {formatVideoTime(duration())}

src/components/Timeline.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,11 @@ const Timeline: VoidComponent<TimelineProps> = (props) => {
106106

107107
onMount(() => {
108108
const updateMarker = (clientX: number) => {
109+
if (!props.route || duration() === 0) return
109110
const rect = ref.getBoundingClientRect()
110-
const x = Math.min(Math.max(clientX - rect.left, 0), rect.width - MARKER_WIDTH)
111-
const fraction = x / rect.width
111+
const availableWidth = Math.max(rect.width - MARKER_WIDTH, 1)
112+
const x = Math.min(Math.max(clientX - rect.left, 0), availableWidth)
113+
const fraction = x / availableWidth
112114
// Update marker immediately without waiting for video
113115
setMarkerOffsetPct(fraction * 100)
114116
props.updateTime(duration() * fraction)

src/components/UploadQueue.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const queries = {
2929
},
3030
}
3131

32-
const mapOfflineQueueItems = (data: AthenaOfflineQueueResponse): UploadQueueItem[] =>
32+
export const mapOfflineQueueItems = (data: AthenaOfflineQueueResponse): UploadQueueItem[] =>
3333
data
3434
.filter((item) => item.method === 'uploadFilesToUrls')
3535
.flatMap((item) =>
@@ -122,6 +122,13 @@ const UploadQueue: VoidComponent<{ dongleId: string }> = (props) => {
122122
Cancel all
123123
</Button>
124124
</div>
125+
<Show when={items.length > 0}>
126+
<div class="px-4 text-xs text-on-surface-variant">
127+
{onlineQueue.isSuccess
128+
? 'Uploads are active or waiting on the device.'
129+
: 'Uploads are queued and will continue when the device is reachable.'}
130+
</div>
131+
</Show>
125132
<div class="relative h-[calc(4*3rem)] sm:h-[calc(6*3rem)] flex justify-center items-center text-on-surface-variant">
126133
<Switch
127134
fallback={

src/components/material/Icon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const Icons = [
88
'add', 'arrow_back', 'camera', 'check', 'chevron_right', 'clear', 'close', 'delete', 'description', 'directions_car', 'download', 'error',
99
'file_copy', 'flag', 'info', 'keyboard_arrow_down', 'keyboard_arrow_up', 'local_fire_department', 'logout', 'menu', 'my_location',
1010
'open_in_new', 'payments', 'person', 'progress_activity', 'satellite_alt', 'search', 'settings', 'upload', 'videocam', 'refresh',
11-
'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all', 'volume_off', 'volume_up',
11+
'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all', 'volume_off', 'volume_up', 'replay_10', 'forward_10',
1212
] as const
1313

1414
export type IconName = (typeof Icons)[number]

src/pages/dashboard/activities/DeviceActivity.tsx

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
1-
import { createResource, createSignal, For, Show, Suspense, type VoidComponent } from 'solid-js'
1+
import { createEffect, createResource, createSignal, For, Show, Suspense, type VoidComponent } from 'solid-js'
22
import { createStore } from 'solid-js/store'
33
import clsx from 'clsx'
4-
54
import { takeSnapshot } from '~/api/athena'
6-
import { getDevice, SHARED_DEVICE } from '~/api/devices'
5+
import { getAthenaOfflineQueue, getDevice, SHARED_DEVICE } from '~/api/devices'
6+
import { getUploadQueue } from '~/api/file'
77
import { DrawerToggleButton, useDrawerContext } from '~/components/material/Drawer'
88
import Icon from '~/components/material/Icon'
99
import IconButton from '~/components/material/IconButton'
1010
import TopAppBar from '~/components/material/TopAppBar'
1111
import DeviceLocation from '~/components/DeviceLocation'
1212
import DeviceStatistics from '~/components/DeviceStatistics'
13-
import UploadQueue from '~/components/UploadQueue'
13+
import UploadQueue, { mapOfflineQueueItems } from '~/components/UploadQueue'
1414
import { dayjs } from '~/utils/format'
1515
import { getDeviceName } from '~/utils/device'
16-
1716
import RouteList from '../components/RouteList'
18-
1917
type DeviceActivityProps = {
2018
dongleId: string
2119
}
22-
2320
const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
24-
// TODO: device should be passed in from DeviceList
21+
const getOnlineUploadSummary = async (dongleId: string) => {
22+
try {
23+
return await getUploadQueue(dongleId)
24+
} catch {
25+
return { result: [] }
26+
}
27+
}
28+
29+
const getOfflineUploadSummary = async (dongleId: string) => {
30+
try {
31+
return await getAthenaOfflineQueue(dongleId)
32+
} catch {
33+
return []
34+
}
35+
}
2536
const [device] = createResource(() => props.dongleId, getDevice)
26-
// Resource as source of another resource blocks component initialization
2737
const deviceName = () => (device.latest ? getDeviceName(device.latest) : '')
28-
// TODO: remove this. if we're listing the routes for a device you should always be a user, this is for viewing public routes which are being removed
2938
const isDeviceUser = () => (device.loading ? true : device.latest?.is_owner || device.latest?.alias !== SHARED_DEVICE)
3039
const [queueVisible, setQueueVisible] = createSignal(false)
3140
const [snapshot, setSnapshot] = createStore<{
@@ -37,7 +46,6 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
3746
fetching: false,
3847
images: [],
3948
})
40-
4149
const onClickSnapshot = async () => {
4250
setSnapshot({ error: null, fetching: true })
4351
try {
@@ -58,7 +66,6 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
5866
setSnapshot('fetching', false)
5967
}
6068
}
61-
6269
const downloadSnapshot = (image: string, index: number) => {
6370
const link = document.createElement('a')
6471
link.href = `data:image/jpeg;base64,${image}`
@@ -67,23 +74,35 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
6774
link.click()
6875
document.body.removeChild(link)
6976
}
70-
71-
const clearImage = (index: number) => {
72-
const newImages = snapshot.images.filter((_, i) => i !== index)
73-
setSnapshot('images', newImages)
74-
}
75-
77+
const clearImage = (index: number) =>
78+
setSnapshot(
79+
'images',
80+
snapshot.images.filter((_, i) => i !== index),
81+
)
7682
const clearError = () => setSnapshot('error', null)
77-
7883
const { modal } = useDrawerContext()
84+
const [onlineQueue] = createResource(() => props.dongleId, getOnlineUploadSummary)
85+
const [offlineQueue] = createResource(() => props.dongleId, getOfflineUploadSummary)
7986
const onlineStatus = () => (device.latest?.is_online ? 'Online now' : 'Offline')
80-
const lastSeen = () => {
81-
const lastPing = device.latest?.last_athena_ping
82-
if (!lastPing) return 'Last seen unavailable'
83-
return `Last seen ${dayjs.unix(lastPing).format('MMM D, h:mm A')}`
84-
}
87+
const lastSeen = () =>
88+
device.latest?.last_athena_ping
89+
? `Last seen ${dayjs.unix(device.latest.last_athena_ping).format('MMM D, h:mm A')}`
90+
: 'Last seen unavailable'
8591
const versionLabel = () => device.latest?.openpilot_version || 'Version unavailable'
86-
92+
const uploadSummary = () => {
93+
const onlineItems = onlineQueue.latest?.result ?? []
94+
const offlineItems = offlineQueue.latest ? mapOfflineQueueItems(offlineQueue.latest) : []
95+
const queuedCount = onlineItems.length + offlineItems.length
96+
if (queuedCount === 0) return { count: 0, label: 'No pending uploads' }
97+
if (onlineItems.some((item) => item.progress > 0 && item.progress < 1)) {
98+
return { count: queuedCount, label: `${queuedCount} upload${queuedCount === 1 ? '' : 's'} in progress` }
99+
}
100+
if (offlineItems.length > 0 && onlineItems.length === 0) {
101+
return { count: queuedCount, label: `${queuedCount} queued until the device reconnects` }
102+
}
103+
return { count: queuedCount, label: `${queuedCount} upload${queuedCount === 1 ? '' : 's'} queued` }
104+
}
105+
createEffect(() => uploadSummary().count > 0 && setQueueVisible(true))
87106
return (
88107
<>
89108
<TopAppBar
@@ -133,6 +152,7 @@ const DeviceActivity: VoidComponent<DeviceActivityProps> = (props) => {
133152
onClick={() => setQueueVisible(!queueVisible())}
134153
>
135154
<p>{queueVisible() ? 'Hide upload status' : 'Show upload status'}</p>
155+
<span class="rounded-full bg-surface-container-high px-2 py-1 text-xs text-on-surface-variant">{uploadSummary().label}</span>
136156
<Icon class="text-zinc-500" name={queueVisible() ? 'keyboard_arrow_up' : 'keyboard_arrow_down'} />
137157
</button>
138158
</Show>

src/pages/dashboard/activities/RouteActivity.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,14 @@ const RouteActivity: VoidComponent<RouteActivityProps> = (props) => {
3636
const selection = () => ({ startTime: props.startTime, endTime: props.endTime })
3737

3838
// FIXME: generateTimelineStatistics is given different versions of TimelineEvents multiple times, leading to stuttering engaged % on switch
39-
const [events] = createResource(route, getTimelineEvents, { initialValue: [] })
40-
const [statistics] = createResource(
41-
() => [route(), events()] as const,
39+
const [events, { mutate: setEvents }] = createResource(route, getTimelineEvents)
40+
const [statistics, { mutate: setStatistics }] = createResource(
41+
() => {
42+
const currentRoute = route()
43+
const routeEvents = events()
44+
if (!currentRoute || !routeEvents || events.loading) return undefined
45+
return [currentRoute, routeEvents] as const
46+
},
4247
([r, e]) => generateRouteStatistics(r, e),
4348
)
4449

@@ -50,6 +55,8 @@ const RouteActivity: VoidComponent<RouteActivityProps> = (props) => {
5055
createEffect(() => {
5156
routeName() // track changes
5257
setSeekTime(props.startTime)
58+
setEvents(undefined)
59+
setStatistics(undefined)
5360
onTimelineChange(props.startTime)
5461
})
5562

@@ -69,7 +76,7 @@ const RouteActivity: VoidComponent<RouteActivityProps> = (props) => {
6976
<div class="flex flex-col gap-6 px-4 pb-4">
7077
<div class="flex flex-col">
7178
<RouteVideoPlayer ref={setVideoRef} routeName={routeName()} selection={selection()} onProgress={setSeekTime} />
72-
<Timeline class="mb-1" route={route()} seekTime={seekTime()} updateTime={onTimelineChange} events={events()} />
79+
<Timeline class="mb-1" route={route()} seekTime={seekTime()} updateTime={onTimelineChange} events={events() ?? []} />
7380

7481
<Show when={selection().startTime || selection().endTime}>
7582
<A

0 commit comments

Comments
 (0)