xref: /MusicFree/src/pages/musicDetail/components/content/lyric/index.tsx (revision 316c909695af8978fd54ffb10af86d9214d66fef)
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                                                        ''}
280281                                                </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