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