diff --git a/src/components/Global/components/downloaded-icon.tsx b/src/components/Global/components/downloaded-icon.tsx index 1a430e0d..ed44d5f7 100644 --- a/src/components/Global/components/downloaded-icon.tsx +++ b/src/components/Global/components/downloaded-icon.tsx @@ -1,10 +1,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import Icon from './icon' import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' -import { memo } from 'react' import { useIsDownloaded } from '../../../api/queries/download' -function DownloadedIcon({ item }: { item: BaseItemDto }) { +export default function DownloadedIcon({ item }: { item: BaseItemDto }) { const isDownloaded = useIsDownloaded([item.Id]) return isDownloaded ? ( @@ -19,9 +18,3 @@ function DownloadedIcon({ item }: { item: BaseItemDto }) { <> ) } - -// Memoize the component to prevent unnecessary re-renders -export default memo(DownloadedIcon, (prevProps, nextProps) => { - // Only re-render if the item ID changes - return prevProps.item.Id === nextProps.item.Id -}) diff --git a/src/components/Global/components/favorite-icon.tsx b/src/components/Global/components/favorite-icon.tsx index edd4b76e..91ddd3fd 100644 --- a/src/components/Global/components/favorite-icon.tsx +++ b/src/components/Global/components/favorite-icon.tsx @@ -1,6 +1,5 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import Icon from './icon' -import { memo } from 'react' import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' import { useIsFavorite } from '../../../api/queries/user-data' @@ -11,7 +10,7 @@ import { useIsFavorite } from '../../../api/queries/user-data' * @param item - The item to display the favorite icon for. * @returns A React component that displays a favorite icon for a given item. */ -function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element { +export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element { const { data: isFavorite } = useIsFavorite(item) return isFavorite ? ( @@ -26,12 +25,3 @@ function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element { <> ) } - -// Memoize the component to prevent unnecessary re-renders -export default memo(FavoriteIcon, (prevProps, nextProps) => { - // Only re-render if the item ID changes or if the initial favorite state changes - return ( - prevProps.item.Id === nextProps.item.Id && - prevProps.item.UserData?.IsFavorite === nextProps.item.UserData?.IsFavorite - ) -}) diff --git a/src/components/Player/components/blurred-background.tsx b/src/components/Player/components/blurred-background.tsx index 9dd4d541..2d33e637 100644 --- a/src/components/Player/components/blurred-background.tsx +++ b/src/components/Player/components/blurred-background.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react' +import React from 'react' import { getToken, useTheme, View, YStack, ZStack } from 'tamagui' import { useColorScheme } from 'react-native' import LinearGradient from 'react-native-linear-gradient' @@ -8,7 +8,7 @@ import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated' import { useThemeSetting } from '../../../stores/settings/app' import { useCurrentTrack } from '../../../stores/player/queue' -function BlurredBackground({ +export default function BlurredBackground({ width, height, }: { @@ -107,9 +107,3 @@ function BlurredBackground({ ) } - -// Memoize the component to prevent unnecessary re-renders -export default memo(BlurredBackground, (prevProps, nextProps) => { - // Only re-render if dimensions change - return prevProps.width === nextProps.width && prevProps.height === nextProps.height -}) diff --git a/src/components/Player/components/lyrics.tsx b/src/components/Player/components/lyrics.tsx index 34516f45..4e249173 100644 --- a/src/components/Player/components/lyrics.tsx +++ b/src/components/Player/components/lyrics.tsx @@ -7,7 +7,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useProgress } from '../../../providers/Player/hooks/queries' import { useSeekTo } from '../../../providers/Player/hooks/mutations' import { UPDATE_INTERVAL } from '../../../configs/player.config' -import React, { useEffect, useMemo, useRef, useCallback } from 'react' +import React, { useEffect, useRef } from 'react' import Animated, { useSharedValue, useAnimatedStyle, @@ -36,132 +36,130 @@ interface ParsedLyricLine { const AnimatedText = Animated.createAnimatedComponent(Text) const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) -// Memoized lyric line component for better performance -const LyricLineItem = React.memo( - ({ - item, - index, - currentLineIndex, - onPress, - }: { - item: ParsedLyricLine - index: number - currentLineIndex: SharedValue - onPress: (startTime: number, index: number) => void - }) => { - const theme = useTheme() +// Lyric line component +function LyricLineItem({ + item, + index, + currentLineIndex, + onPress, +}: { + item: ParsedLyricLine + index: number + currentLineIndex: SharedValue + onPress: (startTime: number, index: number) => void +}) { + const theme = useTheme() - // Get theme-aware colors - const primaryColor = theme.color.val // Primary text color (adapts to dark/light) - const neutralColor = theme.neutral.val // Secondary text color - const highlightColor = theme.primary.val // Highlight color (primaryDark/primaryLight) - const translucentColor = theme.translucent?.val // Theme-aware translucent background - const backgroundHighlight = translucentColor || theme.primary.val + '15' // Fallback with 15% opacity + // Get theme-aware colors + const primaryColor = theme.color.val // Primary text color (adapts to dark/light) + const neutralColor = theme.neutral.val // Secondary text color + const highlightColor = theme.primary.val // Highlight color (primaryDark/primaryLight) + const translucentColor = theme.translucent?.val // Theme-aware translucent background + const backgroundHighlight = translucentColor || theme.primary.val + '15' // Fallback with 15% opacity - const animatedStyle = useAnimatedStyle(() => { - const isActive = Math.abs(currentLineIndex.value - index) < 0.5 - const isPast = currentLineIndex.value > index - const distance = Math.abs(currentLineIndex.value - index) + const animatedStyle = useAnimatedStyle(() => { + const isActive = Math.abs(currentLineIndex.value - index) < 0.5 + const isPast = currentLineIndex.value > index + const distance = Math.abs(currentLineIndex.value - index) - return { - opacity: withSpring(isActive ? 1 : distance < 2 ? 0.8 : isPast ? 0.4 : 0.6, { - damping: 20, - stiffness: 300, - }), - transform: [ - { - scale: withSpring(isActive ? 1.05 : 1, { - damping: 20, - stiffness: 300, - }), - }, - { - translateY: withSpring(isActive ? -4 : 0, { - damping: 20, - stiffness: 300, - }), - }, - ], - } - }) + return { + opacity: withSpring(isActive ? 1 : distance < 2 ? 0.8 : isPast ? 0.4 : 0.6, { + damping: 20, + stiffness: 300, + }), + transform: [ + { + scale: withSpring(isActive ? 1.05 : 1, { + damping: 20, + stiffness: 300, + }), + }, + { + translateY: withSpring(isActive ? -4 : 0, { + damping: 20, + stiffness: 300, + }), + }, + ], + } + }) - const backgroundStyle = useAnimatedStyle(() => { - const isActive = Math.abs(currentLineIndex.value - index) < 0.5 + const backgroundStyle = useAnimatedStyle(() => { + const isActive = Math.abs(currentLineIndex.value - index) < 0.5 - return { - backgroundColor: interpolateColor( - isActive ? 1 : 0, - [0, 1], - ['transparent', backgroundHighlight], // subtle theme-aware glow for active - ), - borderRadius: withSpring(isActive ? 12 : 8, { - damping: 20, - stiffness: 300, - }), - } - }) + return { + backgroundColor: interpolateColor( + isActive ? 1 : 0, + [0, 1], + ['transparent', backgroundHighlight], // subtle theme-aware glow for active + ), + borderRadius: withSpring(isActive ? 12 : 8, { + damping: 20, + stiffness: 300, + }), + } + }) - const textColorStyle = useAnimatedStyle(() => { - const isActive = Math.abs(currentLineIndex.value - index) < 0.5 - const isPast = currentLineIndex.value > index + const textColorStyle = useAnimatedStyle(() => { + const isActive = Math.abs(currentLineIndex.value - index) < 0.5 + const isPast = currentLineIndex.value > index - return { - color: interpolateColor( - isActive ? 1 : 0, - [0, 1], - [isPast ? neutralColor : primaryColor, highlightColor], // theme-aware colors - ), - fontWeight: isActive ? '600' : '500', - } - }) + return { + color: interpolateColor( + isActive ? 1 : 0, + [0, 1], + [isPast ? neutralColor : primaryColor, highlightColor], // theme-aware colors + ), + fontWeight: isActive ? '600' : '500', + } + }) - const handlePress = useCallback(() => { - onPress(item.startTime, index) - }, [item.startTime, index, onPress]) + const handlePress = () => { + onPress(item.startTime, index) + } - return ( + return ( + - - - {item.text} - - + {item.text} + - ) - }, -) + + ) +} LyricLineItem.displayName = 'LyricLineItem' @@ -182,7 +180,7 @@ export default function Lyrics({ const isUserScrolling = useSharedValue(false) // Convert lyrics from ticks to seconds and parse - const parsedLyrics = useMemo(() => { + const parsedLyrics: ParsedLyricLine[] = (() => { if (!lyrics) return [] try { @@ -199,19 +197,16 @@ export default function Lyrics({ console.error('Error parsing lyrics:', error) return [] } - }, [lyrics]) + })() - const lyricStartTimes = useMemo( - () => parsedLyrics.map((line) => line.startTime), - [parsedLyrics], - ) + const lyricStartTimes = parsedLyrics.map((line) => line.startTime) // Track manually selected lyric for immediate feedback const manuallySelectedIndex = useSharedValue(-1) const manualSelectTimeout = useRef(null) // Find current lyric line based on playback position - const currentLyricIndex = useMemo(() => { + const currentLyricIndex = (() => { if (position === null || position === undefined || lyricStartTimes.length === 0) return -1 // Binary search to find the last startTime <= position @@ -230,10 +225,10 @@ export default function Lyrics({ } return found - }, [position, lyricStartTimes]) + })() // Simple auto-scroll that keeps highlighted lyric in center - const scrollToCurrentLyric = useCallback(() => { + const scrollToCurrentLyric = () => { if ( currentLyricIndex >= 0 && currentLyricIndex < parsedLyrics.length && @@ -262,7 +257,7 @@ export default function Lyrics({ }) } } - }, [currentLyricIndex, parsedLyrics.length, height]) + } useEffect(() => { // Only update if there's no manual selection active @@ -322,94 +317,80 @@ export default function Lyrics({ }, []) // Scroll to specific lyric keeping it centered - const scrollToLyric = useCallback( - (lyricIndex: number) => { - if (flatListRef.current && lyricIndex >= 0 && lyricIndex < parsedLyrics.length) { - try { - // Use scrollToIndex with viewPosition 0.5 to center the lyric - flatListRef.current.scrollToIndex({ - index: lyricIndex, - animated: true, - viewPosition: 0.5, // 0.5 = center of visible area - }) - } catch (error) { - // Fallback to scrollToOffset if scrollToIndex fails - console.warn('scrollToIndex failed, using fallback') - const estimatedItemHeight = 80 - const targetOffset = Math.max( - 0, - lyricIndex * estimatedItemHeight - height * 0.4, - ) + const scrollToLyric = (lyricIndex: number) => { + if (flatListRef.current && lyricIndex >= 0 && lyricIndex < parsedLyrics.length) { + try { + // Use scrollToIndex with viewPosition 0.5 to center the lyric + flatListRef.current.scrollToIndex({ + index: lyricIndex, + animated: true, + viewPosition: 0.5, // 0.5 = center of visible area + }) + } catch (error) { + // Fallback to scrollToOffset if scrollToIndex fails + console.warn('scrollToIndex failed, using fallback') + const estimatedItemHeight = 80 + const targetOffset = Math.max(0, lyricIndex * estimatedItemHeight - height * 0.4) - flatListRef.current.scrollToOffset({ - offset: targetOffset, - animated: true, - }) - } + flatListRef.current.scrollToOffset({ + offset: targetOffset, + animated: true, + }) } - }, - [parsedLyrics.length, height], - ) + } + } // Handle seeking to specific lyric timestamp - const handleLyricPress = useCallback( - (startTime: number, lyricIndex: number) => { - trigger('impactMedium') // Haptic feedback for seek action + const handleLyricPress = (startTime: number, lyricIndex: number) => { + trigger('impactMedium') // Haptic feedback for seek action - // Immediately update the highlighting for instant feedback - manuallySelectedIndex.value = lyricIndex - currentLineIndex.value = withTiming(lyricIndex, { duration: 200 }) + // Immediately update the highlighting for instant feedback + manuallySelectedIndex.value = lyricIndex + currentLineIndex.value = withTiming(lyricIndex, { duration: 200 }) - // Scroll to ensure the selected lyric is visible - scrollToLyric(lyricIndex) + // Scroll to ensure the selected lyric is visible + scrollToLyric(lyricIndex) - // Clear any existing timeout - if (manualSelectTimeout.current) { - clearTimeout(manualSelectTimeout.current) - } + // Clear any existing timeout + if (manualSelectTimeout.current) { + clearTimeout(manualSelectTimeout.current) + } - // Set a fallback timeout in case the position doesn't catch up - manualSelectTimeout.current = setTimeout(() => { - manuallySelectedIndex.value = -1 - }, 3000) + // Set a fallback timeout in case the position doesn't catch up + manualSelectTimeout.current = setTimeout(() => { + manuallySelectedIndex.value = -1 + }, 3000) - seekTo(startTime) - // Temporarily disable auto-scroll when user manually seeks - isUserScrolling.value = true - setTimeout(() => { - isUserScrolling.value = false - }, 1000) - }, - [seekTo, scrollToLyric], - ) + seekTo(startTime) + // Temporarily disable auto-scroll when user manually seeks + isUserScrolling.value = true + setTimeout(() => { + isUserScrolling.value = false + }, 1000) + } // Handle back navigation - const handleBackPress = useCallback(() => { + const handleBackPress = () => { trigger('impactLight') // Haptic feedback for navigation navigation.goBack() - }, [navigation]) + } // Optimized render item for FlatList - const renderLyricItem: ListRenderItem = useCallback( - ({ item, index }) => { - return ( - - ) - }, - [currentLineIndex, handleLyricPress], - ) + const renderLyricItem: ListRenderItem = ({ item, index }) => { + return ( + + ) + } // Removed getItemLayout to prevent crashes with dynamic content heights - const keyExtractor = useCallback( - (item: ParsedLyricLine, index: number) => `lyric-${index}-${item.startTime}`, - [], - ) + const keyExtractor = (item: ParsedLyricLine, index: number) => + `lyric-${index}-${item.startTime}` if (!parsedLyrics.length) { return ( diff --git a/src/components/Settings/components/gestures-tab.tsx b/src/components/Settings/components/gestures-tab.tsx index 22eea19e..e9516e9c 100644 --- a/src/components/Settings/components/gestures-tab.tsx +++ b/src/components/Settings/components/gestures-tab.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React from 'react' import { YStack, XStack, Paragraph, Separator } from 'tamagui' import SettingsListGroup from './settings-list-group' import { CheckboxWithLabel } from '../../Global/helpers/checkbox-with-label' @@ -16,8 +16,8 @@ export default function GesturesTab(): React.JSX.Element { const toggleLeft = useSwipeSettingsStore((s) => s.toggleLeft) const toggleRight = useSwipeSettingsStore((s) => s.toggleRight) - const leftSummary = useMemo(() => (left.length ? left.join(', ') : 'None'), [left]) - const rightSummary = useMemo(() => (right.length ? right.join(', ') : 'None'), [right]) + const leftSummary = left.length ? left.join(', ') : 'None' + const rightSummary = right.length ? right.join(', ') : 'None' return ( { + const initialRouteName = (() => { if (isUndefined(server)) { return 'ServerAddress' } @@ -24,7 +23,7 @@ export default function Login(): React.JSX.Element { return 'ServerAuthentication' } return 'LibrarySelection' - }, [server, user]) + })() return ( { - // Don't proceed if the same library is selected - if (libraryId === library?.musicLibraryId) { - navigation.goBack() - return - } - - setLibrary({ - musicLibraryId: libraryId, - musicLibraryName: selectedLibrary.Name ?? 'No library name', - musicLibraryPrimaryImageId: selectedLibrary.ImageTags?.Primary, - playlistLibraryId: playlistLibrary?.Id, - playlistLibraryPrimaryImageId: playlistLibrary?.ImageTags?.Primary, - }) - - // Invalidate all library-related queries to refresh the data - queryClient.invalidateQueries({ queryKey: [QueryKeys.AllArtistsAlphabetical] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.AllAlbumsAlphabetical] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.AllTracks] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.AllAlbums] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.AllArtists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.Playlists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoritePlaylists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteArtists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteAlbums] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteTracks] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyPlayed] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyPlayedArtists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FrequentArtists] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.FrequentlyPlayed] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyAdded] }) - queryClient.invalidateQueries({ queryKey: [QueryKeys.RefreshHome] }) - - Toast.show({ - text1: 'Library changed', - text2: `Now using ${selectedLibrary.Name}`, - type: 'success', - }) - + const handleLibrarySelected = ( + libraryId: string, + selectedLibrary: BaseItemDto, + playlistLibrary?: BaseItemDto, + ) => { + // Don't proceed if the same library is selected + if (libraryId === library?.musicLibraryId) { navigation.goBack() - }, - [setLibrary], - ) + return + } + + setLibrary({ + musicLibraryId: libraryId, + musicLibraryName: selectedLibrary.Name ?? 'No library name', + musicLibraryPrimaryImageId: selectedLibrary.ImageTags?.Primary, + playlistLibraryId: playlistLibrary?.Id, + playlistLibraryPrimaryImageId: playlistLibrary?.ImageTags?.Primary, + }) + + // Invalidate all library-related queries to refresh the data + queryClient.invalidateQueries({ queryKey: [QueryKeys.AllArtistsAlphabetical] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.AllAlbumsAlphabetical] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.AllTracks] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.AllAlbums] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.AllArtists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.Playlists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoritePlaylists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteArtists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteAlbums] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FavoriteTracks] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyPlayed] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyPlayedArtists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FrequentArtists] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.FrequentlyPlayed] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.RecentlyAdded] }) + queryClient.invalidateQueries({ queryKey: [QueryKeys.RefreshHome] }) + + Toast.show({ + text1: 'Library changed', + text2: `Now using ${selectedLibrary.Name}`, + type: 'success', + }) + + navigation.goBack() + } const handleCancel = () => { navigation.goBack() diff --git a/src/screens/Settings/storage-management/useDeletionToast.ts b/src/screens/Settings/storage-management/useDeletionToast.ts index 4564b469..9cfe7a69 100644 --- a/src/screens/Settings/storage-management/useDeletionToast.ts +++ b/src/screens/Settings/storage-management/useDeletionToast.ts @@ -1,13 +1,11 @@ -import { useCallback } from 'react' import Toast from 'react-native-toast-message' import { formatBytes } from '../../../utils/format-bytes' -export const useDeletionToast = () => - useCallback((message: string, freedBytes: number) => { - Toast.show({ - type: 'success', - text1: message, - text2: `Freed ${formatBytes(freedBytes)}`, - }) - }, []) +export const useDeletionToast = () => (message: string, freedBytes: number) => { + Toast.show({ + type: 'success', + text1: message, + text2: `Freed ${formatBytes(freedBytes)}`, + }) +} diff --git a/src/screens/Settings/storage-selection-modal.tsx b/src/screens/Settings/storage-selection-modal.tsx index d99c5404..afb1b5e3 100644 --- a/src/screens/Settings/storage-selection-modal.tsx +++ b/src/screens/Settings/storage-selection-modal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React from 'react' import { ScrollView } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { NativeStackScreenProps } from '@react-navigation/native-stack' @@ -31,27 +31,25 @@ export default function StorageSelectionModal({ const showDeletionToast = useDeletionToast() const { bottom } = useSafeAreaInsets() - const selectedDownloads = useMemo( - () => downloads?.filter((download) => selection[download.item.Id as string]) ?? [], - [downloads, selection], + const selectedDownloads = + downloads?.filter((download) => selection[download.item.Id as string]) ?? [] + + const selectedBytes = selectedDownloads.reduce( + (total, download) => total + getDownloadSize(download), + 0, ) - const selectedBytes = useMemo( - () => selectedDownloads.reduce((total, download) => total + getDownloadSize(download), 0), - [selectedDownloads], - ) - - const handleDelete = useCallback(async () => { + const handleDelete = async () => { const result = await deleteSelection() if (result?.deletedCount) { showDeletionToast(`Deleted ${result.deletedCount} downloads`, result.freedBytes) navigation.goBack() } - }, [deleteSelection, navigation, showDeletionToast]) + } - const handleClose = useCallback(() => { + const handleClose = () => { navigation.goBack() - }, [navigation]) + } const hasSelection = selectedDownloads.length > 0