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

@@ -58,7 +58,8 @@ module.exports = {
{ {
ignoreProps: true ignoreProps: true
} }
] ],
'react/react-in-jsx-scope': 'off',
}, },
settings: { settings: {
react: { react: {

View File

@@ -887,4 +887,4 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
} }

View File

@@ -0,0 +1,3 @@
<svg width="17" height="15" viewBox="0 0 17 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.314163 14.9808V3.01292C0.314163 2.68584 0.428955 2.40744 0.658538 2.17771C0.88826 1.94813 1.16659 1.83334 1.49354 1.83334H9.46791C9.795 1.83334 10.0733 1.94813 10.3029 2.17771C10.5326 2.40744 10.6475 2.68584 10.6475 3.01292V3.41021C10.4989 3.51605 10.3717 3.63598 10.266 3.77001C10.1602 3.90417 10.0651 4.05084 9.98083 4.21001V3.01292C9.98083 2.86334 9.93271 2.74042 9.83646 2.64417C9.74034 2.54806 9.6175 2.50001 9.46791 2.50001H1.49354C1.34395 2.50001 1.22111 2.54806 1.125 2.64417C1.02889 2.74042 0.98083 2.86334 0.98083 3.01292V12.5H9.46791C9.6175 12.5 9.74034 12.4519 9.83646 12.3558C9.93271 12.2596 9.98083 12.1367 9.98083 11.9871V8.79001C10.0651 8.94917 10.1633 9.09653 10.2752 9.23209C10.387 9.36764 10.5111 9.48688 10.6475 9.5898V11.9871C10.6475 12.3142 10.5326 12.5926 10.3029 12.8223C10.0733 13.0519 9.795 13.1667 9.46791 13.1667H2.12812L0.314163 14.9808ZM2.8975 10.5833H5.06416V9.91667H2.8975V10.5833ZM12.6796 8.58334C12.1026 8.58334 11.6112 8.38035 11.2052 7.97438C10.7992 7.56841 10.5962 7.07695 10.5962 6.50001C10.5962 5.92306 10.7992 5.4316 11.2052 5.02563C11.6112 4.61966 12.1026 4.41667 12.6796 4.41667C12.936 4.41667 13.1611 4.45806 13.355 4.54084C13.5489 4.62362 13.796 4.76917 14.0962 4.9775V0.416672H16.2629V1.08334H14.7629V6.50001C14.7629 7.07695 14.5599 7.56841 14.1537 7.97438C13.7478 8.38035 13.2564 8.58334 12.6796 8.58334ZM2.8975 7.83334H8.06416V7.16667H2.8975V7.83334ZM2.8975 5.08334H8.06416V4.41667H2.8975V5.08334Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -62,6 +62,9 @@ function generateStyles(scheme: ColorSchemeName, highContrast: boolean) {
backgroundColor: scheme === 'dark' ? '#191919' : '#f3f3f3', backgroundColor: scheme === 'dark' ? '#191919' : '#f3f3f3',
color: scheme === 'dark' ? '#fff' : '#000', color: scheme === 'dark' ? '#fff' : '#000',
}, },
trackBackground: {
backgroundColor: scheme === 'dark' ? '#111' : '#fff',
},
stackHeader: { stackHeader: {
color: scheme === 'dark' ? 'white' : 'black' color: scheme === 'dark' ? 'white' : 'black'
}, },

View File

@@ -29,6 +29,7 @@ export function calculateProgressTranslation(
return output; return output;
} }
// Progress track did not show up on Lyrics screen if min height is not set
export const ProgressTrackContainer = styled.View` export const ProgressTrackContainer = styled.View`
overflow: hidden; overflow: hidden;
height: 5px; height: 5px;
@@ -37,6 +38,7 @@ export const ProgressTrackContainer = styled.View`
align-items: center; align-items: center;
position: relative; position: relative;
border-radius: 6px; border-radius: 6px;
min-height: 5px;
`; `;
export interface ProgressTrackProps { export interface ProgressTrackProps {

View File

@@ -75,5 +75,6 @@
"sleep-timer": "Sleep timer", "sleep-timer": "Sleep timer",
"delete": "Delete", "delete": "Delete",
"cancel": "Cancel", "cancel": "Cancel",
"disc": "Disc" "disc": "Disc",
"lyrics": "Lyrics"
} }

View File

@@ -74,4 +74,5 @@ export type LocaleKeys = 'play-next'
| 'sleep-timer' | 'sleep-timer'
| 'delete' | 'delete'
| 'cancel' | 'cancel'
| 'disc' | 'disc'
| 'lyrics';

View File

@@ -17,9 +17,11 @@ import { calculateProgressTranslation } from '@/components/Progresstrack';
import { NavigationProp } from '@/screens/types'; import { NavigationProp } from '@/screens/types';
import { ShadowWrapper } from '@/components/Shadow'; import { ShadowWrapper } from '@/components/Shadow';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const NOW_PLAYING_POPOVER_MARGIN = 6; export const NOW_PLAYING_POPOVER_MARGIN = 6;
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN; export const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
export const NOW_PLAYING_POPOVER_HEIGHT = 58;
const PopoverPosition = css` const PopoverPosition = css`
position: absolute; position: absolute;
@@ -34,6 +36,7 @@ const Container = styled.ScrollView`
`; `;
const InnerContainer = styled.TouchableOpacity` const InnerContainer = styled.TouchableOpacity`
height: ${NOW_PLAYING_POPOVER_HEIGHT}px;
padding: 12px; padding: 12px;
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
@@ -105,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 { index, track } = useCurrentTrack();
const { buffered, position } = useProgress(); const { buffered, position } = useProgress();
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const previousBuffered = usePrevious(buffered); const previousBuffered = usePrevious(buffered);
const previousPosition = usePrevious(position); const previousPosition = usePrevious(position);
@@ -163,7 +167,14 @@ function NowPlaying({ offset = 0 }: { offset?: number }) {
} }
return ( return (
<Container style={{ bottom: tabBarHeight + 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 */} {/** TODO: Fix shadow overflow on Android */}
{Platform.OS === 'ios' ? ( {Platform.OS === 'ios' ? (
<ShadowOverlay pointerEvents='none'> <ShadowOverlay pointerEvents='none'>

View File

@@ -24,6 +24,7 @@ import ErrorReportingAlert from '@/utility/ErrorReportingAlert';
import useDefaultStyles, { ColoredBlurView } from '@/components/Colors'; import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
import Player from './modals/Player'; import Player from './modals/Player';
import { StackParams } from './types'; import { StackParams } from './types';
import Lyrics from './modals/Lyrics';
const Stack = createNativeStackNavigator<StackParams>(); const Stack = createNativeStackNavigator<StackParams>();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
@@ -36,7 +37,7 @@ type Screens = {
function Screens() { function Screens() {
const styles = useDefaultStyles(); const styles = useDefaultStyles();
const isOnboardingComplete = useTypedSelector(state => state.settings.isOnboardingComplete); const isOnboardingComplete = useTypedSelector(state => state.settings.isOnboardingComplete);
// GUARD: If onboarding has not been completed, we instead render the // GUARD: If onboarding has not been completed, we instead render the
// onboarding component, so that the user can get setup in the app. // onboarding component, so that the user can get setup in the app.
if (!isOnboardingComplete) { if (!isOnboardingComplete) {
@@ -91,12 +92,16 @@ export default function Routes() {
<Stack.Navigator screenOptions={{ <Stack.Navigator screenOptions={{
presentation: 'modal', presentation: 'modal',
headerShown: false, headerShown: false,
contentStyle: {
backgroundColor: 'transparent'
}
}} id="MAIN"> }} id="MAIN">
<Stack.Screen name="Screens" component={Screens} /> <Stack.Screen name="Screens" component={Screens} />
<Stack.Screen name="SetJellyfinServer" component={SetJellyfinServer} /> <Stack.Screen name="SetJellyfinServer" component={SetJellyfinServer} />
<Stack.Screen name="TrackPopupMenu" component={TrackPopupMenu} options={{ presentation: 'formSheet' }} /> <Stack.Screen name="TrackPopupMenu" component={TrackPopupMenu} options={{ presentation: 'formSheet' }} />
<Stack.Screen name="ErrorReporting" component={ErrorReportingPopup} /> <Stack.Screen name="ErrorReporting" component={ErrorReportingPopup} />
<Stack.Screen name="Player" component={Player} /> <Stack.Screen name="Player" component={Player} />
<Stack.Screen name="Lyrics" component={Lyrics} />
</Stack.Navigator> </Stack.Navigator>
); );
} }
@@ -104,4 +109,4 @@ export default function Routes() {
export type NavigationProp = CompositeNavigationProp< export type NavigationProp = CompositeNavigationProp<
StackNavigationProp<Routes>, StackNavigationProp<Routes>,
BottomTabNavigationProp<Screens> BottomTabNavigationProp<Screens>
>; >;

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 ConnectionNotice from './components/ConnectionNotice';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import StreamStatus from './components/StreamStatus'; import StreamStatus from './components/StreamStatus';
import { Platform } from 'react-native'; import {Platform} from 'react-native';
import BackButton from './components/Backbutton'; import BackButton from './components/Backbutton';
import Timer from './components/Timer'; 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 ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
{Platform.OS === 'android' && (<BackButton />)} <ColoredBlurView>
<Queue header={( {Platform.OS === 'android' && (<BackButton />)}
<> <Queue header={(
<NowPlaying /> <>
<ConnectionNotice /> <NowPlaying />
<StreamStatus /> <ConnectionNotice />
<ProgressBar /> <StreamStatus />
<MediaControls /> <ProgressBar />
<Timer /> <MediaControls />
</> <Group>
)} /> <Timer />
</Group>
<LyricsPreview />
</>
)} />
</ColoredBlurView>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }

View File

@@ -14,6 +14,7 @@ export type StackParams = {
Search: undefined; Search: undefined;
SetJellyfinServer: undefined; SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string }; TrackPopupMenu: { trackId: string };
Lyrics: undefined;
}; };
export type NavigationProp = StackNavigationProp<StackParams>; export type NavigationProp = StackNavigationProp<StackParams>;

View File

@@ -1,3 +1,5 @@
import {Lyrics} from '@/utility/JellyfinApi/lyrics.ts';
export interface UserData { export interface UserData {
PlaybackPositionTicks: number; PlaybackPositionTicks: number;
PlayCount: number; PlayCount: number;
@@ -67,6 +69,8 @@ export interface AlbumTrack {
BackdropImageTags: any[]; BackdropImageTags: any[];
LocationType: string; LocationType: string;
MediaType: string; MediaType: string;
HasLyrics: boolean;
Lyrics: Lyrics | null;
} }
export interface State { export interface State {
@@ -99,4 +103,4 @@ export interface Playlist {
export interface SimilarAlbum { export interface SimilarAlbum {
Id: string; Id: string;
} }

View File

@@ -1,5 +1,6 @@
import { Album, AlbumTrack, SimilarAlbum } from '@/store/music/types'; import { Album, AlbumTrack, SimilarAlbum } from '@/store/music/types';
import { fetchApi } from './lib'; import { fetchApi } from './lib';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const albumOptions = { const albumOptions = {
SortBy: 'AlbumArtist,SortName', SortBy: 'AlbumArtist,SortName',
@@ -39,7 +40,7 @@ const latestAlbumsOptions = {
}; };
/** /**
* Retrieve the most recently added albums on the Jellyfin server * Retrieve the most recently added albums on the Jellyfin server
*/ */
export async function retrieveRecentAlbums(numberOfAlbums = 24) { export async function retrieveRecentAlbums(numberOfAlbums = 24) {
// Generate custom config based on function input // Generate custom config based on function input
@@ -64,5 +65,5 @@ export async function retrieveAlbumTracks(ItemId: string) {
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString(); const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`) return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
.then((data) => data!.Items); .then((data) => retrieveAndInjectLyricsToTracks(data.Items));
} }

View File

@@ -0,0 +1,48 @@
import { fetchApi } from './lib';
import {AlbumTrack} from '@/store/music/types.ts';
interface Metadata {
Artist: string
Album: string
Title: string
Author: string
Length: number
By: string
Offset: number
Creator: string
Version: string
IsSynced: boolean
}
interface LyricData {
Text: string
Start: number
}
export interface Lyrics {
Metadata: Metadata
Lyrics: LyricData[]
}
async function retrieveTrackLyrics(trackId: string): Promise<Lyrics | null> {
return fetchApi<Lyrics>(`/Audio/${trackId}/Lyrics`)
.catch((e) => {
console.error('Error on fetching track lyrics: ', e);
return null;
});
}
export async function retrieveAndInjectLyricsToTracks(tracks: AlbumTrack[]): Promise<AlbumTrack[]> {
return Promise.all(tracks.map(async (track) => {
if (!track.HasLyrics) {
track.Lyrics = null;
return track;
}
track.Lyrics = await retrieveTrackLyrics(track.Id);
return track;
}));
}

View File

@@ -1,5 +1,6 @@
import { AlbumTrack, Playlist } from '@/store/music/types'; import { AlbumTrack, Playlist } from '@/store/music/types';
import { asyncFetchStore, fetchApi } from './lib'; import { asyncFetchStore, fetchApi } from './lib';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const playlistOptions = { const playlistOptions = {
SortBy: 'SortName', SortBy: 'SortName',
@@ -17,7 +18,7 @@ const playlistOptions = {
*/ */
export async function retrieveAllPlaylists() { export async function retrieveAllPlaylists() {
const playlistParams = new URLSearchParams(playlistOptions).toString(); const playlistParams = new URLSearchParams(playlistOptions).toString();
return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`) return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`)
.then((d) => d!.Items); .then((d) => d!.Items);
} }
@@ -34,5 +35,5 @@ export async function retrievePlaylistTracks(ItemId: string) {
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString(); const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`) return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
.then((d) => d!.Items); .then((d) => retrieveAndInjectLyricsToTracks(d.Items));
} }

View File

@@ -3,6 +3,7 @@ import { Platform } from 'react-native';
import { Track } from 'react-native-track-player'; import { Track } from 'react-native-track-player';
import { fetchApi, getImage } from './lib'; import { fetchApi, getImage } from './lib';
import store from '@/store'; import store from '@/store';
import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics';
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = { const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
ios: { ios: {
@@ -60,6 +61,8 @@ export function generateTrack(track: AlbumTrack): Track {
artwork: track.AlbumId artwork: track.AlbumId
? getImage(track.AlbumId) ? getImage(track.AlbumId)
: getImage(track.Id), : getImage(track.Id),
hasLyrics: track.HasLyrics,
lyrics: track.Lyrics,
}; };
} }
@@ -77,5 +80,5 @@ const trackParams = {
*/ */
export async function retrieveAllTracks() { export async function retrieveAllTracks() {
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`) return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
.then((d) => d!.Items); .then((d) => retrieveAndInjectLyricsToTracks(d.Items));
} }

View File

@@ -1,8 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useTypedSelector } from '@/store';
import TrackPlayer, { Event, Track, useTrackPlayerEvents } from 'react-native-track-player'; import { AlbumTrack } from '@/store/music/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
import TrackPlayer, { Event, useTrackPlayerEvents, Track } from 'react-native-track-player';
interface CurrentTrackResponse { interface CurrentTrackResponse {
track: Track | undefined; track: Track | undefined;
albumTrack: AlbumTrack | undefined;
index: number | undefined; index: number | undefined;
} }
@@ -13,12 +16,20 @@ export default function useCurrentTrack(): CurrentTrackResponse {
const [track, setTrack] = useState<Track | undefined>(); const [track, setTrack] = useState<Track | undefined>();
const [index, setIndex] = useState<number | undefined>(); const [index, setIndex] = useState<number | undefined>();
// Retrieve entities from the store
const entities = useTypedSelector((state) => state.music.tracks.entities);
// Attempt to extract the track from the store
const albumTrack = useMemo(() => (
entities[track?.backendId]
), [track?.backendId, entities]);
// Retrieve the current track from the queue using the index // Retrieve the current track from the queue using the index
const retrieveCurrentTrack = useCallback(async () => { const retrieveCurrentTrack = useCallback(async () => {
const queue = await TrackPlayer.getQueue(); const queue = await TrackPlayer.getQueue();
const currentTrackIndex = await TrackPlayer.getCurrentTrack(); const currentTrackIndex = await TrackPlayer.getCurrentTrack();
if (currentTrackIndex !== null) { if (currentTrackIndex !== null) {
setTrack(queue[currentTrackIndex]); setTrack(queue[currentTrackIndex] as Track);
setIndex(currentTrackIndex); setIndex(currentTrackIndex);
} else { } else {
setTrack(undefined); setTrack(undefined);
@@ -28,7 +39,7 @@ export default function useCurrentTrack(): CurrentTrackResponse {
// Then execute the function on component mount and track changes // Then execute the function on component mount and track changes
useEffect(() => { retrieveCurrentTrack(); }, [retrieveCurrentTrack]); useEffect(() => { retrieveCurrentTrack(); }, [retrieveCurrentTrack]);
useTrackPlayerEvents([ Event.PlaybackTrackChanged, Event.PlaybackState ], retrieveCurrentTrack); useTrackPlayerEvents([ Event.PlaybackActiveTrackChanged, Event.PlaybackState ], retrieveCurrentTrack);
return { track, index }; return { track, index, albumTrack };
} }