Lyrics implementation prototype (#224)
* Lyrics implementation prototype * feat: update lyrics view * chore: add docs * chore: cleanup * feat: animate active text * fix: hide lyrics button when there are none * feat: create lyrics preview in now playing modal * fix: header overlay color Closes #224 Closes #151 Closes #100 --------- Co-authored-by: Lei Nelissen <lei@codified.nl>
This commit is contained in:
committed by
GitHub
parent
a64f52c4f9
commit
c5b1406e16
146
src/screens/modals/Lyrics/components/LyricsRenderer.tsx
Normal file
146
src/screens/modals/Lyrics/components/LyricsRenderer.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react';
|
||||
import { LayoutChangeEvent, LayoutRectangle, StyleSheet, View } from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { Lyrics } from '@/utility/JellyfinApi/lyrics';
|
||||
import { useProgress } from 'react-native-track-player';
|
||||
import useCurrentTrack from '@/utility/useCurrentTrack';
|
||||
import LyricsLine from './LyricsLine';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useTypedSelector } from '@/store';
|
||||
import { NOW_PLAYING_POPOVER_HEIGHT } from '@/screens/Music/overlays/NowPlaying';
|
||||
import LyricsProgress, { LyricsProgressProps } from './LyricsProgress';
|
||||
|
||||
type LyricsLine = Lyrics['Lyrics'][number];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
lyricsContainerFull: {
|
||||
padding: 40,
|
||||
paddingBottom: 40 + NOW_PLAYING_POPOVER_HEIGHT,
|
||||
gap: 12,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
lyricsContainerSmall: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 80,
|
||||
gap: 8,
|
||||
},
|
||||
containerSmall: {
|
||||
maxHeight: 160,
|
||||
flex: 1,
|
||||
}
|
||||
});
|
||||
|
||||
// Always hit the changes this amount of microseconds early so that it appears
|
||||
// to follow the track a bit more accurate.
|
||||
const TIME_OFFSET = 2e6;
|
||||
|
||||
export interface LyricsRendererProps {
|
||||
size?: 'small' | 'full',
|
||||
}
|
||||
|
||||
export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) {
|
||||
const scrollViewRef = useRef<Animated.ScrollView>(null);
|
||||
const lineLayoutsRef = useRef(new Map<number, LayoutRectangle>());
|
||||
const { position } = useProgress(100);
|
||||
const { track: trackPlayerTrack } = useCurrentTrack();
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const track = useMemo(() => tracks[trackPlayerTrack?.backendId], [trackPlayerTrack?.backendId, tracks]);
|
||||
const navigation = useNavigation();
|
||||
|
||||
// We will be using isUserScrolling to prevent lyrics controller scroll lyrics view
|
||||
// while user is scrolling
|
||||
const isUserScrolling = useRef(false);
|
||||
|
||||
// We will be using containerHeight to make sure active lyrics line is in the center
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
|
||||
// Calculate current ime
|
||||
const currentTime = useMemo(() => {
|
||||
return position * 10_000_000;
|
||||
}, [position]);
|
||||
|
||||
// Handler for saving line positions
|
||||
const handleLayoutChange = useCallback((index: number, event: LayoutChangeEvent) => {
|
||||
lineLayoutsRef.current.set(index, event.nativeEvent.layout);
|
||||
}, []);
|
||||
|
||||
const handleActive = useCallback((index: number) => {
|
||||
const lineLayout = lineLayoutsRef.current.get(index);
|
||||
if (!containerHeight || isUserScrolling.current || !lineLayout) return;
|
||||
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: lineLayout.y - containerHeight / 2 + lineLayout.height / 2,
|
||||
animated: true,
|
||||
});
|
||||
}, [containerHeight, isUserScrolling]);
|
||||
|
||||
// Calculate current container height
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setContainerHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
// Handlers for user scroll handling
|
||||
const handleScrollBeginDrag = useCallback(() => isUserScrolling.current = true, []);
|
||||
const handleScrollEndDrag = useCallback(() => isUserScrolling.current = false, []);
|
||||
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GUARD: If the track has no lyrics, close the modal
|
||||
if (!track.HasLyrics || !track.Lyrics) {
|
||||
navigation.goBack();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={size === 'small' && styles.containerSmall}>
|
||||
<Animated.ScrollView
|
||||
contentContainerStyle={size === 'full'
|
||||
? styles.lyricsContainerFull
|
||||
: styles.lyricsContainerSmall
|
||||
}
|
||||
ref={scrollViewRef}
|
||||
onLayout={handleContainerLayout}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
>
|
||||
<LyricsProgress
|
||||
start={0}
|
||||
end={track.Lyrics.Lyrics[0].Start - TIME_OFFSET}
|
||||
position={currentTime}
|
||||
index={-1}
|
||||
onActive={handleActive}
|
||||
onLayout={handleLayoutChange}
|
||||
/>
|
||||
{track.Lyrics.Lyrics.map((lyrics, i) => {
|
||||
const props: LyricsProgressProps = {
|
||||
start: lyrics.Start - TIME_OFFSET,
|
||||
end: track.Lyrics!.Lyrics.length === i + 1
|
||||
? track.RunTimeTicks
|
||||
: track.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET
|
||||
,
|
||||
position: currentTime,
|
||||
onLayout: handleLayoutChange,
|
||||
onActive: handleActive,
|
||||
index: i,
|
||||
};
|
||||
|
||||
return lyrics.Text ? (
|
||||
<LyricsLine
|
||||
key={`lyric_${i}`}
|
||||
{...props}
|
||||
text={lyrics.Text}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<LyricsProgress
|
||||
key={`lyric_${i}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user