this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}