1import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; 2import {LayoutRectangle, StyleSheet, Text, View} from 'react-native'; 3import rpx from '@/utils/rpx'; 4import useDelayFalsy from '@/hooks/useDelayFalsy'; 5import { 6 FlatList, 7 Gesture, 8 GestureDetector, 9 TapGestureHandler, 10} from 'react-native-gesture-handler'; 11import {fontSizeConst} from '@/constants/uiConst'; 12import Loading from '@/components/base/loading'; 13import globalStyle from '@/constants/globalStyle'; 14import {showPanel} from '@/components/panels/usePanel'; 15import LyricManager from '@/core/lyricManager'; 16import TrackPlayer from '@/core/trackPlayer'; 17import {musicIsPaused} from '@/utils/trackUtils'; 18import delay from '@/utils/delay'; 19import DraggingTime from './draggingTime'; 20import LyricItemComponent from './lyricItem'; 21import PersistStatus from '@/core/persistStatus'; 22import LyricOperations from './lyricOperations'; 23import MediaExtra from '@/core/mediaExtra'; 24import {IParsedLrcItem} from '@/utils/lrcParser'; 25import {IconButtonWithGesture} from '@/components/base/iconButton.tsx'; 26 27const ITEM_HEIGHT = rpx(92); 28 29interface IItemHeights { 30 blankHeight?: number; 31 [k: number]: number; 32} 33 34interface IProps { 35 onTurnPageClick?: () => void; 36} 37 38const fontSizeMap = { 39 0: rpx(24), 40 1: rpx(30), 41 2: rpx(36), 42 3: rpx(42), 43} as Record<number, number>; 44 45export default function Lyric(props: IProps) { 46 const {onTurnPageClick} = props; 47 48 const {loading, meta, lyrics, hasTranslation} = 49 LyricManager.useLyricState(); 50 const currentLrcItem = LyricManager.useCurrentLyric(); 51 const showTranslation = PersistStatus.useValue( 52 'lyric.showTranslation', 53 false, 54 ); 55 const fontSizeKey = PersistStatus.useValue('lyric.detailFontSize', 1); 56 const fontSizeStyle = useMemo( 57 () => ({ 58 fontSize: fontSizeMap[fontSizeKey!], 59 }), 60 [fontSizeKey], 61 ); 62 63 const [draggingIndex, setDraggingIndex, setDraggingIndexImmi] = 64 useDelayFalsy<number | undefined>(undefined, 2000); 65 const musicState = TrackPlayer.useMusicState(); 66 67 const [layout, setLayout] = useState<LayoutRectangle>(); 68 69 const listRef = useRef<FlatList<IParsedLrcItem> | null>(); 70 71 const currentMusicItem = TrackPlayer.useCurrentMusic(); 72 const associateMusicItem = currentMusicItem 73 ? MediaExtra.get(currentMusicItem)?.associatedLrc 74 : null; 75 // 是否展示拖拽 76 const dragShownRef = useRef(false); 77 78 // 组件是否挂载 79 const isMountedRef = useRef(true); 80 81 // 用来缓存高度 82 const itemHeightsRef = useRef<IItemHeights>({}); 83 84 // 设置空白组件,获取组件高度 85 const blankComponent = useMemo(() => { 86 return ( 87 <View 88 style={styles.empty} 89 onLayout={evt => { 90 itemHeightsRef.current.blankHeight = 91 evt.nativeEvent.layout.height; 92 }} 93 /> 94 ); 95 }, []); 96 97 const handleLyricItemLayout = useCallback( 98 (index: number, height: number) => { 99 itemHeightsRef.current[index] = height; 100 }, 101 [], 102 ); 103 104 // 滚到当前item 105 const scrollToCurrentLrcItem = useCallback(() => { 106 if (!listRef.current) { 107 return; 108 } 109 const currentLrcItem = LyricManager.getCurrentLyric(); 110 const lyrics = LyricManager.getLyricState().lyrics; 111 if (currentLrcItem?.index === -1 || !currentLrcItem) { 112 listRef.current?.scrollToIndex({ 113 index: 0, 114 viewPosition: 0.5, 115 }); 116 } else { 117 listRef.current?.scrollToIndex({ 118 index: Math.min(currentLrcItem.index ?? 0, lyrics.length - 1), 119 viewPosition: 0.5, 120 }); 121 } 122 }, []); 123 124 const delayedScrollToCurrentLrcItem = useMemo(() => { 125 let sto: number; 126 127 return () => { 128 if (sto) { 129 clearTimeout(sto); 130 } 131 sto = setTimeout(() => { 132 if (isMountedRef.current) { 133 scrollToCurrentLrcItem(); 134 } 135 }, 200) as any; 136 }; 137 }, []); 138 139 useEffect(() => { 140 // 暂停且拖拽才返回 141 if ( 142 lyrics.length === 0 || 143 draggingIndex !== undefined || 144 (draggingIndex === undefined && musicIsPaused(musicState)) || 145 lyrics[lyrics.length - 1].time < 1 146 ) { 147 return; 148 } 149 if (currentLrcItem?.index === -1 || !currentLrcItem) { 150 listRef.current?.scrollToIndex({ 151 index: 0, 152 viewPosition: 0.5, 153 }); 154 } else { 155 listRef.current?.scrollToIndex({ 156 index: Math.min(currentLrcItem.index ?? 0, lyrics.length - 1), 157 viewPosition: 0.5, 158 }); 159 } 160 // 音乐暂停状态不应该影响到滑动,所以不放在依赖里,但是这样写不好。。 161 }, [currentLrcItem, lyrics, draggingIndex]); 162 163 useEffect(() => { 164 scrollToCurrentLrcItem(); 165 return () => { 166 isMountedRef.current = false; 167 }; 168 }, []); 169 170 // 开始滚动时拖拽生效 171 const onScrollBeginDrag = () => { 172 dragShownRef.current = true; 173 }; 174 175 const onScrollEndDrag = async () => { 176 if (draggingIndex !== undefined) { 177 setDraggingIndex(undefined); 178 } 179 dragShownRef.current = false; 180 }; 181 182 const onScroll = (e: any) => { 183 if (dragShownRef.current) { 184 const offset = 185 e.nativeEvent.contentOffset.y + 186 e.nativeEvent.layoutMeasurement.height / 2; 187 188 const itemHeights = itemHeightsRef.current; 189 let height = itemHeights.blankHeight!; 190 if (offset <= height) { 191 setDraggingIndex(0); 192 return; 193 } 194 for (let i = 0; i < lyrics.length; ++i) { 195 height += itemHeights[i] ?? 0; 196 if (height > offset) { 197 setDraggingIndex(i); 198 return; 199 } 200 } 201 } 202 }; 203 204 const onLyricSeekPress = async () => { 205 if (draggingIndex !== undefined) { 206 const time = lyrics[draggingIndex].time + +(meta?.offset ?? 0); 207 if (time !== undefined && !isNaN(time)) { 208 await TrackPlayer.seekTo(time); 209 await TrackPlayer.play(); 210 setDraggingIndexImmi(undefined); 211 } 212 } 213 }; 214 215 const tapGesture = Gesture.Tap() 216 .onStart(() => { 217 onTurnPageClick?.(); 218 }) 219 .runOnJS(true); 220 221 const unlinkTapGesture = Gesture.Tap() 222 .onStart(() => { 223 if (currentMusicItem) { 224 MediaExtra.update(currentMusicItem, { 225 associatedLrc: undefined, 226 }); 227 LyricManager.refreshLyric(false, true); 228 } 229 }) 230 .runOnJS(true); 231 232 return ( 233 <> 234 <GestureDetector gesture={tapGesture}> 235 <View style={globalStyle.fwflex1}> 236 {loading ? ( 237 <Loading color="white" /> 238 ) : lyrics?.length ? ( 239 <FlatList 240 ref={_ => { 241 listRef.current = _; 242 }} 243 onLayout={e => { 244 setLayout(e.nativeEvent.layout); 245 }} 246 viewabilityConfig={{ 247 itemVisiblePercentThreshold: 100, 248 }} 249 onScrollToIndexFailed={({index}) => { 250 delay(120).then(() => { 251 listRef.current?.scrollToIndex({ 252 index: Math.min( 253 index ?? 0, 254 lyrics.length - 1, 255 ), 256 viewPosition: 0.5, 257 }); 258 }); 259 }} 260 fadingEdgeLength={120} 261 ListHeaderComponent={ 262 <> 263 {blankComponent} 264 <View style={styles.lyricMeta}> 265 {associateMusicItem ? ( 266 <> 267 <Text 268 style={[ 269 styles.lyricMetaText, 270 fontSizeStyle, 271 ]} 272 ellipsizeMode="middle" 273 numberOfLines={1}> 274 歌词关联自「 275 { 276 associateMusicItem.platform 277 }{' '} 278 -{' '} 279 {associateMusicItem.title || 280 ''} 281 」 282 </Text> 283 284 <GestureDetector 285 gesture={unlinkTapGesture}> 286 <Text 287 style={[ 288 styles.linkText, 289 fontSizeStyle, 290 ]}> 291 解除关联 292 </Text> 293 </GestureDetector> 294 </> 295 ) : null} 296 </View> 297 </> 298 } 299 ListFooterComponent={blankComponent} 300 onScrollBeginDrag={onScrollBeginDrag} 301 onMomentumScrollEnd={onScrollEndDrag} 302 onScroll={onScroll} 303 scrollEventThrottle={32} 304 style={styles.wrapper} 305 data={lyrics} 306 initialNumToRender={30} 307 overScrollMode="never" 308 extraData={currentLrcItem} 309 renderItem={({item, index}) => { 310 let text = item.lrc; 311 if (showTranslation && hasTranslation) { 312 text += `\n${item?.translation ?? ''}`; 313 } 314 315 return ( 316 <LyricItemComponent 317 index={index} 318 text={text} 319 fontSize={fontSizeStyle.fontSize} 320 onLayout={handleLyricItemLayout} 321 light={draggingIndex === index} 322 highlight={ 323 currentLrcItem?.index === index 324 } 325 /> 326 ); 327 }} 328 /> 329 ) : ( 330 <View style={globalStyle.fullCenter}> 331 <Text style={[styles.white, fontSizeStyle]}> 332 暂无歌词 333 </Text> 334 <TapGestureHandler 335 onActivated={() => { 336 showPanel('SearchLrc', { 337 musicItem: 338 TrackPlayer.getCurrentMusic(), 339 }); 340 }}> 341 <Text 342 style={[styles.searchLyric, fontSizeStyle]}> 343 搜索歌词 344 </Text> 345 </TapGestureHandler> 346 </View> 347 )} 348 {draggingIndex !== undefined && ( 349 <View 350 style={[ 351 styles.draggingTime, 352 layout?.height 353 ? { 354 top: 355 (layout.height - ITEM_HEIGHT) / 2, 356 } 357 : null, 358 ]}> 359 <DraggingTime 360 time={ 361 (lyrics[draggingIndex]?.time ?? 0) + 362 +(meta?.offset ?? 0) 363 } 364 /> 365 <View style={styles.singleLine} /> 366 367 <IconButtonWithGesture 368 style={styles.playIcon} 369 sizeType="small" 370 name="play" 371 onPress={onLyricSeekPress} 372 /> 373 </View> 374 )} 375 </View> 376 </GestureDetector> 377 <LyricOperations 378 scrollToCurrentLrcItem={delayedScrollToCurrentLrcItem} 379 /> 380 </> 381 ); 382} 383 384const styles = StyleSheet.create({ 385 wrapper: { 386 width: '100%', 387 marginVertical: rpx(48), 388 flex: 1, 389 }, 390 empty: { 391 paddingTop: '70%', 392 }, 393 white: { 394 color: 'white', 395 }, 396 lyricMeta: { 397 position: 'absolute', 398 width: '100%', 399 flexDirection: 'row', 400 justifyContent: 'center', 401 alignItems: 'center', 402 left: 0, 403 paddingHorizontal: rpx(48), 404 bottom: rpx(48), 405 }, 406 lyricMetaText: { 407 color: 'white', 408 opacity: 0.8, 409 maxWidth: '80%', 410 }, 411 linkText: { 412 color: '#66ccff', 413 textDecorationLine: 'underline', 414 }, 415 draggingTime: { 416 position: 'absolute', 417 width: '100%', 418 height: ITEM_HEIGHT, 419 top: '40%', 420 marginTop: rpx(48), 421 paddingHorizontal: rpx(18), 422 right: 0, 423 flexDirection: 'row', 424 alignItems: 'center', 425 justifyContent: 'space-between', 426 }, 427 draggingTimeText: { 428 color: '#dddddd', 429 fontSize: fontSizeConst.description, 430 width: rpx(90), 431 }, 432 singleLine: { 433 width: '67%', 434 height: 1, 435 backgroundColor: '#cccccc', 436 opacity: 0.4, 437 }, 438 playIcon: { 439 width: rpx(90), 440 textAlign: 'right', 441 color: 'white', 442 }, 443 searchLyric: { 444 width: rpx(180), 445 marginTop: rpx(14), 446 paddingVertical: rpx(10), 447 textAlign: 'center', 448 alignSelf: 'center', 449 color: '#66eeff', 450 textDecorationLine: 'underline', 451 }, 452}); 453