diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 02d04583..d12e4e7b 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -1,6 +1,6 @@ -import { ActivityIndicator, RefreshControl } from 'react-native' +import { RefreshControl } from 'react-native' import { Separator, useTheme, XStack, YStack } from 'tamagui' -import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' +import React, { RefObject, useEffect, useRef } from 'react' import { Text } from '../Global/helpers/text' import { FlashList, FlashListRef } from '@shopify/flash-list' import { UseInfiniteQueryResult } from '@tanstack/react-query' @@ -39,55 +39,52 @@ export default function Albums({ const pendingLetterRef = useRef(null) // Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations - const stickyHeaderIndices = React.useMemo(() => { - if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return [] - - return albumsInfiniteQuery.data - .map((album, index) => (typeof album === 'string' ? index : 0)) - .filter((value, index, indices) => indices.indexOf(value) === index) - }, [showAlphabeticalSelector, albumsInfiniteQuery.data]) + const stickyHeaderIndices = + !showAlphabeticalSelector || !albumsInfiniteQuery.data + ? [] + : albumsInfiniteQuery.data + .map((album, index) => (typeof album === 'string' ? index : 0)) + .filter((value, index, indices) => indices.indexOf(value) === index) const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) - const refreshControl = useMemo( - () => ( - - ), - [albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch], + const refreshControl = ( + ) - const ItemSeparatorComponent = useCallback( - ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => - typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( - - ), - [], - ) + const ItemSeparatorComponent = ({ + leadingItem, + trailingItem, + }: { + leadingItem: unknown + trailingItem: unknown + }) => + typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : - const keyExtractor = useCallback( - (item: BaseItemDto | string | number) => - typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, - [], - ) + const keyExtractor = (item: BaseItemDto | string | number) => + typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id! - const renderItem = useCallback( - ({ index, item: album }: { index: number; item: BaseItemDto | string | number }) => - typeof album === 'string' ? ( - - ) : typeof album === 'number' ? null : typeof album === 'object' ? ( - - ) : null, - [navigation], - ) + const renderItem = ({ + index, + item: album, + }: { + index: number + item: BaseItemDto | string | number + }) => + typeof album === 'string' ? ( + + ) : typeof album === 'number' ? null : typeof album === 'object' ? ( + + ) : null - const onEndReached = useCallback(() => { + const onEndReached = () => { if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage() - }, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage]) + } // Effect for handling the pending alphabet selector letter useEffect(() => { diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 33d0eb0d..369a606a 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -1,5 +1,5 @@ -import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' -import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui' +import React, { RefObject, useEffect, useRef } from 'react' +import { Separator, useTheme, XStack, YStack } from 'tamagui' import { Text } from '../Global/helpers/text' import { RefreshControl } from 'react-native' import ItemRow from '../Global/components/item-row' @@ -50,41 +50,41 @@ export default function Artists({ const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) - const stickyHeaderIndices = useMemo(() => { - if (!showAlphabeticalSelector || !artists) return [] + const stickyHeaderIndices = + !showAlphabeticalSelector || !artists + ? [] + : artists + .map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) + .filter((value, index, indices) => indices.indexOf(value) === index) - return artists - .map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) - .filter((value, index, indices) => indices.indexOf(value) === index) - }, [showAlphabeticalSelector, artists]) + const ItemSeparatorComponent = ({ + leadingItem, + trailingItem, + }: { + leadingItem: unknown + trailingItem: unknown + }) => + typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : - const ItemSeparatorComponent = useCallback( - ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => - typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( - - ), - [], - ) + const KeyExtractor = (item: BaseItemDto | string | number, index: number) => + typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id! - const KeyExtractor = useCallback( - (item: BaseItemDto | string | number, index: number) => - typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, - [], - ) - - const renderItem = useCallback( - ({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) => - typeof artist === 'string' ? ( - // Don't render the letter if we don't have any artists that start with it - // If the index is the last index, or the next index is not an object, then don't render the letter - index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( - - ) - ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( - - ) : null, - [navigation], - ) + const renderItem = ({ + index, + item: artist, + }: { + index: number + item: BaseItemDto | number | string + }) => + typeof artist === 'string' ? ( + // Don't render the letter if we don't have any artists that start with it + // If the index is the last index, or the next index is not an object, then don't render the letter + index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( + + ) + ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( + + ) : null // Effect for handling the pending alphabet selector letter useEffect(() => { diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index 5dbcfa6d..b81de253 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -1,4 +1,4 @@ -import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, useEffect, useRef, useState } from 'react' import { LayoutChangeEvent, Platform, View as RNView } from 'react-native' import { getToken, Spinner, useTheme, View, YStack } from 'tamagui' import { Gesture, GestureDetector } from 'react-native-gesture-handler' @@ -61,78 +61,70 @@ export default function AZScroller({ }) } - const panGesture = useMemo( - () => - Gesture.Pan() - .runOnJS(true) - .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onUpdate((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onEnd(() => { - if (selectedLetter.value) { - scheduleOnRN(async () => { - setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { - scheduleOnRN(hideOverlay) - setOperationPending(false) - }) - }) - } else { + const panGesture = Gesture.Pan() + .runOnJS(true) + .onBegin((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onUpdate((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onEnd(() => { + if (selectedLetter.value) { + scheduleOnRN(async () => { + setOperationPending(true) + onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { scheduleOnRN(hideOverlay) - } - }), - [onLetterSelect], - ) + setOperationPending(false) + }) + }) + } else { + scheduleOnRN(hideOverlay) + } + }) - const tapGesture = useMemo( - () => - Gesture.Tap() - .runOnJS(true) - .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onEnd(() => { - if (selectedLetter.value) { - scheduleOnRN(async () => { - setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { - scheduleOnRN(hideOverlay) - setOperationPending(false) - }) - }) - } else { + const tapGesture = Gesture.Tap() + .runOnJS(true) + .onBegin((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onEnd(() => { + if (selectedLetter.value) { + scheduleOnRN(async () => { + setOperationPending(true) + onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { scheduleOnRN(hideOverlay) - } - }), - [onLetterSelect], - ) + setOperationPending(false) + }) + }) + } else { + scheduleOnRN(hideOverlay) + } + }) const gesture = Gesture.Simultaneous(panGesture, tapGesture) diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index 24aa63d6..81473e31 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network' import useStreamingDeviceProfile from '../../../stores/device-profile' import useItemContext from '../../../hooks/use-item-context' import { RouteProp, useRoute } from '@react-navigation/native' -import React, { memo, useCallback, useMemo, useState } from 'react' +import React from 'react' import { LayoutChangeEvent } from 'react-native' import Animated, { SharedValue, @@ -51,322 +51,264 @@ interface ItemRowProps { * @param navigation - The navigation object. * @returns */ -const ItemRow = memo( - function ItemRow({ - item, - circular, - navigation, - onPress, - queueName, - }: ItemRowProps): React.JSX.Element { - const artworkAreaWidth = useSharedValue(0) +function ItemRow({ + item, + circular, + navigation, + onPress, + queueName, +}: ItemRowProps): React.JSX.Element { + const artworkAreaWidth = useSharedValue(0) - const api = useApi() + const api = useApi() - const [networkStatus] = useNetworkStatus() + const [networkStatus] = useNetworkStatus() - const deviceProfile = useStreamingDeviceProfile() + const deviceProfile = useStreamingDeviceProfile() - const loadNewQueue = useLoadNewQueue() - const addToQueue = useAddToQueue() - const { mutate: addFavorite } = useAddFavorite() - const { mutate: removeFavorite } = useRemoveFavorite() - const [hideRunTimes] = useHideRunTimesSetting() + const loadNewQueue = useLoadNewQueue() + const addToQueue = useAddToQueue() + const { mutate: addFavorite } = useAddFavorite() + const { mutate: removeFavorite } = useRemoveFavorite() + const [hideRunTimes] = useHideRunTimesSetting() - const warmContext = useItemContext() - const { data: isFavorite } = useIsFavorite(item) + const warmContext = useItemContext() + const { data: isFavorite } = useIsFavorite(item) - const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id]) + const onPressIn = () => warmContext(item) - const onLongPress = useCallback( - () => - navigationRef.navigate('Context', { - item, - navigation, - }), - [navigationRef, navigation, item.Id], - ) + const onLongPress = () => + navigationRef.navigate('Context', { + item, + navigation, + }) - const onPressCallback = useCallback(async () => { - if (onPress) await onPress() - else - switch (item.Type) { - case 'Audio': { - loadNewQueue({ - api, - networkStatus, - deviceProfile, - track: item, - tracklist: [item], - index: 0, - queue: queueName ?? 'Search', - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - break - } - case 'MusicArtist': { - navigation?.navigate('Artist', { artist: item }) - break - } - - case 'MusicAlbum': { - navigation?.navigate('Album', { album: item }) - break - } - - case 'Playlist': { - navigation?.navigate('Playlist', { playlist: item, canEdit: true }) - break - } - default: { - break - } - } - }, [onPress, loadNewQueue, item.Id, navigation, queueName]) - - const renderRunTime = useMemo( - () => item.Type === BaseItemKind.Audio && !hideRunTimes, - [item.Type, hideRunTimes], - ) - - const isAudio = useMemo(() => item.Type === 'Audio', [item.Type]) - - const playlistTrackCount = useMemo( - () => (item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined), - [item.Type, item.SongCount, item.ChildCount], - ) - - const leftSettings = useSwipeSettingsStore((s) => s.left) - const rightSettings = useSwipeSettingsStore((s) => s.right) - - const swipeHandlers = useCallback( - () => ({ - addToQueue: async () => - await addToQueue({ + const onPressCallback = async () => { + if (onPress) await onPress() + else + switch (item.Type) { + case 'Audio': { + loadNewQueue({ api, - deviceProfile, networkStatus, - tracks: [item], - queuingType: QueuingType.DirectlyQueued, - }), - toggleFavorite: () => - isFavorite ? removeFavorite({ item }) : addFavorite({ item }), - addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), - }), - [ - addToQueue, + deviceProfile, + track: item, + tracklist: [item], + index: 0, + queue: queueName ?? 'Search', + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + break + } + case 'MusicArtist': { + navigation?.navigate('Artist', { artist: item }) + break + } + + case 'MusicAlbum': { + navigation?.navigate('Album', { album: item }) + break + } + + case 'Playlist': { + navigation?.navigate('Playlist', { playlist: item, canEdit: true }) + break + } + default: { + break + } + } + } + + const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes + + const isAudio = item.Type === 'Audio' + + const playlistTrackCount = + item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined + + const leftSettings = useSwipeSettingsStore((s) => s.left) + const rightSettings = useSwipeSettingsStore((s) => s.right) + + const swipeHandlers = () => ({ + addToQueue: async () => + await addToQueue({ api, deviceProfile, networkStatus, - item, - addFavorite, - removeFavorite, - isFavorite, - ], - ) + tracks: [item], + queuingType: QueuingType.DirectlyQueued, + }), + toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })), + addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), + }) - const swipeConfig = useMemo( - () => - isAudio - ? buildSwipeConfig({ - left: leftSettings, - right: rightSettings, - handlers: swipeHandlers(), - }) - : {}, - [isAudio, leftSettings, rightSettings, swipeHandlers], - ) + const swipeConfig = isAudio + ? buildSwipeConfig({ + left: leftSettings, + right: rightSettings, + handlers: swipeHandlers(), + }) + : {} - const handleArtworkLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width } = event.nativeEvent.layout - artworkAreaWidth.value = width - }, - [artworkAreaWidth], - ) + const handleArtworkLayout = (event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout + artworkAreaWidth.value = width + } - const pressStyle = useMemo(() => ({ opacity: 0.5 }), []) + const pressStyle = { + opacity: 0.5, + } - return ( - + - - - - - - - - {renderRunTime ? ( - {item.RunTimeTicks} - ) : item.Type === 'Playlist' ? ( - - {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`} - - ) : null} - - - {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( - - ) : null} - - - - ) - }, - (prevProps, nextProps) => - prevProps.item.Id === nextProps.item.Id && - prevProps.circular === nextProps.circular && - prevProps.navigation === nextProps.navigation && - prevProps.queueName === nextProps.queueName && - !!prevProps.onPress === !!nextProps.onPress, -) - -const ItemRowDetails = memo( - function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { - const route = useRoute>() - - const shouldRenderArtistName = - item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist') - - const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist' - - const shouldRenderGenres = - item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist - - return ( - - - {item.Name ?? ''} - - - {shouldRenderArtistName && ( - - {item.AlbumArtist ?? 'Untitled Artist'} - - )} - - {shouldRenderProductionYear && ( - - - {item.ProductionYear?.toString() ?? 'Unknown Year'} - - - + + + + + + {renderRunTime ? ( {item.RunTimeTicks} - - )} + ) : item.Type === 'Playlist' ? ( + + {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`} + + ) : null} + - {shouldRenderGenres && item.Genres && ( + {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( + + ) : null} + + + + ) +} + +function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { + const route = useRoute>() + + const shouldRenderArtistName = + item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist') + + const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist' + + const shouldRenderGenres = item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist + + return ( + + + {item.Name ?? ''} + + + {shouldRenderArtistName && ( + + {item.AlbumArtist ?? 'Untitled Artist'} + + )} + + {shouldRenderProductionYear && ( + - {item.Genres?.join(', ') ?? ''} + {item.ProductionYear?.toString() ?? 'Unknown Year'} - )} - - ) - }, - (prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id, -) + + + + {item.RunTimeTicks} + + )} + + {shouldRenderGenres && item.Genres && ( + + {item.Genres?.join(', ') ?? ''} + + )} + + ) +} // Artwork wrapper that fades out when the quick-action menu is open -const HideableArtwork = memo( - function HideableArtwork({ - item, - circular, - onLayout, - }: { - item: BaseItemDto - circular?: boolean - onLayout?: (event: LayoutChangeEvent) => void - }): React.JSX.Element { - const { tx } = useSwipeableRowContext() - // Hide artwork as soon as swiping starts (any non-zero tx) - const style = useAnimatedStyle(() => ({ - opacity: tx.value === 0 ? withTiming(1) : 0, - })) - return ( - - - - - - ) - }, - (prevProps, nextProps) => - prevProps.item.Id === nextProps.item.Id && - prevProps.circular === nextProps.circular && - !!prevProps.onLayout === !!nextProps.onLayout, -) +function HideableArtwork({ + item, + circular, + onLayout, +}: { + item: BaseItemDto + circular?: boolean + onLayout?: (event: LayoutChangeEvent) => void +}): React.JSX.Element { + const { tx } = useSwipeableRowContext() + // Hide artwork as soon as swiping starts (any non-zero tx) + const style = useAnimatedStyle(() => ({ + opacity: tx.value === 0 ? withTiming(1) : 0, + })) + return ( + + + + + + ) +} -const SlidingTextArea = memo( - function SlidingTextArea({ - leftGapWidth, - children, - }: { - leftGapWidth: SharedValue - children: React.ReactNode - }): React.JSX.Element { - const { tx, rightWidth } = useSwipeableRowContext() - const tokenValue = getToken('$2', 'space') - const spacingValue = - typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) - const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 - const style = useAnimatedStyle(() => { - const t = tx.value - let offset = 0 - if (t > 0 && leftGapWidth.get() > 0) { - offset = -Math.min(t, leftGapWidth.get()) - } else if (t < 0) { - const rightSpace = Math.max(0, rightWidth) - const compensate = Math.min(-t, rightSpace) - const progress = rightSpace > 0 ? compensate / rightSpace : 1 - offset = compensate * 0.7 + quickActionBuffer * progress - } - return { transform: [{ translateX: offset }] } - }) - const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 - return ( - - {children} - - ) - }, - (prevProps, nextProps) => - prevProps.leftGapWidth === nextProps.leftGapWidth && - prevProps.children?.valueOf() === nextProps.children?.valueOf(), -) +function SlidingTextArea({ + leftGapWidth, + children, +}: { + leftGapWidth: SharedValue + children: React.ReactNode +}): React.JSX.Element { + const { tx, rightWidth } = useSwipeableRowContext() + const tokenValue = getToken('$2', 'space') + const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) + const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 + const style = useAnimatedStyle(() => { + const t = tx.value + let offset = 0 + if (t > 0 && leftGapWidth.get() > 0) { + offset = -Math.min(t, leftGapWidth.get()) + } else if (t < 0) { + const rightSpace = Math.max(0, rightWidth) + const compensate = Math.min(-t, rightSpace) + const progress = rightSpace > 0 ? compensate / rightSpace : 1 + offset = compensate * 0.7 + quickActionBuffer * progress + } + return { transform: [{ translateX: offset }] } + }) + const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 + return ( + + {children} + + ) +} export default ItemRow diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 980a1c90..27f9d44e 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, memo } from 'react' +import React, { useState } from 'react' import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui' import { Text } from '../helpers/text' import { RunTimeTicks } from '../helpers/time-codes' @@ -28,10 +28,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori import { StackActions } from '@react-navigation/native' import { useSwipeableRowContext } from './swipeable-row-context' import { useHideRunTimesSetting } from '../../../stores/settings/app' -import { queryClient, ONE_HOUR } from '../../../constants/query-client' -import { fetchMediaInfo } from '../../../api/queries/media/utils' -import MediaInfoQueryKey from '../../../api/queries/media/keys' -import JellifyTrack from '../../../types/JellifyTrack' +import useStreamedMediaInfo from '../../../api/queries/media' export interface TrackProps { track: BaseItemDto @@ -48,329 +45,243 @@ export interface TrackProps { editing?: boolean | undefined } -const queueItemsCache = new WeakMap() +export default function Track({ + track, + navigation, + tracklist, + index, + queue, + showArtwork, + onPress, + onLongPress, + testID, + isNested, + invertedColors, + editing, +}: TrackProps): React.JSX.Element { + const theme = useTheme() + const [artworkAreaWidth, setArtworkAreaWidth] = useState(0) -const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => { - if (!queue?.length) return [] + const api = useApi() - const cached = queueItemsCache.get(queue) - if (cached) return cached + const deviceProfile = useStreamingDeviceProfile() - const mapped = queue.map((entry) => entry.item) - queueItemsCache.set(queue, mapped) - return mapped -} + const [hideRunTimes] = useHideRunTimesSetting() -const Track = memo( - function Track({ - track, - navigation, - tracklist, - index, - queue, - showArtwork, - onPress, - onLongPress, - testID, - isNested, - invertedColors, - editing, - }: TrackProps): React.JSX.Element { - const theme = useTheme() - const [artworkAreaWidth, setArtworkAreaWidth] = useState(0) + const nowPlaying = useCurrentTrack() + const playQueue = usePlayQueue() + const loadNewQueue = useLoadNewQueue() + const addToQueue = useAddToQueue() + const [networkStatus] = useNetworkStatus() - const api = useApi() + const { data: mediaInfo } = useStreamedMediaInfo(track.Id) - const deviceProfile = useStreamingDeviceProfile() + const offlineAudio = useDownloadedTrack(track.Id) - const [hideRunTimes] = useHideRunTimesSetting() + const { mutate: addFavorite } = useAddFavorite() + const { mutate: removeFavorite } = useRemoveFavorite() + const { data: isFavoriteTrack } = useIsFavorite(track) + const leftSettings = useSwipeSettingsStore((s) => s.left) + const rightSettings = useSwipeSettingsStore((s) => s.right) - const nowPlaying = useCurrentTrack() - const playQueue = usePlayQueue() - const loadNewQueue = useLoadNewQueue() - const addToQueue = useAddToQueue() - const [networkStatus] = useNetworkStatus() + // Memoize expensive computations + const isPlaying = nowPlaying?.item.Id === track.Id - const offlineAudio = useDownloadedTrack(track.Id) + const isOffline = networkStatus === networkStatusTypes.DISCONNECTED - const { mutate: addFavorite } = useAddFavorite() - const { mutate: removeFavorite } = useRemoveFavorite() - const { data: isFavoriteTrack } = useIsFavorite(track) - const leftSettings = useSwipeSettingsStore((s) => s.left) - const rightSettings = useSwipeSettingsStore((s) => s.right) + // Memoize tracklist for queue loading + const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? [] - // Memoize expensive computations - const isPlaying = useMemo( - () => nowPlaying?.item.Id === track.Id, - [nowPlaying?.item.Id, track.Id], - ) - - const isOffline = useMemo( - () => networkStatus === networkStatusTypes.DISCONNECTED, - [networkStatus], - ) - - // Memoize tracklist for queue loading - const memoizedTracklist = useMemo( - () => tracklist ?? getQueueItems(playQueue), - [tracklist, playQueue], - ) - - // Memoize handlers to prevent recreation - const handlePress = useCallback(async () => { - if (onPress) { - await onPress() - } else { - loadNewQueue({ - api, - deviceProfile, - networkStatus, - track, - index, - tracklist: memoizedTracklist, - queue, - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - } - }, [ - onPress, - api, - deviceProfile, - networkStatus, - track, - index, - memoizedTracklist, - queue, - loadNewQueue, - ]) - - const fetchStreamingMediaSourceInfo = useCallback(async () => { - if (!api || !deviceProfile || !track.Id) return undefined - - const queryKey = MediaInfoQueryKey({ api, deviceProfile, itemId: track.Id }) - - try { - const info = await queryClient.ensureQueryData({ - queryKey, - queryFn: () => fetchMediaInfo(api, deviceProfile, track.Id), - staleTime: ONE_HOUR, - gcTime: ONE_HOUR, - }) - - return info.MediaSources?.[0] - } catch (error) { - console.warn('Failed to fetch media info for context sheet', error) - return undefined - } - }, [api, deviceProfile, track.Id]) - - const openContextSheet = useCallback(async () => { - const streamingMediaSourceInfo = await fetchStreamingMediaSourceInfo() - - navigationRef.navigate('Context', { - item: track, - navigation, - streamingMediaSourceInfo, - downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, - }) - }, [fetchStreamingMediaSourceInfo, track, navigation, offlineAudio?.mediaSourceInfo]) - - const handleLongPress = useCallback(() => { - if (onLongPress) { - onLongPress() - return - } - - void openContextSheet() - }, [onLongPress, openContextSheet]) - - const handleIconPress = useCallback(() => { - void openContextSheet() - }, [openContextSheet]) - - // Memoize artists text - const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists]) - - // Memoize track name - const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name]) - - // Memoize index number - const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber]) - - // Memoize show artists condition - const shouldShowArtists = useMemo( - () => showArtwork || (track.Artists && track.Artists.length > 1), - [showArtwork, track.Artists], - ) - - const swipeHandlers = useMemo( - () => ({ - addToQueue: async () => { - console.info('Running add to queue swipe action') - await addToQueue({ - api, - deviceProfile, - networkStatus, - tracks: [track], - queuingType: QueuingType.DirectlyQueued, - }) - }, - toggleFavorite: () => { - console.info( - `Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`, - ) - if (isFavoriteTrack) removeFavorite({ item: track }) - else addFavorite({ item: track }) - }, - addToPlaylist: () => { - console.info('Running add to playlist swipe handler') - navigationRef.dispatch(StackActions.push('AddToPlaylist', { track })) - }, - }), - [ - addToQueue, + // Memoize handlers to prevent recreation + const handlePress = async () => { + if (onPress) { + await onPress() + } else { + loadNewQueue({ api, deviceProfile, networkStatus, track, - addFavorite, - removeFavorite, - isFavoriteTrack, - navigationRef, - ], - ) + index, + tracklist: memoizedTracklist, + queue, + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + } + } - const swipeConfig = useMemo( - () => - buildSwipeConfig({ - left: leftSettings, - right: rightSettings, - handlers: swipeHandlers, - }), - [leftSettings, rightSettings, swipeHandlers], - ) + const handleLongPress = () => { + if (onLongPress) { + onLongPress() + } else { + navigationRef.navigate('Context', { + item: track, + navigation, + streamingMediaSourceInfo: mediaInfo?.MediaSources + ? mediaInfo!.MediaSources![0] + : undefined, + downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, + }) + } + } - const textColor = useMemo( - () => (isPlaying ? theme.primary.val : theme.color.val), - [isPlaying], - ) + const handleIconPress = () => { + navigationRef.navigate('Context', { + item: track, + navigation, + streamingMediaSourceInfo: mediaInfo?.MediaSources + ? mediaInfo!.MediaSources![0] + : undefined, + downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, + }) + } - const runtimeComponent = useMemo( - () => - hideRunTimes ? ( - <> - ) : ( - - {track.RunTimeTicks} - - ), - [hideRunTimes, track.RunTimeTicks], - ) + // Memoize text color to prevent recalculation + const textColor = isPlaying + ? theme.primary.val + : isOffline + ? offlineAudio + ? theme.color + : theme.neutral.val + : theme.color - return ( - - 1) + + const swipeHandlers = { + addToQueue: async () => { + console.info('Running add to queue swipe action') + await addToQueue({ + api, + deviceProfile, + networkStatus, + tracks: [track], + queuingType: QueuingType.DirectlyQueued, + }) + }, + toggleFavorite: () => { + console.info(`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`) + if (isFavoriteTrack) removeFavorite({ item: track }) + else addFavorite({ item: track }) + }, + addToPlaylist: () => { + console.info('Running add to playlist swipe handler') + navigationRef.dispatch(StackActions.push('AddToPlaylist', { track })) + }, + } + + const swipeConfig = buildSwipeConfig({ + left: leftSettings, + right: rightSettings, + handlers: swipeHandlers, + }) + + const runtimeComponent = hideRunTimes ? ( + <> + ) : ( + + {track.RunTimeTicks} + + ) + + return ( + + + setArtworkAreaWidth(e.nativeEvent.layout.width)} > - setArtworkAreaWidth(e.nativeEvent.layout.width)} - > - {showArtwork ? ( - - - - ) : ( - - {indexNumber} - - )} - + {showArtwork ? ( + + + + ) : ( + + {indexNumber} + + )} + - - + + + + {trackName} + + + {shouldShowArtists && ( - {trackName} + {artistsText} - - {shouldShowArtists && ( - - {artistsText} - - )} - - - - - - - {runtimeComponent} - {!editing && ( - )} - + + + + + + + {runtimeComponent} + {!editing && } - - - ) - }, - (prevProps, nextProps) => - prevProps.track.Id === nextProps.track.Id && - prevProps.index === nextProps.index && - prevProps.showArtwork === nextProps.showArtwork && - prevProps.isNested === nextProps.isNested && - prevProps.invertedColors === nextProps.invertedColors && - prevProps.testID === nextProps.testID && - prevProps.editing === nextProps.editing && - prevProps.queue === nextProps.queue && - prevProps.tracklist === nextProps.tracklist && - !!prevProps.onPress === !!nextProps.onPress && - !!prevProps.onLongPress === !!nextProps.onLongPress, -) + + + + ) +} function HideableArtwork({ children }: { children: React.ReactNode }) { const { tx } = useSwipeableRowContext() @@ -402,7 +313,5 @@ function SlidingTextArea({ } return { transform: [{ translateX: offset }] } }) - return {children} + return {children} } - -export default Track