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() {
-
+
>
)} />