this repo has no description
1import {useCallback, useEffect, useRef, useState} from 'react'
2import {View} from 'react-native'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5
6import {clamp} from '#/lib/numbers'
7import {atoms as a, useTheme, web} from '#/alf'
8import {useInteractionState} from '#/components/hooks/useInteractionState'
9import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env'
10import {formatTime} from './utils'
11
12export function Scrubber({
13 duration,
14 currentTime,
15 onSeek,
16 onSeekEnd,
17 onSeekStart,
18 seekLeft,
19 seekRight,
20 togglePlayPause,
21 drawFocus,
22}: {
23 duration: number
24 currentTime: number
25 onSeek: (time: number) => void
26 onSeekEnd: () => void
27 onSeekStart: () => void
28 seekLeft: () => void
29 seekRight: () => void
30 togglePlayPause: () => void
31 drawFocus: () => void
32}) {
33 const {_} = useLingui()
34 const t = useTheme()
35 const [scrubberActive, setScrubberActive] = useState(false)
36 const {
37 state: hovered,
38 onIn: onStartHover,
39 onOut: onEndHover,
40 } = useInteractionState()
41 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
42 const [seekPosition, setSeekPosition] = useState(0)
43 const isSeekingRef = useRef(false)
44 const barRef = useRef<HTMLDivElement>(null)
45 const circleRef = useRef<HTMLDivElement>(null)
46
47 const seek = useCallback(
48 (evt: React.PointerEvent<HTMLDivElement>) => {
49 if (!barRef.current) return
50 const {left, width} = barRef.current.getBoundingClientRect()
51 const x = evt.clientX
52 const percent = clamp((x - left) / width, 0, 1) * duration
53 onSeek(percent)
54 setSeekPosition(percent)
55 },
56 [duration, onSeek],
57 )
58
59 const onPointerDown = useCallback(
60 (evt: React.PointerEvent<HTMLDivElement>) => {
61 const target = evt.target
62 if (target instanceof Element) {
63 evt.preventDefault()
64 target.setPointerCapture(evt.pointerId)
65 isSeekingRef.current = true
66 seek(evt)
67 setScrubberActive(true)
68 onSeekStart()
69 }
70 },
71 [seek, onSeekStart],
72 )
73
74 const onPointerMove = useCallback(
75 (evt: React.PointerEvent<HTMLDivElement>) => {
76 if (isSeekingRef.current) {
77 evt.preventDefault()
78 seek(evt)
79 }
80 },
81 [seek],
82 )
83
84 const onPointerUp = useCallback(
85 (evt: React.PointerEvent<HTMLDivElement>) => {
86 const target = evt.target
87 if (isSeekingRef.current && target instanceof Element) {
88 evt.preventDefault()
89 target.releasePointerCapture(evt.pointerId)
90 isSeekingRef.current = false
91 onSeekEnd()
92 setScrubberActive(false)
93 }
94 },
95 [onSeekEnd],
96 )
97
98 useEffect(() => {
99 // HACK: there's divergent browser behaviour about what to do when
100 // a pointerUp event is fired outside the element that captured the
101 // pointer. Firefox clicks on the element the mouse is over, so we have
102 // to make everything unclickable while seeking -sfn
103 if (IS_WEB_FIREFOX && scrubberActive) {
104 document.body.classList.add('force-no-clicks')
105
106 return () => {
107 document.body.classList.remove('force-no-clicks')
108 }
109 }
110 }, [scrubberActive, onSeekEnd])
111
112 useEffect(() => {
113 if (!circleRef.current) return
114 if (focused) {
115 const abortController = new AbortController()
116 const {signal} = abortController
117 circleRef.current.addEventListener(
118 'keydown',
119 evt => {
120 // space: play/pause
121 // arrow left: seek backward
122 // arrow right: seek forward
123
124 if (evt.key === ' ') {
125 evt.preventDefault()
126 drawFocus()
127 togglePlayPause()
128 } else if (evt.key === 'ArrowLeft') {
129 evt.preventDefault()
130 drawFocus()
131 seekLeft()
132 } else if (evt.key === 'ArrowRight') {
133 evt.preventDefault()
134 drawFocus()
135 seekRight()
136 }
137 },
138 {signal},
139 )
140
141 return () => abortController.abort()
142 }
143 }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
144
145 const progress = scrubberActive ? seekPosition : currentTime
146 const progressPercent = (progress / duration) * 100
147
148 if (duration < 3) return null
149
150 return (
151 <View
152 testID="scrubber"
153 style={[
154 {height: IS_WEB_TOUCH_DEVICE ? 32 : 18, width: '100%'},
155 a.flex_shrink_0,
156 a.px_xs,
157 ]}
158 onPointerEnter={onStartHover}
159 onPointerLeave={onEndHover}>
160 <div
161 ref={barRef}
162 style={{
163 flex: 1,
164 display: 'flex',
165 alignItems: 'center',
166 position: 'relative',
167 cursor: scrubberActive ? 'grabbing' : 'grab',
168 padding: '4px 0',
169 }}
170 onPointerDown={onPointerDown}
171 onPointerMove={onPointerMove}
172 onPointerUp={onPointerUp}
173 onPointerCancel={onPointerUp}>
174 <View
175 style={[
176 a.w_full,
177 a.rounded_full,
178 a.overflow_hidden,
179 {backgroundColor: 'rgba(255, 255, 255, 0.4)'},
180 {height: hovered || scrubberActive ? 6 : 3},
181 web({transition: 'height 0.1s ease'}),
182 ]}>
183 {duration > 0 && (
184 <View
185 style={[
186 a.h_full,
187 {backgroundColor: t.palette.white},
188 {width: `${progressPercent}%`},
189 ]}
190 />
191 )}
192 </View>
193 <div
194 ref={circleRef}
195 aria-label={_(
196 msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`,
197 )}
198 role="slider"
199 aria-valuemax={duration}
200 aria-valuemin={0}
201 aria-valuenow={currentTime}
202 aria-valuetext={_(
203 msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
204 )}
205 tabIndex={0}
206 onFocus={onFocus}
207 onBlur={onBlur}
208 style={{
209 position: 'absolute',
210 height: 16,
211 width: 16,
212 left: `calc(${progressPercent}% - 8px)`,
213 borderRadius: 8,
214 pointerEvents: 'none',
215 }}>
216 <View
217 style={[
218 a.w_full,
219 a.h_full,
220 a.rounded_full,
221 {backgroundColor: t.palette.white},
222 {
223 transform: [
224 {
225 scale:
226 hovered || scrubberActive || focused
227 ? scrubberActive
228 ? 1
229 : 0.6
230 : 0,
231 },
232 ],
233 },
234 ]}
235 />
236 </div>
237 </div>
238 </View>
239 )
240}