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