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(); const [index, setIndex] = useState(); + // 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 const retrieveCurrentTrack = useCallback(async () => { const queue = await TrackPlayer.getQueue(); const currentTrackIndex = await TrackPlayer.getCurrentTrack(); if (currentTrackIndex !== null) { - setTrack(queue[currentTrackIndex]); + setTrack(queue[currentTrackIndex] as Track); setIndex(currentTrackIndex); } else { setTrack(undefined); @@ -28,7 +39,7 @@ export default function useCurrentTrack(): CurrentTrackResponse { // Then execute the function on component mount and track changes useEffect(() => { retrieveCurrentTrack(); }, [retrieveCurrentTrack]); - useTrackPlayerEvents([ Event.PlaybackTrackChanged, Event.PlaybackState ], retrieveCurrentTrack); - - return { track, index }; -} \ No newline at end of file + useTrackPlayerEvents([ Event.PlaybackActiveTrackChanged, Event.PlaybackState ], retrieveCurrentTrack); + + return { track, index, albumTrack }; +}