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
72
src/screens/modals/Lyrics/components/LyricsLine.tsx
Normal file
72
src/screens/modals/Lyrics/components/LyricsLine.tsx
Normal 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);
|
||||
86
src/screens/modals/Lyrics/components/LyricsProgress.tsx
Normal file
86
src/screens/modals/Lyrics/components/LyricsProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
19
src/screens/modals/Lyrics/index.tsx
Normal file
19
src/screens/modals/Lyrics/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user