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