diff --git a/.eslintrc.js b/.eslintrc.js
index 31e1fe9..dc6f4c2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -58,7 +58,8 @@ module.exports = {
{
ignoreProps: true
}
- ]
+ ],
+ 'react/react-in-jsx-scope': 'off',
},
settings: {
react: {
diff --git a/ios/Fintunes.xcodeproj/project.pbxproj b/ios/Fintunes.xcodeproj/project.pbxproj
index 9b43a01..da15070 100644
--- a/ios/Fintunes.xcodeproj/project.pbxproj
+++ b/ios/Fintunes.xcodeproj/project.pbxproj
@@ -887,4 +887,4 @@
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
-}
+}
\ No newline at end of file
diff --git a/src/assets/icons/lyrics.svg b/src/assets/icons/lyrics.svg
new file mode 100644
index 0000000..c8301d2
--- /dev/null
+++ b/src/assets/icons/lyrics.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/Colors.tsx b/src/components/Colors.tsx
index 7f4eb31..f7b559a 100644
--- a/src/components/Colors.tsx
+++ b/src/components/Colors.tsx
@@ -62,6 +62,9 @@ function generateStyles(scheme: ColorSchemeName, highContrast: boolean) {
backgroundColor: scheme === 'dark' ? '#191919' : '#f3f3f3',
color: scheme === 'dark' ? '#fff' : '#000',
},
+ trackBackground: {
+ backgroundColor: scheme === 'dark' ? '#111' : '#fff',
+ },
stackHeader: {
color: scheme === 'dark' ? 'white' : 'black'
},
diff --git a/src/components/Progresstrack.tsx b/src/components/Progresstrack.tsx
index 090f1e5..0b9f46e 100644
--- a/src/components/Progresstrack.tsx
+++ b/src/components/Progresstrack.tsx
@@ -29,6 +29,7 @@ export function calculateProgressTranslation(
return output;
}
+// Progress track did not show up on Lyrics screen if min height is not set
export const ProgressTrackContainer = styled.View`
overflow: hidden;
height: 5px;
@@ -37,6 +38,7 @@ export const ProgressTrackContainer = styled.View`
align-items: center;
position: relative;
border-radius: 6px;
+ min-height: 5px;
`;
export interface ProgressTrackProps {
diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json
index 727c397..085293f 100644
--- a/src/localisation/lang/en/locale.json
+++ b/src/localisation/lang/en/locale.json
@@ -75,5 +75,6 @@
"sleep-timer": "Sleep timer",
"delete": "Delete",
"cancel": "Cancel",
- "disc": "Disc"
+ "disc": "Disc",
+ "lyrics": "Lyrics"
}
diff --git a/src/localisation/types.ts b/src/localisation/types.ts
index 5e46cd3..9fbf55a 100644
--- a/src/localisation/types.ts
+++ b/src/localisation/types.ts
@@ -74,4 +74,5 @@ export type LocaleKeys = 'play-next'
| 'sleep-timer'
| 'delete'
| 'cancel'
-| 'disc'
\ No newline at end of file
+| 'disc'
+| 'lyrics';
\ No newline at end of file
diff --git a/src/screens/Music/overlays/NowPlaying/index.tsx b/src/screens/Music/overlays/NowPlaying/index.tsx
index 56623b9..049be62 100644
--- a/src/screens/Music/overlays/NowPlaying/index.tsx
+++ b/src/screens/Music/overlays/NowPlaying/index.tsx
@@ -17,9 +17,11 @@ 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';
-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_MARGIN = 6;
+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`
position: absolute;
@@ -34,6 +36,7 @@ const Container = styled.ScrollView`
`;
const InnerContainer = styled.TouchableOpacity`
+ height: ${NOW_PLAYING_POPOVER_HEIGHT}px;
padding: 12px;
overflow: hidden;
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 { buffered, position } = useProgress();
const defaultStyles = useDefaultStyles();
const tabBarHeight = useBottomTabBarHeight();
+ const insets = useSafeAreaInsets();
const previousBuffered = usePrevious(buffered);
const previousPosition = usePrevious(position);
@@ -163,7 +167,14 @@ function NowPlaying({ offset = 0 }: { offset?: number }) {
}
return (
-
+
{/** TODO: Fix shadow overflow on Android */}
{Platform.OS === 'ios' ? (
diff --git a/src/screens/index.tsx b/src/screens/index.tsx
index c38fea3..1a86898 100644
--- a/src/screens/index.tsx
+++ b/src/screens/index.tsx
@@ -24,6 +24,7 @@ import ErrorReportingAlert from '@/utility/ErrorReportingAlert';
import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
import Player from './modals/Player';
import { StackParams } from './types';
+import Lyrics from './modals/Lyrics';
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
@@ -36,7 +37,7 @@ type Screens = {
function Screens() {
const styles = useDefaultStyles();
const isOnboardingComplete = useTypedSelector(state => state.settings.isOnboardingComplete);
-
+
// GUARD: If onboarding has not been completed, we instead render the
// onboarding component, so that the user can get setup in the app.
if (!isOnboardingComplete) {
@@ -91,12 +92,16 @@ export default function Routes() {
+
);
}
@@ -104,4 +109,4 @@ export default function Routes() {
export type NavigationProp = CompositeNavigationProp<
StackNavigationProp,
BottomTabNavigationProp
->;
\ No newline at end of file
+>;
diff --git a/src/screens/modals/Lyrics/components/LyricsLine.tsx b/src/screens/modals/Lyrics/components/LyricsLine.tsx
new file mode 100644
index 0000000..144eed8
--- /dev/null
+++ b/src/screens/modals/Lyrics/components/LyricsLine.tsx
@@ -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 {
+ 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 = 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 (
+
+
+ {text}
+
+
+ );
+}
+
+export default memo(LyricsLine);
diff --git a/src/screens/modals/Lyrics/components/LyricsProgress.tsx b/src/screens/modals/Lyrics/components/LyricsProgress.tsx
new file mode 100644
index 0000000..bac7022
--- /dev/null
+++ b/src/screens/modals/Lyrics/components/LyricsProgress.tsx
@@ -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 {
+ 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 (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/screens/modals/Lyrics/components/LyricsRenderer.tsx b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx
new file mode 100644
index 0000000..adead36
--- /dev/null
+++ b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx
@@ -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(null);
+ const lineLayoutsRef = useRef(new Map());
+ 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 (
+
+
+
+ {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 ? (
+
+ ) : (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/screens/modals/Lyrics/index.tsx b/src/screens/modals/Lyrics/index.tsx
new file mode 100644
index 0000000..32181e2
--- /dev/null
+++ b/src/screens/modals/Lyrics/index.tsx
@@ -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 (
+
+
+ {Platform.OS === 'android' && ()}
+
+
+
+
+ );
+}
diff --git a/src/screens/modals/Player/components/LyricsPreview.tsx b/src/screens/modals/Player/components/LyricsPreview.tsx
new file mode 100644
index 0000000..938e12c
--- /dev/null
+++ b/src/screens/modals/Player/components/LyricsPreview.tsx
@@ -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();
+ const [width, setWidth] = useState(0);
+
+ const handleLayoutChange = useCallback((e: LayoutChangeEvent) => {
+ setWidth(e.nativeEvent.layout.width);
+ }, []);
+
+ const handleShowLyrics = useCallback(() => {
+ navigation.navigate('Lyrics');
+ }, [navigation]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/screens/modals/Player/index.tsx b/src/screens/modals/Player/index.tsx
index 63a584b..aeea5a8 100644
--- a/src/screens/modals/Player/index.tsx
+++ b/src/screens/modals/Player/index.tsx
@@ -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 (
- {Platform.OS === 'android' && ()}
-
-
-
-
-
-
-
- >
- )} />
+
+ {Platform.OS === 'android' && ()}
+
+
+
+
+
+
+
+
+
+
+ >
+ )} />
+
);
-}
\ No newline at end of file
+}
diff --git a/src/screens/types.ts b/src/screens/types.ts
index 4b429df..702f5e8 100644
--- a/src/screens/types.ts
+++ b/src/screens/types.ts
@@ -14,6 +14,7 @@ export type StackParams = {
Search: undefined;
SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string };
+ Lyrics: undefined;
};
-export type NavigationProp = StackNavigationProp;
+export type NavigationProp = StackNavigationProp;
diff --git a/src/store/music/types.ts b/src/store/music/types.ts
index 89ff567..99a683c 100644
--- a/src/store/music/types.ts
+++ b/src/store/music/types.ts
@@ -1,3 +1,5 @@
+import {Lyrics} from '@/utility/JellyfinApi/lyrics.ts';
+
export interface UserData {
PlaybackPositionTicks: number;
PlayCount: number;
@@ -67,6 +69,8 @@ export interface AlbumTrack {
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
+ HasLyrics: boolean;
+ Lyrics: Lyrics | null;
}
export interface State {
@@ -99,4 +103,4 @@ export interface Playlist {
export interface SimilarAlbum {
Id: string;
-}
\ No newline at end of file
+}
diff --git a/src/utility/JellyfinApi/album.ts b/src/utility/JellyfinApi/album.ts
index 1542016..11e9754 100644
--- a/src/utility/JellyfinApi/album.ts
+++ b/src/utility/JellyfinApi/album.ts
@@ -1,5 +1,6 @@
import { Album, AlbumTrack, SimilarAlbum } from '@/store/music/types';
import { fetchApi } from './lib';
+import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const albumOptions = {
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) {
// Generate custom config based on function input
@@ -64,5 +65,5 @@ export async function retrieveAlbumTracks(ItemId: string) {
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
- .then((data) => data!.Items);
-}
\ No newline at end of file
+ .then((data) => retrieveAndInjectLyricsToTracks(data.Items));
+}
diff --git a/src/utility/JellyfinApi/lyrics.ts b/src/utility/JellyfinApi/lyrics.ts
new file mode 100644
index 0000000..839078f
--- /dev/null
+++ b/src/utility/JellyfinApi/lyrics.ts
@@ -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 {
+ return fetchApi(`/Audio/${trackId}/Lyrics`)
+ .catch((e) => {
+ console.error('Error on fetching track lyrics: ', e);
+ return null;
+ });
+}
+
+
+export async function retrieveAndInjectLyricsToTracks(tracks: AlbumTrack[]): Promise {
+ return Promise.all(tracks.map(async (track) => {
+ if (!track.HasLyrics) {
+ track.Lyrics = null;
+ return track;
+ }
+
+ track.Lyrics = await retrieveTrackLyrics(track.Id);
+
+ return track;
+
+ }));
+}
diff --git a/src/utility/JellyfinApi/playlist.ts b/src/utility/JellyfinApi/playlist.ts
index 27c1a51..a055614 100644
--- a/src/utility/JellyfinApi/playlist.ts
+++ b/src/utility/JellyfinApi/playlist.ts
@@ -1,5 +1,6 @@
import { AlbumTrack, Playlist } from '@/store/music/types';
import { asyncFetchStore, fetchApi } from './lib';
+import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics.ts';
const playlistOptions = {
SortBy: 'SortName',
@@ -17,7 +18,7 @@ const playlistOptions = {
*/
export async function retrieveAllPlaylists() {
const playlistParams = new URLSearchParams(playlistOptions).toString();
-
+
return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`)
.then((d) => d!.Items);
}
@@ -34,5 +35,5 @@ export async function retrievePlaylistTracks(ItemId: string) {
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
- .then((d) => d!.Items);
-}
\ No newline at end of file
+ .then((d) => retrieveAndInjectLyricsToTracks(d.Items));
+}
diff --git a/src/utility/JellyfinApi/track.ts b/src/utility/JellyfinApi/track.ts
index 0c153d2..73f05bb 100644
--- a/src/utility/JellyfinApi/track.ts
+++ b/src/utility/JellyfinApi/track.ts
@@ -3,6 +3,7 @@ import { Platform } from 'react-native';
import { Track } from 'react-native-track-player';
import { fetchApi, getImage } from './lib';
import store from '@/store';
+import {retrieveAndInjectLyricsToTracks} from '@/utility/JellyfinApi/lyrics';
const trackOptionsOsOverrides: Record> = {
ios: {
@@ -60,6 +61,8 @@ export function generateTrack(track: AlbumTrack): Track {
artwork: track.AlbumId
? getImage(track.AlbumId)
: getImage(track.Id),
+ hasLyrics: track.HasLyrics,
+ lyrics: track.Lyrics,
};
}
@@ -77,5 +80,5 @@ const trackParams = {
*/
export async function retrieveAllTracks() {
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
- .then((d) => d!.Items);
+ .then((d) => retrieveAndInjectLyricsToTracks(d.Items));
}
diff --git a/src/utility/useCurrentTrack.ts b/src/utility/useCurrentTrack.ts
index 24cf379..703eb74 100644
--- a/src/utility/useCurrentTrack.ts
+++ b/src/utility/useCurrentTrack.ts
@@ -1,8 +1,11 @@
-import { useCallback, useEffect, useState } from 'react';
-import TrackPlayer, { Event, Track, useTrackPlayerEvents } from 'react-native-track-player';
+import { useTypedSelector } from '@/store';
+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 {
track: Track | undefined;
+ albumTrack: AlbumTrack | undefined;
index: number | undefined;
}
@@ -13,12 +16,20 @@ export default function useCurrentTrack(): CurrentTrackResponse {
const [track, setTrack] = useState