Skip to content

Commit

Permalink
feat: create lyrics preview in now playing modal
Browse files Browse the repository at this point in the history
  • Loading branch information
leinelissen committed Jul 25, 2024
1 parent f258b5e commit 5f20199
Show file tree
Hide file tree
Showing 10 changed files with 241 additions and 140 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ module.exports = {
{
ignoreProps: true
}
]
],
'react/react-in-jsx-scope': 'off',
},
settings: {
react: {
Expand Down
2 changes: 1 addition & 1 deletion src/assets/icons/lyrics.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 11 additions & 2 deletions src/screens/Music/overlays/NowPlaying/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { calculateProgressTranslation } from '@/components/Progresstrack';
import { NavigationProp } from '@/screens/types';
import { ShadowWrapper } from '@/components/Shadow';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

export const NOW_PLAYING_POPOVER_MARGIN = 6;
export const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
Expand Down Expand Up @@ -107,11 +108,12 @@ function SelectActionButton() {
}
}

function NowPlaying({ offset = 0 }: { offset?: number }) {
function NowPlaying({ offset = 0, inset }: { offset?: number, inset?: boolean }) {
const { index, track } = useCurrentTrack();
const { buffered, position } = useProgress();
const defaultStyles = useDefaultStyles();
const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const previousBuffered = usePrevious(buffered);
const previousPosition = usePrevious(position);

Expand Down Expand Up @@ -165,7 +167,14 @@ function NowPlaying({ offset = 0 }: { offset?: number }) {
}

return (
<Container style={{ bottom: (tabBarHeight || 0) + NOW_PLAYING_POPOVER_MARGIN + offset }}>
<Container
style={{
bottom: (tabBarHeight || 0)
+ (inset ? insets.bottom : 0)
+ NOW_PLAYING_POPOVER_MARGIN
+ offset
}}
>
{/** TODO: Fix shadow overflow on Android */}
{Platform.OS === 'ios' ? (
<ShadowOverlay pointerEvents='none'>
Expand Down
33 changes: 25 additions & 8 deletions src/screens/modals/Lyrics/components/LyricsLine.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import useDefaultStyles from '@/components/Colors';
import {StyleProp, TextStyle, ViewProps} from 'react-native';
import {LayoutChangeEvent, StyleProp, TextStyle, ViewProps} from 'react-native';
import styled from 'styled-components/native';
import Animated, { useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated';

Expand All @@ -10,41 +10,58 @@ const Container = styled(Animated.View)`

const LyricsText = styled(Animated.Text)`
flex: 1;
font-size: 20px;
font-size: 24px;
`;

export interface LyricsLineProps extends ViewProps {
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, ...viewProps }: LyricsLineProps) {
function LyricsLine({
text, start, end, position, size, onLayout, onActive, index, ...viewProps
}: LyricsLineProps) {
const defaultStyles = useDefaultStyles();

// Determine whether the current line should be active
// 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',
}), [active, defaultStyles]);
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} >
<Container {...viewProps} onLayout={handleLayout} >
<LyricsText style={[lyricsTextStyle, animatedStyle]}>
{text}
</LyricsText>
Expand Down
33 changes: 28 additions & 5 deletions src/screens/modals/Lyrics/components/LyricsProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,46 @@
import useDefaultStyles from '@/components/Colors';
import ProgressTrack, { calculateProgressTranslation, ProgressTrackContainer } from '@/components/Progresstrack';
import React, { useMemo } from 'react';
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 {
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 }: LyricsProgressProps) {
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]);

Expand Down Expand Up @@ -54,8 +72,13 @@ export default function LyricsProgress({ start, end, position }: LyricsProgressP

return (
<ProgressTrackContainer
onLayout={(e) => width.value = e.nativeEvent.layout.width}
style={[defaultStyles.trackBackground, { flexGrow: 0, marginVertical: 8 }]}
{...props}
style={[
defaultStyles.trackBackground,
{ flexGrow: 0, marginVertical: 8 },
style
]}
onLayout={handleLayout}
>
<ProgressTrack style={[progressStyles, defaultStyles.themeBackground]} />
</ProgressTrackContainer>
Expand Down
120 changes: 62 additions & 58 deletions src/screens/modals/Lyrics/components/LyricsRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
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';
Expand All @@ -8,26 +8,39 @@ 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 from './LyricsProgress';
import LyricsProgress, { LyricsProgressProps } from './LyricsProgress';

type LyricsLine = Lyrics['Lyrics'][number];

const styles = StyleSheet.create({
lyricsContainer: {
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 default function LyricsRenderer() {
export interface LyricsRendererProps {
size?: 'small' | 'full',
}

export default function LyricsRenderer({ size = 'full' }: LyricsRendererProps) {
const scrollViewRef = useRef<Animated.ScrollView>(null);
const lineLayoutsRef = useRef(new Map<LyricsLine, LayoutRectangle>());
const lineLayoutsRef = useRef(new Map<number, LayoutRectangle>());
const { position } = useProgress(100);
const { track: trackPlayerTrack } = useCurrentTrack();
const tracks = useTypedSelector((state) => state.music.tracks.entities);
Expand All @@ -36,7 +49,7 @@ export default function LyricsRenderer() {

// We will be using isUserScrolling to prevent lyrics controller scroll lyrics view
// while user is scrolling
const [isUserScrolling, setIsUserScrolling] = useState(false);
const isUserScrolling = useRef(false);

// We will be using containerHeight to make sure active lyrics line is in the center
const [containerHeight, setContainerHeight] = useState(0);
Expand All @@ -47,45 +60,28 @@ export default function LyricsRenderer() {
}, [position]);

// Handler for saving line positions
const handleLayoutChange = useCallback((line: LyricsLine, event: LayoutChangeEvent) => {
lineLayoutsRef.current.set(line, event.nativeEvent.layout);
const handleLayoutChange = useCallback((index: number, event: LayoutChangeEvent) => {
lineLayoutsRef.current.set(index, event.nativeEvent.layout);
}, []);

// Calculate current container height
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
setContainerHeight(event.nativeEvent.layout.height);
}, []);

// Handlers for user scroll handling
const handleScrollBeginDrag = useCallback(() => setIsUserScrolling(true), []);
const handleScrollEndDrag = useCallback(() => setIsUserScrolling(false), []);

const handleScrollDrag = useCallback((lineLayout: LayoutRectangle) => {
if (!containerHeight || isUserScrolling) return;
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,
y: lineLayout.y - containerHeight / 2 + lineLayout.height / 2,
animated: true,
});
}, [isUserScrolling, containerHeight]);
}, [containerHeight, isUserScrolling]);

useEffect(() => {
if (!track || scrollViewRef.current === null || !track.Lyrics) return;

const activeLine = track.Lyrics.Lyrics.reduce<LyricsLine | null>((prev, cur) => {
return currentTime >= cur.Start? cur : prev;
}, null);


if (!activeLine) return;
// Calculate current container height
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
setContainerHeight(event.nativeEvent.layout.height);
}, []);

// Attempt to retrieve the layout for the current line
const lineLayout = lineLayoutsRef.current.get(activeLine);
if (lineLayout) {
// If it exists, scroll to it
handleScrollDrag(lineLayout);
}
}, [currentTime, scrollViewRef, track, handleScrollDrag]);
// Handlers for user scroll handling
const handleScrollBeginDrag = useCallback(() => isUserScrolling.current = true, []);
const handleScrollEndDrag = useCallback(() => isUserScrolling.current = false, []);

if (!track) {
return null;
Expand All @@ -98,9 +94,12 @@ export default function LyricsRenderer() {
}

return (
<View style={{flex: 1}}>
<View style={size === 'small' && styles.containerSmall}>
<Animated.ScrollView
contentContainerStyle={styles.lyricsContainer}
contentContainerStyle={size === 'full'
? styles.lyricsContainerFull
: styles.lyricsContainerSmall
}
ref={scrollViewRef}
onLayout={handleContainerLayout}
onScrollBeginDrag={handleScrollBeginDrag}
Expand All @@ -110,32 +109,37 @@ export default function LyricsRenderer() {
start={0}
end={track.Lyrics.Lyrics[0].Start - TIME_OFFSET}
position={currentTime}
index={-1}
onActive={handleActive}
onLayout={handleLayoutChange}
/>
{track.Lyrics.Lyrics.map((lyrics, i) => (
lyrics.Text ? (
{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={lyrics.Start}
start={lyrics.Start - TIME_OFFSET}
end={track.Lyrics!.Lyrics.length === i + 1
? track.RunTimeTicks
: track.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET
}
key={`lyric_${i}`}
{...props}
text={lyrics.Text}
position={currentTime}
onLayout={(e) => handleLayoutChange(lyrics, e)}
size={size}
/>
) : (
<LyricsProgress
key={lyrics.Start}
start={lyrics.Start - TIME_OFFSET}
end={track.Lyrics!.Lyrics.length === i + 1
? track.RunTimeTicks
: track.Lyrics!.Lyrics[i + 1]?.Start - TIME_OFFSET
}
position={currentTime}
key={`lyric_${i}`}
{...props}
/>
)
))}
);
})}
</Animated.ScrollView>
</View>
);
Expand Down
2 changes: 1 addition & 1 deletion src/screens/modals/Lyrics/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function Lyrics() {
<ColoredBlurView style={{ flex: 1 }}>
{Platform.OS === 'android' && (<BackButton />)}
<LyricsRenderer />
<NowPlaying />
<NowPlaying inset />
</ColoredBlurView>
</GestureHandlerRootView>
);
Expand Down
Loading

0 comments on commit 5f20199

Please sign in to comment.