diff --git a/.eslintrc.js b/.eslintrc.js index 31e1fe9c..dc6f4c22 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/src/assets/icons/lyrics.svg b/src/assets/icons/lyrics.svg index 8081cbc0..c8301d25 100644 --- a/src/assets/icons/lyrics.svg +++ b/src/assets/icons/lyrics.svg @@ -1,3 +1,3 @@ - + diff --git a/src/screens/Music/overlays/NowPlaying/index.tsx b/src/screens/Music/overlays/NowPlaying/index.tsx index 03d5c1dc..049be62a 100644 --- a/src/screens/Music/overlays/NowPlaying/index.tsx +++ b/src/screens/Music/overlays/NowPlaying/index.tsx @@ -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; @@ -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); @@ -165,7 +167,14 @@ function NowPlaying({ offset = 0 }: { offset?: number }) { } return ( - + {/** TODO: Fix shadow overflow on Android */} {Platform.OS === 'ios' ? ( diff --git a/src/screens/modals/Lyrics/components/LyricsLine.tsx b/src/screens/modals/Lyrics/components/LyricsLine.tsx index b7f23392..144eed80 100644 --- a/src/screens/modals/Lyrics/components/LyricsLine.tsx +++ b/src/screens/modals/Lyrics/components/LyricsLine.tsx @@ -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'; @@ -10,33 +10,50 @@ 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 { 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 = 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(() => ({ @@ -44,7 +61,7 @@ function LyricsLine({ text, start, end, position, ...viewProps }: LyricsLineProp })); return ( - + {text} diff --git a/src/screens/modals/Lyrics/components/LyricsProgress.tsx b/src/screens/modals/Lyrics/components/LyricsProgress.tsx index b2f90669..bac70229 100644 --- a/src/screens/modals/Lyrics/components/LyricsProgress.tsx +++ b/src/screens/modals/Lyrics/components/LyricsProgress.tsx @@ -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 { 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]); @@ -54,8 +72,13 @@ export default function LyricsProgress({ start, end, position }: LyricsProgressP return ( width.value = e.nativeEvent.layout.width} - style={[defaultStyles.trackBackground, { flexGrow: 0, marginVertical: 8 }]} + {...props} + style={[ + defaultStyles.trackBackground, + { flexGrow: 0, marginVertical: 8 }, + style + ]} + onLayout={handleLayout} > diff --git a/src/screens/modals/Lyrics/components/LyricsRenderer.tsx b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx index 8fd53c78..adead368 100644 --- a/src/screens/modals/Lyrics/components/LyricsRenderer.tsx +++ b/src/screens/modals/Lyrics/components/LyricsRenderer.tsx @@ -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'; @@ -8,16 +8,25 @@ 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, } }); @@ -25,9 +34,13 @@ const styles = StyleSheet.create({ // 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(null); - const lineLayoutsRef = useRef(new Map()); + const lineLayoutsRef = useRef(new Map()); const { position } = useProgress(100); const { track: trackPlayerTrack } = useCurrentTrack(); const tracks = useTypedSelector((state) => state.music.tracks.entities); @@ -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); @@ -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((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; @@ -98,9 +94,12 @@ export default function LyricsRenderer() { } return ( - + - {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 ? ( handleLayoutChange(lyrics, e)} + size={size} /> ) : ( - ) - ))} + ); + })} ); diff --git a/src/screens/modals/Lyrics/index.tsx b/src/screens/modals/Lyrics/index.tsx index 5b797898..32181e2b 100644 --- a/src/screens/modals/Lyrics/index.tsx +++ b/src/screens/modals/Lyrics/index.tsx @@ -12,7 +12,7 @@ export default function Lyrics() { {Platform.OS === 'android' && ()} - + ); diff --git a/src/screens/modals/Player/components/LyricsButton.tsx b/src/screens/modals/Player/components/LyricsButton.tsx deleted file mode 100644 index e5184bee..00000000 --- a/src/screens/modals/Player/components/LyricsButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import {TouchableOpacity} from 'react-native-gesture-handler'; -import LyricsIcon from '@/assets/icons/lyrics.svg'; -import styled from 'styled-components/native'; -import { t } from '@/localisation'; -import useDefaultStyles from '@/components/Colors.tsx'; -import {useNavigation} from '@react-navigation/native'; -import {NavigationProp} from '@/screens/types.ts'; -import useCurrentTrack from '@/utility/useCurrentTrack'; - -const Container = styled.View` - align-self: flex-start; - align-items: flex-start; - margin-top: 52px; - padding: 8px; - margin-left: -8px; - flex: 0 1 auto; - border-radius: 8px; -`; - -const View = styled.View` - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -`; - - -const Label = styled.Text` - font-size: 13px; -`; - -export default function LyricsButton() { - const { albumTrack } = useCurrentTrack(); - const defaultStyles = useDefaultStyles(); - const navigation = useNavigation(); - - const handleShowLyrics = () => { - navigation.navigate('Lyrics'); - }; - - // GUARD: Hide lyrics button if the track has none - if (!albumTrack || !albumTrack.HasLyrics) { - return null; - } - - // Retrieve styles - return ( - - - - - - - - - ); -} diff --git a/src/screens/modals/Player/components/LyricsPreview.tsx b/src/screens/modals/Player/components/LyricsPreview.tsx new file mode 100644 index 00000000..31257924 --- /dev/null +++ b/src/screens/modals/Player/components/LyricsPreview.tsx @@ -0,0 +1,109 @@ +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 e2ff74c8..aeea5a8c 100644 --- a/src/screens/modals/Player/index.tsx +++ b/src/screens/modals/Player/index.tsx @@ -9,9 +9,9 @@ import StreamStatus from './components/StreamStatus'; import {Platform} from 'react-native'; import BackButton from './components/Backbutton'; import Timer from './components/Timer'; -import LyricsButton from './components/LyricsButton.tsx'; import styled from 'styled-components/native'; import { ColoredBlurView } from '@/components/Colors.tsx'; +import LyricsPreview from './components/LyricsPreview.tsx'; const Group = styled.View` flex-direction: row; @@ -32,8 +32,8 @@ export default function Player() { - + )} />