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>
|
||||
);
|
||||
}
|
||||
127
src/screens/modals/Player/components/LyricsPreview.tsx
Normal file
127
src/screens/modals/Player/components/LyricsPreview.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user