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