Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 242 lines 6.9 kB view raw
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}