Skip to content

Commit

Permalink
feat(route): control video via the timeline (#103)
Browse files Browse the repository at this point in the history
Co-authored-by: Cameron Clough <[email protected]>
  • Loading branch information
t1mp4 and incognitojam authored Jan 22, 2025
1 parent 562ea9b commit 2d69bc4
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 26 deletions.
4 changes: 4 additions & 0 deletions src/components/RouteVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type RouteVideoPlayerProps = {
class?: string
routeName: string
onProgress?: (seekTime: number) => void
ref?: (el: HTMLVideoElement) => void
}

const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
Expand All @@ -19,6 +20,9 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
const timeUpdate = () => props.onProgress?.(video.currentTime)
video.addEventListener('timeupdate', timeUpdate)
onCleanup(() => video.removeEventListener('timeupdate', timeUpdate))
if (props.ref) {
props.ref(video)
}
})
let hls = new Hls()
createEffect(() => {
Expand Down
99 changes: 75 additions & 24 deletions src/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { For, createResource, Show, Suspense } from 'solid-js'
import type { VoidComponent } from 'solid-js'
import { For, createResource, createSignal, createEffect, createMemo, Show, Suspense } from 'solid-js'
import type { VoidComponent, Accessor } from 'solid-js'
import clsx from 'clsx'

import { TimelineEvent, getTimelineEvents } from '~/api/derived'
Expand Down Expand Up @@ -92,43 +92,87 @@ function renderTimelineEvents(
)
}

// TODO: align to first camera frame event
function renderMarker(route: Route | undefined, seekTime: number | undefined) {
if (!route) return null
if (seekTime === undefined) return null

const duration = getRouteDuration(route)?.asSeconds() ?? 0
const offsetPct = (seekTime / duration) * 100
return (
<div
class="absolute top-0 z-10 h-full"
style={{
'background-color': 'rgba(255,255,255,0.7)',
width: '3px',
left: `${offsetPct}%`,
}}
/>
)
}

interface TimelineProps {
class?: string
routeName: string
seekTime?: number
seekTime: Accessor<number>
updateTime: (newTime: number) => void
}

const Timeline: VoidComponent<TimelineProps> = (props) => {
const [route] = createResource(() => props.routeName, getRoute)
const [events] = createResource(route, getTimelineEvents)
// TODO: align to first camera frame event
const [markerOffsetPct, setMarkerOffsetPct] = createSignal(0)
const duration = createMemo(() =>
route()
? getRouteDuration(route()!)?.asSeconds() ?? 0
: 0,
)

let ref: HTMLDivElement
let handledTouchStart = false

function updateMarker(clientX: number, rect: DOMRect) {
const x = clientX - rect.left
const fraction = x / rect.width
// Update marker immediately without waiting for video
setMarkerOffsetPct(fraction * 100)
const newTime = duration() * fraction
props.updateTime(newTime)
}

function onMouseDownOrTouchStart(ev: MouseEvent | TouchEvent) {
if (handledTouchStart || !route()) return

const rect = ref.getBoundingClientRect()

if (ev.type === 'mousedown') {
ev = ev as MouseEvent
updateMarker(ev.clientX, rect)
const onMove = (moveEv: MouseEvent) => {
updateMarker(moveEv.clientX, rect)
}
const onUpOrLeave = () => {
ref.removeEventListener('mousemove', onMove)
ref.removeEventListener('mouseup', onUpOrLeave)
ref.removeEventListener('mouseleave', onUpOrLeave)
}
ref.addEventListener('mousemove', onMove)
ref.addEventListener('mouseup', onUpOrLeave)
ref.addEventListener('mouseleave', onUpOrLeave)
} else {
ev = ev as TouchEvent
if (ev.touches.length === 1) {
updateMarker(ev.touches[0].clientX, rect)
}
}
}

createEffect(() => {
setMarkerOffsetPct((props.seekTime() / duration()) * 100)
})

return (
<div
ref={ref!}
class={clsx(
'relative isolate flex h-6 self-stretch overflow-hidden rounded-sm bg-blue-900',
'relative isolate flex h-6 cursor-pointer self-stretch overflow-hidden rounded-sm bg-blue-900',
'after:absolute after:inset-0 after:bg-gradient-to-b after:from-[rgba(0,0,0,0)] after:via-[rgba(0,0,0,0.1)] after:to-[rgba(0,0,0,0.2)]',
props.class,
)}
title="Disengaged"
onMouseDown={onMouseDownOrTouchStart}
onTouchStart={(ev) => {
handledTouchStart = false
onMouseDownOrTouchStart(ev)
handledTouchStart = true
}}
onTouchMove={(ev) => {
if (ev.touches.length !== 1 || !route()) return
const rect = ref.getBoundingClientRect()
updateMarker(ev.touches[0].clientX, rect)
}}
>
<Suspense fallback={<div class="skeleton-loader size-full" />}>
<Show when={route()} keyed>
Expand All @@ -137,7 +181,14 @@ const Timeline: VoidComponent<TimelineProps> = (props) => {
<Show when={events()} keyed>
{(events) => renderTimelineEvents(route, events)}
</Show>
{renderMarker(route, props.seekTime)}
<div
class="absolute top-0 z-10 h-full"
style={{
'background-color': 'rgba(255,255,255,0.7)',
width: '3px',
left: `${markerOffsetPct()}%`,
}}
/>
</>
)}
</Show>
Expand Down
15 changes: 13 additions & 2 deletions src/pages/dashboard/activities/RouteActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const RouteActivity: VoidComponent<RouteActivityProps> = (props) => {
const [route] = createResource(routeName, getRoute)
const [startTime] = createResource(route, (route) => parseDateStr(route.start_time)?.format('ddd, MMM D, YYYY'))

let videoRef: HTMLVideoElement

function onTimelineChange(newTime: number) {
videoRef.currentTime = newTime
}

return (
<>
<TopAppBar leading={<IconButton href={`/${props.dongleId}`}>arrow_back</IconButton>}>
Expand All @@ -42,12 +48,17 @@ const RouteActivity: VoidComponent<RouteActivityProps> = (props) => {
<div class="skeleton-loader aspect-[241/151] rounded-lg bg-surface-container-low" />
}
>
<RouteVideoPlayer routeName={routeName()} onProgress={setSeekTime} />
<RouteVideoPlayer ref={ref => videoRef = ref} routeName={routeName()} onProgress={setSeekTime} />
</Suspense>

<div class="flex flex-col gap-2">
<h3 class="text-label-sm">Timeline</h3>
<Timeline class="mb-1" routeName={routeName()} seekTime={seekTime()} />
<Timeline
class="mb-1"
routeName={routeName()}
seekTime={seekTime}
updateTime={onTimelineChange}
/>
<Suspense fallback={<div class="h-10" />}>
<RouteStatistics route={route()} />
</Suspense>
Expand Down

0 comments on commit 2d69bc4

Please sign in to comment.