diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 21eac555..31a49f14 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -27,7 +27,7 @@ jobs: uses: ./.github/actions/setup-xcode - name: 🍎 Run yarn init-ios:new-arch - run: yarn init-android && cd ios && bundle install && RCT_USE_RN_DEP=1 RCT_USE_PREBUILT_RNCORE=1 bundle exec pod install + run: yarn init-android && cd ios && bundle install && bundle exec pod install - name: 🚀 Run fastlane build diff --git a/ios/Podfile b/ios/Podfile index 92379a17..28fce431 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -20,6 +20,8 @@ end target 'Jellify' do config = use_native_modules! + pod 'SDWebImage', :modular_headers => true + use_react_native!( :path => config[:reactNativePath], # An absolute path to your application root. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 24168f9a..9a340a5a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -12,18 +12,97 @@ PODS: - hermes-engine (0.81.1): - hermes-engine/Pre-built (= 0.81.1) - hermes-engine/Pre-built (0.81.1) - - libwebp (1.5.0): - - libwebp/demux (= 1.5.0) - - libwebp/mux (= 1.5.0) - - libwebp/sharpyuv (= 1.5.0) - - libwebp/webp (= 1.5.0) - - libwebp/demux (1.5.0): - - libwebp/webp - - libwebp/mux (1.5.0): - - libwebp/demux - - libwebp/sharpyuv (1.5.0) - - libwebp/webp (1.5.0): - - libwebp/sharpyuv + - NitroImage (0.6.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroModules (0.29.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroWebImage (0.6.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroImage + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SDWebImage + - SocketRocket + - Yoga - PromisesObjC (2.4.0) - Protobuf (3.29.5) - RCT-Folly (2024.11.18.00): @@ -2603,10 +2682,6 @@ PODS: - React-Core - RNDnsLookup (1.0.6): - React - - RNFastImage (8.6.3): - - React-Core - - SDWebImage (~> 5.11.1) - - SDWebImageWebPCoder (~> 0.8.4) - RNFS (2.20.0): - React-Core - RNGestureHandler (2.28.0): @@ -2935,9 +3010,6 @@ PODS: - SDWebImage (5.11.1): - SDWebImage/Core (= 5.11.1) - SDWebImage/Core (5.11.1) - - SDWebImageWebPCoder (0.8.5): - - libwebp (~> 1.0) - - SDWebImage/Core (~> 5.10) - Sentry/HybridSDK (8.53.2) - SocketRocket (0.7.1) - SSZipArchive (2.4.3) @@ -2953,6 +3025,9 @@ DEPENDENCIES: - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroImage (from `../node_modules/react-native-nitro-image`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) + - NitroWebImage (from `../node_modules/react-native-nitro-web-image`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) @@ -3034,7 +3109,6 @@ DEPENDENCIES: - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNDnsLookup (from `../node_modules/react-native-dns-lookup`) - - RNFastImage (from `../node_modules/react-native-fast-image`) - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) @@ -3049,11 +3123,9 @@ DEPENDENCIES: SPEC REPOS: trunk: - google-cast-sdk - - libwebp - PromisesObjC - Protobuf - SDWebImage - - SDWebImageWebPCoder - Sentry - SocketRocket - SSZipArchive @@ -3077,6 +3149,12 @@ EXTERNAL SOURCES: hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 + NitroImage: + :path: "../node_modules/react-native-nitro-image" + NitroModules: + :path: "../node_modules/react-native-nitro-modules" + NitroWebImage: + :path: "../node_modules/react-native-nitro-web-image" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -3237,8 +3315,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-device-info" RNDnsLookup: :path: "../node_modules/react-native-dns-lookup" - RNFastImage: - :path: "../node_modules/react-native-fast-image" RNFS: :path: "../node_modules/react-native-fs" RNGestureHandler: @@ -3266,7 +3342,9 @@ SPEC CHECKSUMS: glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca - libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + NitroImage: f2d14e6531629630904d8ceb4037459d6057440a + NitroModules: 8d96528777600e967d371fd62b7eb183e9204530 + NitroWebImage: 04de36dd513d350fe3d4d3a9279a95817c1a39f1 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 @@ -3349,7 +3427,6 @@ SPEC CHECKSUMS: RNCPicker: 66c392786945ecee5275242c148e6a4601221d3a RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521 - RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: 3a73f098d74712952870e948b3d9cf7b6cae9961 RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6 @@ -3358,13 +3435,12 @@ SPEC CHECKSUMS: RNSentry: 6c63debc7b22a00cbf7d1c9ed8de43e336216545 RNWorklets: e8335dff9d27004709f58316985769040cd1e8f2 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d - SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef SwiftAudioEx: f6aa653770f3a0d3851edaf8d834a30aee4a7646 Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0 -PODFILE CHECKSUM: 531be4f3bdf91c9e2b2a6f2444e455020f218a20 +PODFILE CHECKSUM: 320ff45ce6a038db4058773abf4993a674de141e COCOAPODS: 1.16.2 diff --git a/jest/setup/nitro-image.ts b/jest/setup/nitro-image.ts index 0d4fbe57..105de7eb 100644 --- a/jest/setup/nitro-image.ts +++ b/jest/setup/nitro-image.ts @@ -1,42 +1,60 @@ -// // Mock for react-native-nitro-image -// import React from 'react' -// import { View, ViewProps } from 'react-native' +// Mock for react-native-nitro-image +import React from 'react' +import { Image, ImageProps } from 'react-native' -// // Mock the useWebImage hook -// const mockUseWebImage = jest.fn(() => ({ -// imageUri: 'mock://image.jpg', -// isLoading: false, -// error: null, -// })) +// Mock the useWebImage hook +const mockUseWebImage = jest.fn(() => ({ + imageUri: 'mock://image.jpg', + isLoading: false, + error: null, +})) -// // Mock the NitroImage component -// const MockNitroImage = (props: ViewProps) => { -// // Return a basic View component for testing -// return React.createElement(View, props) -// } +// Define types for NitroImage props +interface NitroImageProps extends Omit { + image?: { + url: string + } +} -// // Mock the entire react-native-nitro-image module -// jest.mock('react-native-nitro-image', () => ({ -// useWebImage: mockUseWebImage, -// NitroImage: MockNitroImage, -// // Add other exports that might be used -// createImageFactory: jest.fn(), -// ImageFactory: jest.fn(), -// })) +// Mock the NitroImage component to behave like a regular Image +const MockNitroImage = (props: NitroImageProps) => { + // Extract the URL from the image prop if it exists + const source = props.image?.url ? { uri: props.image.url } : undefined -// // Mock the underlying native module that causes the error -// jest.mock('react-native-nitro-modules', () => ({ -// NitroModules: { -// createModule: jest.fn(), -// install: jest.fn(), -// }, -// createNitroModule: jest.fn(), -// })) + // Destructure to separate the custom image prop from standard Image props + const { image, ...restProps } = props -// // Additional mock for the TurboModule spec that's failing -// jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({ -// default: { -// installModule: jest.fn(), -// uninstallModule: jest.fn(), -// }, -// })) + // Pass through other props while converting to Image component props + const imageProps: ImageProps = { + ...restProps, + source, + } + + return React.createElement(Image, imageProps) +} + +// Mock the entire react-native-nitro-image module +jest.mock('react-native-nitro-image', () => ({ + useWebImage: mockUseWebImage, + NitroImage: MockNitroImage, + // Add other exports that might be used + createImageFactory: jest.fn(), + ImageFactory: jest.fn(), +})) + +// Mock the underlying native module that causes the error +jest.mock('react-native-nitro-modules', () => ({ + NitroModules: { + createModule: jest.fn(), + install: jest.fn(), + }, + createNitroModule: jest.fn(), +})) + +// Additional mock for the TurboModule spec that's failing +jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({ + default: { + installModule: jest.fn(), + uninstallModule: jest.fn(), + }, +})) diff --git a/package.json b/package.json index 748faab9..c9e3787c 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "react-native-device-info": "^14.0.4", "react-native-dns-lookup": "^1.0.6", "react-native-draggable-flatlist": "^4.0.3", - "react-native-fast-image": "^8.6.3", "react-native-flashdrag-list": "^0.2.5", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.28.0", @@ -78,6 +77,9 @@ "react-native-haptic-feedback": "^2.3.3", "react-native-linear-gradient": "^2.8.3", "react-native-mmkv": "3.3.0", + "react-native-nitro-image": "^0.6.1", + "react-native-nitro-modules": "^0.29.4", + "react-native-nitro-web-image": "^0.6.1", "react-native-ota-hot-update": "2.3.1", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "4.0.2", @@ -142,4 +144,4 @@ "node": ">=18" }, "packageManager": "yarn@1.22.22" -} \ No newline at end of file +} diff --git a/src/api/queries/artist/utils/artist.ts b/src/api/queries/artist/utils/artist.ts index dfc78acc..b6a77bd6 100644 --- a/src/api/queries/artist/utils/artist.ts +++ b/src/api/queries/artist/utils/artist.ts @@ -75,6 +75,7 @@ export function fetchArtistAlbums( sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName], sortOrder: [SortOrder.Descending], albumArtistIds: [artist.Id!], + fields: [ItemFields.ChildCount], }) .then((response) => { return response.data.Items ? resolve(response.data.Items) : resolve([]) diff --git a/src/api/queries/image/utils/index.ts b/src/api/queries/image/utils/index.ts new file mode 100644 index 00000000..f9e6014d --- /dev/null +++ b/src/api/queries/image/utils/index.ts @@ -0,0 +1,23 @@ +import { Api } from '@jellyfin/sdk' +import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models' +import { getImageApi } from '@jellyfin/sdk/lib/utils/api' + +export function getItemImageUrl( + api: Api | undefined, + item: BaseItemDto, + type: ImageType, +): string | undefined { + const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item + + if (!api) return undefined + + return AlbumId + ? getImageApi(api).getItemImageUrlById(AlbumId, type, { + tag: AlbumPrimaryImageTag ?? undefined, + }) + : Id + ? getImageApi(api).getItemImageUrlById(Id, type, { + tag: ImageTags ? ImageTags[type] : undefined, + }) + : undefined +} diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 3c257338..ef033326 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -107,11 +107,7 @@ export default function Albums({ ) : typeof album === 'number' ? null : typeof album === 'object' ? ( - + ) : null } ListEmptyComponent={ diff --git a/src/components/Artist/albums.tsx b/src/components/Artist/albums.tsx deleted file mode 100644 index 1f4c8018..00000000 --- a/src/components/Artist/albums.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { ItemCard } from '../Global/components/item-card' -import { ArtistAlbumsProps, ArtistEpsProps, ArtistFeaturedOnProps } from './types' -import { Text } from '../Global/helpers/text' -import { useArtistContext } from '../../providers/Artist' -import { convertRunTimeTicksToSeconds } from '../../utils/runtimeticks' -import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated' -import { ActivityIndicator } from 'react-native' -import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { getToken } from 'tamagui' -import navigationRef from '../../../navigation' -export default function Albums({ - route, - navigation, -}: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element { - const { width } = useSafeAreaFrame() - const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext() - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - 'worklet' - scroll.value = event.contentOffset.y - }, - }) - - const [columns, setColumns] = useState(Math.floor(width / getToken('$20'))) - - useEffect(() => { - setColumns(Math.floor(width / getToken('$20'))) - }, [width]) - - return ( - - /** - * If we're displaying albums, limit the album array - * to those that have at least 6 songs or a runtime longer - * than 28 minutes - * - * We have this set to 28 minutes because 30 minutes is the - * physical limitation of an EP record, but digital albums tend to - * fall in the 28 minute range - */ - (route.name === 'ArtistAlbums' && - ((album.ChildCount && album.ChildCount >= 6) || - convertRunTimeTicksToSeconds(album.RunTimeTicks ?? 0) / - 60 > - 28)) || - (route.name === 'ArtistEps' && - ((album.ChildCount && album.ChildCount < 6) || - convertRunTimeTicksToSeconds(album.RunTimeTicks ?? 0) / - 60 <= - 28)), - ) - : [] - } - key={`${route.name}-${columns}`} - keyExtractor={(item) => `${item.Id}-${item.Name}-${columns}`} - numColumns={columns} - renderItem={({ item: album }) => ( - { - navigation.navigate('Album', { - album, - }) - }} - onLongPress={() => { - navigationRef.navigate('Context', { - item: album, - navigation, - }) - }} - /> - )} - onScroll={scrollHandler} - ListEmptyComponent={ - fetchingAlbums ? ( - - ) : ( - - No albums - - ) - } - removeClippedSubviews - /> - ) -} diff --git a/src/components/Artist/header.tsx b/src/components/Artist/header.tsx new file mode 100644 index 00000000..d672bcb1 --- /dev/null +++ b/src/components/Artist/header.tsx @@ -0,0 +1,112 @@ +import { ImageType } from '@jellyfin/sdk/lib/generated-client' +import LinearGradient from 'react-native-linear-gradient' +import { getTokenValue, useTheme, XStack, YStack, ZStack } from 'tamagui' +import Icon from '../Global/components/icon' +import ItemImage from '../Global/components/image' +import { useSafeAreaFrame } from 'react-native-safe-area-context' +import { H5 } from '../Global/helpers/text' +import { useArtistContext } from '../../providers/Artist' +import FavoriteButton from '../Global/components/favorite-button' +import InstantMixButton from '../Global/components/instant-mix-button' +import { useNavigation } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { BaseStackParamList } from '@/src/screens/types' +import IconButton from '../Global/helpers/icon-button' +import { fetchAlbumDiscs } from '../../api/queries/item' +import { useJellifyContext } from '../../providers' +import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' +import { QueuingType } from '../../enums/queuing-type' +import { useNetworkStatus } from '../../stores/network' +import useStreamingDeviceProfile from '../../stores/device-profile' + +export default function ArtistHeader(): React.JSX.Element { + const { width } = useSafeAreaFrame() + + const { api } = useJellifyContext() + + const { artist, albums } = useArtistContext() + + const [networkStatus] = useNetworkStatus() + + const streamingDeviceProfile = useStreamingDeviceProfile() + + const { mutate: loadNewQueue } = useLoadNewQueue() + + const theme = useTheme() + + const navigation = useNavigation>() + + const playArtist = async (shuffled: boolean = false) => { + if (!albums || albums.length === 0) return + + try { + // Get all tracks from all albums + const albumTracksPromises = albums.map((album) => fetchAlbumDiscs(api, album)) + const albumDiscs = await Promise.all(albumTracksPromises) + + // Flatten all tracks from all albums + const allTracks = albumDiscs.flatMap((discs) => discs.flatMap((disc) => disc.data)) + + if (allTracks.length === 0) return + + loadNewQueue({ + api, + networkStatus, + deviceProfile: streamingDeviceProfile, + track: allTracks[0], + index: 0, + tracklist: allTracks, + queue: artist, + queuingType: QueuingType.FromSelection, + shuffled, + startPlayback: true, + }) + } catch (error) { + console.error('Failed to play artist tracks:', error) + } + } + + return ( + + + + + + + + + + +
+ {artist.Name} +
+
+
+ + + + + + + + + + {/* playArtist(true)} /> */} + + + +
+
+ ) +} diff --git a/src/components/Artist/index.tsx b/src/components/Artist/index.tsx index 7d625045..5dfadf7d 100644 --- a/src/components/Artist/index.tsx +++ b/src/components/Artist/index.tsx @@ -1,68 +1,61 @@ -import React, { useMemo } from 'react' -import Albums from './albums' -import SimilarArtists from './similar' -import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' -import ArtistTabBar from './tab-bar' +import React, { useCallback, useMemo } from 'react' import { useArtistContext } from '../../providers/Artist' -import ArtistTabList from './types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { BaseStackParamList } from '@/src/screens/types' - -const ArtistTabs = createMaterialTopTabNavigator() +import { DefaultSectionT, SectionList, SectionListData } from 'react-native' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import ItemRow from '../Global/components/item-row' +import ArtistHeader from './header' +import { Text } from '../Global/helpers/text' export default function ArtistNavigation({ navigation, }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { featuredOn, artist } = useArtistContext() + const { featuredOn, artist, albums } = useArtistContext() - const hasFeaturedOn = useMemo(() => featuredOn && featuredOn.length > 0, [artist]) + const sections: SectionListData[] = useMemo(() => { + return [ + { + title: 'Albums', + data: albums?.filter(({ ChildCount }) => (ChildCount ?? 0) > 6) ?? [], + }, + { + title: 'EPs', + data: + albums?.filter( + ({ ChildCount }) => (ChildCount ?? 0) <= 6 && (ChildCount ?? 0) >= 3, + ) ?? [], + }, + { + title: 'Singles', + data: albums?.filter(({ ChildCount }) => (ChildCount ?? 0) === 1) ?? [], + }, + { + title: '', + data: albums?.filter(({ ChildCount }) => typeof ChildCount !== 'number') ?? [], + }, + ] + }, [artist, albums?.map(({ Id }) => Id)]) + + const renderSectionHeader = useCallback( + ({ section }: { section: SectionListData }) => + section.data.length > 0 ? ( + + {section.title} + + ) : null, + [], + ) return ( - } - screenOptions={{ - tabBarLabelStyle: { - fontFamily: 'Figtree-Bold', - }, - }} - > - - - - - {hasFeaturedOn && ( - - )} - - 20 ? '...' : '' - }`, - }} - component={SimilarArtists} - /> - + } + /> ) } diff --git a/src/components/Artist/tab-bar.tsx b/src/components/Artist/tab-bar.tsx deleted file mode 100644 index 030c2e3b..00000000 --- a/src/components/Artist/tab-bar.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' -import { getTokens, useTheme, XStack, YStack } from 'tamagui' -import { H5 } from '../Global/helpers/text' -import FavoriteButton from '../Global/components/favorite-button' -import InstantMixButton from '../Global/components/instant-mix-button' -import Animated, { Easing, useAnimatedStyle, withTiming } from 'react-native-reanimated' -import FastImage from 'react-native-fast-image' -import { getImageApi } from '@jellyfin/sdk/lib/utils/api' -import { ImageType } from '@jellyfin/sdk/lib/generated-client/models' -import { useArtistContext } from '../../providers/Artist' -import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { useJellifyContext } from '../../providers' -import React from 'react' -import Icon from '../Global/components/icon' -import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' -import { QueuingType } from '../../enums/queuing-type' -import { fetchAlbumDiscs } from '../../api/queries/item' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { BaseStackParamList } from '../../screens/types' -import { useNetworkStatus } from '../../stores/network' -import useStreamingDeviceProfile from '../../stores/device-profile' - -export default function ArtistTabBar({ - stackNavigation, - tabBarProps, -}: { - stackNavigation: Pick, 'navigate' | 'dispatch'> - tabBarProps: MaterialTopTabBarProps -}) { - const { api } = useJellifyContext() - const { artist, scroll, albums } = useArtistContext() - const { mutate: loadNewQueue } = useLoadNewQueue() - - const deviceProfile = useStreamingDeviceProfile() - - const [networkStatus] = useNetworkStatus() - - const { width } = useSafeAreaFrame() - - const theme = useTheme() - - const bannerHeight = getTokens().size['$16'].val - - const playArtist = async (shuffled: boolean = false) => { - if (!albums || albums.length === 0) return - - try { - // Get all tracks from all albums - const albumTracksPromises = albums.map((album) => fetchAlbumDiscs(api, album)) - const albumDiscs = await Promise.all(albumTracksPromises) - - // Flatten all tracks from all albums - const allTracks = albumDiscs.flatMap((discs) => discs.flatMap((disc) => disc.data)) - - if (allTracks.length === 0) return - - loadNewQueue({ - api, - networkStatus, - deviceProfile, - track: allTracks[0], - index: 0, - tracklist: allTracks, - queue: artist, - queuingType: QueuingType.FromSelection, - shuffled, - startPlayback: true, - }) - } catch (error) { - console.error('Failed to play artist tracks:', error) - } - } - - const animatedBannerStyle = useAnimatedStyle(() => { - 'worklet' - const clampedScroll = Math.max(0, Math.min(scroll.value, bannerHeight)) - return { - height: withTiming(bannerHeight - clampedScroll, { - duration: 500, - easing: Easing.inOut(Easing.ease), - }), - } - }) - - return ( - <> - - - - - - -
- {artist.Name} -
-
- - - - - - - playArtist(false)} /> - - playArtist(true)} /> - -
- - - ) -} diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 60a66e71..a64edf04 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -135,12 +135,7 @@ export default function Artists({ ) ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( - + ) : null } stickyHeaderIndices={stickyHeaderIndices} diff --git a/src/components/Context/components/multiple-artists.tsx b/src/components/Context/components/multiple-artists.tsx index 7c97ed0f..013f9649 100644 --- a/src/components/Context/components/multiple-artists.tsx +++ b/src/components/Context/components/multiple-artists.tsx @@ -26,7 +26,6 @@ export default function MultipleArtists({ circular item={artist} key={artist.Id} - queueName={''} onPress={() => { navigation.popToTop() diff --git a/src/components/Global/components/favorite-icon.tsx b/src/components/Global/components/favorite-icon.tsx index 49daa10f..de60f4dd 100644 --- a/src/components/Global/components/favorite-icon.tsx +++ b/src/components/Global/components/favorite-icon.tsx @@ -20,7 +20,7 @@ function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element { ) : ( - + ) } diff --git a/src/components/Global/components/horizontal-list.tsx b/src/components/Global/components/horizontal-list.tsx index ad0cc101..d40c8312 100644 --- a/src/components/Global/components/horizontal-list.tsx +++ b/src/components/Global/components/horizontal-list.tsx @@ -11,13 +11,14 @@ interface HorizontalCardListProps extends FlashListProps {} * @returns */ export default function HorizontalCardList({ - ...props + data, + renderItem, }: HorizontalCardListProps): React.JSX.Element { return ( ) : ( <> ) } +interface ItemBlurhashProps { + item: BaseItemDto + cornered?: boolean | undefined + circular?: boolean | undefined + width?: Token | string | number | string | undefined + height?: Token | string | number | string | undefined + testID?: string | undefined +} + +const AnimatedBlurhash = Animated.createAnimatedComponent(Blurhash) + +const Styles = StyleSheet.create({ + blurhash: { + width: '100%', + height: '100%', + }, +}) + +function ItemBlurhash({ item }: ItemBlurhashProps): React.JSX.Element { + const blurhash = getBlurhashFromDto(item) + + return ( + + ) +} + +interface ImageProps { + imageUrl: string + item: BaseItemDto + cornered?: boolean | undefined + circular?: boolean | undefined + width?: Token | string | number | string | undefined + height?: Token | string | number | string | undefined + testID?: string | undefined +} + +const AnimatedNitroImage = Animated.createAnimatedComponent(NitroImage) + +function Image({ + item, + imageUrl, + width, + height, + circular, + cornered, + testID, +}: ImageProps): React.JSX.Element { + const { image } = useImage({ url: imageUrl }) + + const imageViewStyle = useMemo( + () => + StyleSheet.create({ + view: { + borderRadius: cornered + ? 0 + : width + ? getBorderRadius(circular, width) + : circular + ? getTokenValue('$20') * 10 + : getTokenValue('$2'), + width: !isUndefined(width) + ? typeof width === 'number' + ? width + : typeof width === 'string' && width.includes('%') + ? width + : getTokenValue(width as Token) + : '100%', + height: !isUndefined(height) + ? typeof height === 'number' + ? height + : typeof height === 'string' && height.includes('%') + ? height + : getTokenValue(height as Token) + : '100%', + alignSelf: 'center', + overflow: 'hidden', + }, + }), + [cornered, circular, width, height], + ) + + return ( + + {image ? ( + + ) : ( + + )} + + ) +} + /** * Get the border radius for the image * @param circular - Whether the image is circular * @param width - The width of the image * @returns The border radius of the image */ -function getBorderRadius(circular: boolean | undefined, width: Token | number | undefined): number { +function getBorderRadius( + circular: boolean | undefined, + width: Token | string | number | string, +): number { let borderRadius if (circular) { - borderRadius = width ? (typeof width === 'number' ? width : getTokenValue(width)) : '100%' + borderRadius = + typeof width === 'number' + ? width + : typeof width === 'string' && width.includes('%') + ? width + : getTokenValue(width as Token) } else if (!isUndefined(width)) { - borderRadius = typeof width === 'number' ? width / 25 : getTokenValue(width) / 15 - } else borderRadius = '5%' + borderRadius = + typeof width === 'number' + ? width / 25 + : typeof width === 'string' && width.includes('%') + ? 0 + : getTokenValue(width as Token) / 15 + } else borderRadius = getTokenValue('$2') return borderRadius } diff --git a/src/components/Global/components/instant-mix-button.tsx b/src/components/Global/components/instant-mix-button.tsx index 71e5611c..c63072ee 100644 --- a/src/components/Global/components/instant-mix-button.tsx +++ b/src/components/Global/components/instant-mix-button.tsx @@ -24,7 +24,7 @@ export default function InstantMixButton({ return data ? ( navigation.navigate('InstantMix', { diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index aba1a51b..6db49d6e 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -16,13 +16,15 @@ import { useJellifyContext } from '../../../providers' import { useNetworkStatus } from '../../../stores/network' import useStreamingDeviceProfile from '../../../stores/device-profile' import useItemContext from '../../../hooks/use-item-context' +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native' +import { useCallback } from 'react' interface ItemRowProps { item: BaseItemDto - navigation?: Pick, 'navigate' | 'dispatch'> - queueName: string - onPress?: () => void + queueName?: string circular?: boolean + onPress?: () => void + navigation?: Pick, 'navigate' | 'dispatch'> } /** @@ -38,9 +40,9 @@ interface ItemRowProps { */ export default function ItemRow({ item, + circular, navigation, onPress, - circular, }: ItemRowProps): React.JSX.Element { const { api } = useJellifyContext() @@ -52,130 +54,138 @@ export default function ItemRow({ const warmContext = useItemContext() - const gestureCallback = () => { - switch (item.Type) { - case 'Audio': { - loadNewQueue({ - api, - networkStatus, - deviceProfile, - track: item, - tracklist: [item], - index: 0, - queue: 'Search', - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - break - } - default: { - break - } - } - } + const onPressIn = useCallback(() => warmContext(item), [warmContext, item]) - const gesture = Gesture.Tap().onEnd(() => { - 'worklet' - runOnJS(gestureCallback)() - }) + const onLongPress = useCallback( + () => + navigationRef.navigate('Context', { + item, + navigation, + }), + [navigationRef], + ) + + const onPressCallback = useCallback(() => { + if (onPress) onPress() + else + switch (item.Type) { + case 'Audio': { + loadNewQueue({ + api, + networkStatus, + deviceProfile, + track: item, + tracklist: [item], + index: 0, + queue: '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 + } + } + }, [loadNewQueue, item, navigation]) + + const renderRunTime = item.Type === BaseItemKind.Audio return ( - - warmContext(item)} - onLongPress={() => { - navigationRef.navigate('Context', { - item, - navigation, - }) - }} - onPress={() => { - if (onPress) { - onPress() - return - } + + + + - switch (item.Type) { - case 'MusicArtist': { - navigation?.navigate('Artist', { artist: item }) - break - } + - case 'MusicAlbum': { - navigation?.navigate('Album', { album: item }) - break - } - } - }} - paddingVertical={'$2'} - paddingRight={'$2'} - > - - - + + {renderRunTime ? ( + {item.RunTimeTicks} + ) : ['Playlist'].includes(item.Type ?? '') ? ( + {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`} + ) : null} + - - - {item.Name ?? ''} - - - {(item.Type === 'Audio' || item.Type === 'MusicAlbum') && ( - - {item.AlbumArtist ?? 'Untitled Artist'} - - )} - - {(item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist) && ( - - {item.Genres?.join(', ') ?? ''} - - )} - - - - - {/* Runtime ticks for Songs */} - {['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? ( - {item.RunTimeTicks} - ) : ['Playlist'].includes(item.Type ?? '') ? ( - {`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`} - ) : null} - - {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( - { - navigationRef.navigate('Context', { - item, - navigation, - }) - }} - /> - ) : null} - + {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.ProductionYear?.toString() ?? 'Unknown Year'} + + + + + {item.RunTimeTicks} + + )} + + {shouldRenderGenres && ( + + {item.Genres?.join(', ') ?? ''} + + )} + ) } diff --git a/src/components/Global/helpers/button.tsx b/src/components/Global/helpers/button.tsx index e0aa0ee4..b185730c 100644 --- a/src/components/Global/helpers/button.tsx +++ b/src/components/Global/helpers/button.tsx @@ -8,5 +8,5 @@ interface ButtonProps extends TamaguiButtonProps { } export default function Button(props: ButtonProps): React.JSX.Element { - return + return } diff --git a/src/components/Player/components/blurred-background.tsx b/src/components/Player/components/blurred-background.tsx index d451e38d..73f2d36b 100644 --- a/src/components/Player/components/blurred-background.tsx +++ b/src/components/Player/components/blurred-background.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react' import { getToken, useTheme, View, YStack, ZStack } from 'tamagui' import { useColorScheme } from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import { getPrimaryBlurhashFromDto } from '../../../utils/blurhash' +import { getBlurhashFromDto } from '../../../utils/blurhash' import { Blurhash } from 'react-native-blurhash' import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' import { useNowPlaying } from '../../../providers/Player/hooks/queries' @@ -27,7 +27,7 @@ function BlurredBackground({ themeSetting === 'dark' || (themeSetting === 'system' && colorScheme === 'dark') // Get blurhash safely - const blurhash = nowPlaying?.item ? getPrimaryBlurhashFromDto(nowPlaying.item) : null + const blurhash = nowPlaying?.item ? getBlurhashFromDto(nowPlaying.item) : null // Define gradient colors const darkGradientColors = [getToken('$black'), getToken('$black25')] diff --git a/src/components/Player/components/lyrics.tsx b/src/components/Player/components/lyrics.tsx index ffb96e05..2df4f896 100644 --- a/src/components/Player/components/lyrics.tsx +++ b/src/components/Player/components/lyrics.tsx @@ -15,10 +15,9 @@ import Animated, { interpolateColor, withTiming, useAnimatedScrollHandler, - runOnJS, + SharedValue, } from 'react-native-reanimated' -import { FlatList, ListRenderItem, Platform } from 'react-native' -import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons' +import { FlatList, ListRenderItem } from 'react-native' import { trigger } from 'react-native-haptic-feedback' import Icon from '../../Global/components/icon' @@ -46,7 +45,7 @@ const LyricLineItem = React.memo( }: { item: ParsedLyricLine index: number - currentLineIndex: Animated.SharedValue + currentLineIndex: SharedValue onPress: (startTime: number, index: number) => void }) => { const theme = useTheme() diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 24f6779d..45075775 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -26,7 +26,6 @@ import { useNowPlaying } from '../../providers/Player/hooks/queries' import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations' export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { - const { api } = useJellifyContext() const { data: nowPlaying } = useNowPlaying() const { mutate: skip } = useSkip() const { mutate: previous } = usePrevious() @@ -92,19 +91,17 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { - {api && ( - - - - )} + + + - + diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 8b081427..87fd34f5 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -37,14 +37,7 @@ export default function Playlists({ } ItemSeparatorComponent={() => } renderItem={({ index, item: playlist }) => ( - { - navigation.navigate('Playlist', { playlist, canEdit }) - }} - queueName={playlist.Name ?? 'Untitled Playlist'} - navigation={navigation} - /> + )} onEndReached={() => { if (hasNextPage) { diff --git a/src/screens/Home/index.tsx b/src/screens/Home/index.tsx index d6aa827f..c85db7b1 100644 --- a/src/screens/Home/index.tsx +++ b/src/screens/Home/index.tsx @@ -3,13 +3,15 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' import { PlaylistScreen } from '../Playlist' import { Home as HomeComponent } from '../../components/Home' import { ArtistScreen } from '../Artist' -import { useTheme } from 'tamagui' +import { useTheme, XStack } from 'tamagui' import HomeArtistsScreen from './artists' import HomeTracksScreen from './tracks' import AlbumScreen from '../Album' import HomeStackParamList from './types' import InstantMix from '../../components/InstantMix/component' import { getItemName } from '../../utils/text' +import FavoriteButton from '../../components/Global/components/favorite-button' +import InstantMixButton from '../../components/Global/components/instant-mix-button' const HomeStack = createNativeStackNavigator() diff --git a/src/utils/blurhash.ts b/src/utils/blurhash.ts index bbb1047f..1a09f0f5 100644 --- a/src/utils/blurhash.ts +++ b/src/utils/blurhash.ts @@ -1,13 +1,14 @@ -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models' -export function getPrimaryBlurhashFromDto(item: BaseItemDto) { - const blurhashKey: string | undefined = item.ImageBlurHashes!.Primary - ? Object.keys(item.ImageBlurHashes!.Primary)[0] - : undefined +export function getBlurhashFromDto( + { ImageBlurHashes }: BaseItemDto, + type: ImageType = ImageType.Primary, +) { + if (!ImageBlurHashes || !ImageBlurHashes[type]) return '' - const blurhashValue: string | undefined = blurhashKey - ? item.ImageBlurHashes!.Primary![blurhashKey!] - : undefined + const blurhashKey: string = Object.keys(ImageBlurHashes[type])[0] + + const blurhashValue: string = ImageBlurHashes[type][blurhashKey] return blurhashValue } diff --git a/tamagui.config.ts b/tamagui.config.ts index 5f708f08..bc1ab9d7 100644 --- a/tamagui.config.ts +++ b/tamagui.config.ts @@ -11,7 +11,11 @@ const tokens = createTokens({ successDark: '#99ffcc', purple: '#100538', purpleGray: '#66617B', - amethyst: '#7E72AF', + + amethyst: 'rgba(126, 114, 175, 1)', + amethyst25: 'rgba(126, 114, 175, 0.25)', + amethyst50: 'rgba(126, 114, 175, 0.5)', + amethyst75: 'rgba(126, 114, 175, 0.75)', secondary: '#cc2f71', @@ -19,9 +23,19 @@ const tokens = createTokens({ primaryDark: '#887BFF', white: '#ffffff', neutral: '#77748E', - darkBackground: '#111014', + + darkBackground: 'rgb(17, 16, 20)', + darkBackground75: 'rgba(17, 16, 20, 0.75)', + darkBackground50: 'rgba(17, 16, 20, 0.5)', + darkBackground25: 'rgba(17, 16, 20, 0.25)', + darkBorder: '#CEAAFF', - lightBackground: '#EBDDFF', + + lightBackground: 'rgb(235, 221, 255)', + lightBackground75: 'rgba(235, 221, 255, 0.75)', + lightBackground50: 'rgba(235, 221, 255, 0.5)', + lightBackground25: 'rgba(235, 221, 255, 0.25)', + black: '#000000', black10: 'rgba(0, 0, 0, 0.1)', black25: 'rgba(0, 0, 0, 0.25)', @@ -45,6 +59,9 @@ const jellifyConfig = createTamagui({ themes: { dark: { background: tokens.color.darkBackground, + background75: tokens.color.darkBackground75, + background50: tokens.color.darkBackground50, + background25: tokens.color.darkBackground25, backgroundActive: tokens.color.amethyst, backgroundPress: tokens.color.amethyst, backgroundFocus: tokens.color.amethyst, @@ -63,6 +80,9 @@ const jellifyConfig = createTamagui({ color: tokens.color.purpleDark, borderColor: tokens.color.amethyst, background: tokens.color.amethyst, + background25: tokens.color.amethyst25, + background50: tokens.color.amethyst50, + background75: tokens.color.amethyst75, success: tokens.color.successDark, secondary: tokens.color.secondary, primary: tokens.color.primaryDark, @@ -73,6 +93,9 @@ const jellifyConfig = createTamagui({ }, light: { background: tokens.color.white, + background75: tokens.color.lightBackground75, + background50: tokens.color.lightBackground50, + background25: tokens.color.lightBackground25, backgroundActive: tokens.color.amethyst, borderColor: tokens.color.neutral, color: tokens.color.purpleDark, @@ -87,7 +110,10 @@ const jellifyConfig = createTamagui({ light_inverted_purple: { color: tokens.color.purpleDark, borderColor: tokens.color.neutral, - background: tokens.color.purpleGray, + background: tokens.color.amethyst, + background25: tokens.color.amethyst25, + background50: tokens.color.amethyst50, + background75: tokens.color.amethyst75, success: tokens.color.success, secondary: tokens.color.secondary, primary: tokens.color.primaryLight, diff --git a/yarn.lock b/yarn.lock index 9849953e..2b4b8cbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8486,11 +8486,6 @@ react-native-draggable-flatlist@^4.0.3: dependencies: "@babel/preset-typescript" "^7.17.12" -react-native-fast-image@^8.6.3: - version "8.6.3" - resolved "https://registry.yarnpkg.com/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz#6edc3f9190092a909d636d93eecbcc54a8822255" - integrity sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg== - react-native-flashdrag-list@^0.2.5: version "0.2.5" resolved "https://registry.yarnpkg.com/react-native-flashdrag-list/-/react-native-flashdrag-list-0.2.5.tgz#003a3c56a6ff701177cd20311446afc6a4fa56c1" @@ -8538,6 +8533,21 @@ react-native-mmkv@3.3.0: resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.3.0.tgz#a0816babdf68afd2bfd881a48e28c8815795286b" integrity sha512-2iPjIJ+IAODXN35wm53EN6nv+hR/0NXLLiLWOdPA/0gXDB2dYVrr6MqvoiFpxhLhdj0B8fkf5ARNVavJaSvuuQ== +react-native-nitro-image@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/react-native-nitro-image/-/react-native-nitro-image-0.6.1.tgz#bf4d37ed52d435f751a27017fade4a6d7b7fb9a6" + integrity sha512-LKttegj2fZ6NdmqZG531LB2NteW5TbGy/vbyHFtTX39g73it3R5jss+IMYmuHJHjhbFse/wu2yP+zh3MXAa6Jg== + +react-native-nitro-modules@^0.29.4: + version "0.29.4" + resolved "https://registry.yarnpkg.com/react-native-nitro-modules/-/react-native-nitro-modules-0.29.4.tgz#e82a243996f4a85b9194a2e2a89970487638e1c2" + integrity sha512-AfUMcwFtj9FuEDwDLN5eIVo0lBYTQqDaV7meiFzuoZyRmc8ywykFTKfyZwRN2t8Z/WtTlfCj9Y9yaET33IImsg== + +react-native-nitro-web-image@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/react-native-nitro-web-image/-/react-native-nitro-web-image-0.6.1.tgz#030d0db7828846d1ba1c425add6aefe0aeed936b" + integrity sha512-WblMIuxLWmy8oqPMmMoBJRVrUafEPhARu4vs9rMvtFDIarGNIu9zSSXZuTQP7f9PlM1UezV47RY2gzn0e3Ersg== + react-native-ota-hot-update@2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/react-native-ota-hot-update/-/react-native-ota-hot-update-2.3.1.tgz#c4c2e32e1c5faef13bdbb3a21e51d39f7b305daa"