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:
Abubakr Khabebulloev
2024-07-25 20:07:23 +09:00
committed by GitHub
parent a64f52c4f9
commit c5b1406e16
22 changed files with 599 additions and 40 deletions

View File

@@ -0,0 +1,72 @@
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import useDefaultStyles from '@/components/Colors';
import {LayoutChangeEvent, StyleProp, TextStyle, ViewProps} from 'react-native';
import styled from 'styled-components/native';
import Animated, { useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated';
const Container = styled(Animated.View)`
`;
const LyricsText = styled(Animated.Text)`
flex: 1;
font-size: 24px;
`;
export interface LyricsLineProps extends Omit<ViewProps, 'onLayout'> {
text?: string;
start: number;
end: number;
position: number;
index: number;
onActive: (index: number) => void;
onLayout: (index: number, event: LayoutChangeEvent) => void;
size: 'small' | 'full';
}
/**
* A single lyric line
*/
function LyricsLine({
text, start, end, position, size, onLayout, onActive, index, ...viewProps
}: LyricsLineProps) {
const defaultStyles = useDefaultStyles();
// Pass on layout changes to the parent
const handleLayout = useCallback((e: LayoutChangeEvent) => {
onLayout?.(index, e);
}, [onLayout, index]);
// Determine whether the loader should be displayed
const active = useMemo(() => (
position > start && position < end
), [start, end, position]);
// Call the parent when the active state changes
useEffect(() => {
if (active) onActive(index);
}, [onActive, active, index]);
// Determine the current style for this line
const lyricsTextStyle: StyleProp<TextStyle> = useMemo(() => ({
color: active ? defaultStyles.themeColor.color : defaultStyles.text.color,
opacity: active ? 1 : 0.7,
transformOrigin: 'left center',
fontSize: size === 'full' ? 24 : 18,
}), [active, defaultStyles, size]);
const scale = useDerivedValue(() => withTiming(active ? 1.05 : 1));
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Container {...viewProps} onLayout={handleLayout} >
<LyricsText style={[lyricsTextStyle, animatedStyle]}>
{text}
</LyricsText>
</Container>
);
}
export default memo(LyricsLine);

View File

@@ -0,0 +1,86 @@
import useDefaultStyles from '@/components/Colors';
import ProgressTrack, { calculateProgressTranslation, ProgressTrackContainer } from '@/components/Progresstrack';
import React, { useCallback, useEffect, useMemo } from 'react';
import { LayoutChangeEvent } from 'react-native';
import { useDerivedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { ViewProps } from 'react-native-svg/lib/typescript/fabric/utils';
export interface LyricsProgressProps extends Omit<ViewProps, 'onLayout'> {
start: number;
end: number;
position: number;
index: number;
onActive: (index: number) => void;
onLayout: (index: number, event: LayoutChangeEvent) => void;
}
/**
* Displays a loading bar when there is a silence in the lyrics.
*/
export default function LyricsProgress({
start, end, position, index, onLayout, onActive, style, ...props
}: LyricsProgressProps) {
const defaultStyles = useDefaultStyles();
// Keep a reference to the width of the container
const width = useSharedValue(0);
// Pass on layout changes to the parent
const handleLayout = useCallback((e: LayoutChangeEvent) => {
onLayout?.(index, e);
width.value = e.nativeEvent.layout.width;
}, [onLayout, index, width]);
// Determine whether the loader should be displayed
const active = useMemo(() => (
position > start && position < end
), [start, end, position]);
// Call the parent when the active state changes
useEffect(() => {
if (active) onActive(index);
}, [onActive, active, index]);
// Determine the duration of the progress bar
const duration = useMemo(() => (end - start), [end, start]);
// Calculate the progress animation
const progressAnimation = useDerivedValue(() => {
// GUARD: If the animatino is not active, hide the progress bar
if (!active) return -width.value;
// Calculate how far along we are
const progress = calculateProgressTranslation(position - start, end - start, width.value);
// Move to that position with easing
return withTiming(progress, { duration: 200 });
});
// Calculate the styles according to the progress
const progressStyles = useAnimatedStyle(() => {
return {
transform: [
{ translateX: progressAnimation.value }
]
};
});
// GUARD: Only show durations if they last for more than 5 seconds.
if (duration < 5e7) {
return null;
}
return (
<ProgressTrackContainer
{...props}
style={[
defaultStyles.trackBackground,
{ flexGrow: 0, marginVertical: 8 },
style
]}
onLayout={handleLayout}
>
<ProgressTrack style={[progressStyles, defaultStyles.themeBackground]} />
</ProgressTrackContainer>
);
}

View 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>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import LyricsRenderer from './components/LyricsRenderer';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Platform } from 'react-native';
import BackButton from '../Player/components/Backbutton';
import { ColoredBlurView } from '@/components/Colors';
import NowPlaying from '@/screens/Music/overlays/NowPlaying';
export default function Lyrics() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ColoredBlurView style={{ flex: 1 }}>
{Platform.OS === 'android' && (<BackButton />)}
<LyricsRenderer />
<NowPlaying inset />
</ColoredBlurView>
</GestureHandlerRootView>
);
}