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

View File

@@ -0,0 +1,127 @@
import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
import useCurrentTrack from '@/utility/useCurrentTrack';
import styled from 'styled-components/native';
import LyricsIcon from '@/assets/icons/lyrics.svg';
import { t } from '@/localisation';
import LyricsRenderer from '../../Lyrics/components/LyricsRenderer';
import { useNavigation } from '@react-navigation/native';
import { useCallback, useState } from 'react';
import { NavigationProp } from '@/screens/types';
import { LayoutChangeEvent } from 'react-native';
import { Defs, LinearGradient, Rect, Stop, Svg } from 'react-native-svg';
const Container = styled.TouchableOpacity`
border-radius: 8px;
margin-top: 24px;
margin-left: -16px;
margin-right: -16px;
position: relative;
overflow: hidden;
`;
const Header = styled.View`
position: absolute;
left: 8px;
top: 8px;
z-index: 3;
border-radius: 4px;
overflow: hidden;
`;
const HeaderInnerContainer = styled(ColoredBlurView)`
padding: 8px;
flex-direction: row;
gap: 8px;
`;
const Label = styled.Text`
`;
const HeaderBackground = styled.View`
position: absolute;
left: 0;
right: 0;
top: 0;
height: 60px;
z-index: 2;
background-color: transparent;
`;
function InnerLyricsPreview() {
const defaultStyles = useDefaultStyles();
const navigation = useNavigation<NavigationProp>();
const [width, setWidth] = useState(0);
const handleLayoutChange = useCallback((e: LayoutChangeEvent) => {
setWidth(e.nativeEvent.layout.width);
}, []);
const handleShowLyrics = useCallback(() => {
navigation.navigate('Lyrics');
}, [navigation]);
return (
<Container
style={defaultStyles.trackBackground}
onPress={handleShowLyrics}
onLayout={handleLayoutChange}
>
<Header style={defaultStyles.activeBackground}>
<HeaderInnerContainer>
<LyricsIcon fill={defaultStyles.themeColor.color} />
<Label style={defaultStyles.themeColor}>
{t('lyrics')}
</Label>
</HeaderInnerContainer>
</Header>
<HeaderBackground>
<Svg width={width} height={60} viewBox={`0 0 ${width} 60`}>
<Defs>
<LinearGradient
id="lyrics-label-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<Stop
offset="0"
stopColor={defaultStyles.trackBackground.backgroundColor}
stopOpacity={1}
/>
<Stop
offset="0.75"
stopColor={defaultStyles.trackBackground.backgroundColor}
stopOpacity={0.7}
/>
<Stop
offset="1"
stopColor={defaultStyles.trackBackground.backgroundColor}
stopOpacity={0}
/>
</LinearGradient>
</Defs>
<Rect x={0} y={0} height={60} width={width} fill="url(#lyrics-label-gradient)" />
</Svg>
</HeaderBackground>
<LyricsRenderer size="small" />
</Container>
);
}
/**
* A wrapper for LyricsPreview, so we only render the component if the current
* track has lyrics.
*/
export default function LyricsPreview() {
const { albumTrack } = useCurrentTrack();
if (!albumTrack?.HasLyrics) {
return null;
}
return (
<InnerLyricsPreview />
);
}

View File

@@ -6,24 +6,37 @@ import Queue from './components/Queue';
import ConnectionNotice from './components/ConnectionNotice';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import StreamStatus from './components/StreamStatus';
import { Platform } from 'react-native';
import {Platform} from 'react-native';
import BackButton from './components/Backbutton';
import Timer from './components/Timer';
import styled from 'styled-components/native';
import { ColoredBlurView } from '@/components/Colors.tsx';
import LyricsPreview from './components/LyricsPreview.tsx';
export default function Player() {
const Group = styled.View`
flex-direction: row;
justify-content: space-between;
`;
export default function Player() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{Platform.OS === 'android' && (<BackButton />)}
<Queue header={(
<>
<NowPlaying />
<ConnectionNotice />
<StreamStatus />
<ProgressBar />
<MediaControls />
<Timer />
</>
)} />
<ColoredBlurView>
{Platform.OS === 'android' && (<BackButton />)}
<Queue header={(
<>
<NowPlaying />
<ConnectionNotice />
<StreamStatus />
<ProgressBar />
<MediaControls />
<Group>
<Timer />
</Group>
<LyricsPreview />
</>
)} />
</ColoredBlurView>
</GestureHandlerRootView>
);
}
}