mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-22 01:28:28 -05:00
Nitro Images + Blurhashes (#503)
Migrate from `react-native-fast-image` to `react-native-nitro-image` Images will now display a blurry placeholder while loading, and then animate into the full image when loaded Introduces a redesign to the artist page that is easier to navigate, especially when there are many albums belonging to a given artist --------- Co-authored-by: riteshshukla04 <riteshshukla2381@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+104
-28
@@ -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
|
||||
|
||||
+55
-37
@@ -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<ImageProps, 'source'> {
|
||||
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(),
|
||||
},
|
||||
}))
|
||||
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -107,11 +107,7 @@ export default function Albums({
|
||||
</Text>
|
||||
</XStack>
|
||||
) : typeof album === 'number' ? null : typeof album === 'object' ? (
|
||||
<ItemRow
|
||||
item={album}
|
||||
queueName={album.Name ?? 'Unknown Album'}
|
||||
navigation={navigation}
|
||||
/>
|
||||
<ItemRow item={album} navigation={navigation} />
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
|
||||
@@ -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 (
|
||||
<Animated.FlatList
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-start',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
data={
|
||||
route.name === 'ArtistFeaturedOn' && featuredOn
|
||||
? featuredOn
|
||||
: albums
|
||||
? albums.filter(
|
||||
(album) =>
|
||||
/**
|
||||
* 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 }) => (
|
||||
<ItemCard
|
||||
caption={album.Name}
|
||||
subCaption={album.ProductionYear?.toString()}
|
||||
size={'$14'}
|
||||
squared
|
||||
item={album}
|
||||
onPress={() => {
|
||||
navigation.navigate('Album', {
|
||||
album,
|
||||
})
|
||||
}}
|
||||
onLongPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item: album,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onScroll={scrollHandler}
|
||||
ListEmptyComponent={
|
||||
fetchingAlbums ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Text textAlign='center' justifyContent='center'>
|
||||
No albums
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<NativeStackNavigationProp<BaseStackParamList>>()
|
||||
|
||||
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 (
|
||||
<YStack flex={1}>
|
||||
<ZStack flex={1} height={getTokenValue('$20')}>
|
||||
<ItemImage
|
||||
item={artist}
|
||||
width={width}
|
||||
height={'$20'}
|
||||
type={ImageType.Backdrop}
|
||||
cornered
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={['transparent', theme.background.val]}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</ZStack>
|
||||
|
||||
<YStack alignItems='center' marginHorizontal={'$3'} backgroundColor={'$background'}>
|
||||
<XStack alignItems='flex-end' justifyContent='flex-start' flex={1}>
|
||||
<XStack alignItems='center' flex={1} justifyContent='space-between'>
|
||||
<H5 flexGrow={1} fontWeight={'bold'} maxWidth={'75%'}>
|
||||
{artist.Name}
|
||||
</H5>
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems='center' justifyContent='space-between' flex={1}>
|
||||
<XStack alignItems='center' gap={'$3'} flex={1}>
|
||||
<FavoriteButton item={artist} />
|
||||
|
||||
<InstantMixButton item={artist} navigation={navigation} />
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems='center' justifyContent='flex-end' gap={'$3'} flex={1}>
|
||||
{/* <Icon name='shuffle' onPress={() => playArtist(true)} /> */}
|
||||
<IconButton circular name='play' onPress={playArtist} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -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<ArtistTabList>()
|
||||
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<BaseStackParamList>
|
||||
}): React.JSX.Element {
|
||||
const { featuredOn, artist } = useArtistContext()
|
||||
const { featuredOn, artist, albums } = useArtistContext()
|
||||
|
||||
const hasFeaturedOn = useMemo(() => featuredOn && featuredOn.length > 0, [artist])
|
||||
const sections: SectionListData<BaseItemDto>[] = 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<BaseItemDto, DefaultSectionT> }) =>
|
||||
section.data.length > 0 ? (
|
||||
<Text padding={'$3'} fontSize={'$6'} bold backgroundColor={'$background'}>
|
||||
{section.title}
|
||||
</Text>
|
||||
) : null,
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<ArtistTabs.Navigator
|
||||
tabBar={(props) => <ArtistTabBar stackNavigation={navigation} tabBarProps={props} />}
|
||||
screenOptions={{
|
||||
tabBarLabelStyle: {
|
||||
fontFamily: 'Figtree-Bold',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArtistTabs.Screen
|
||||
name='ArtistAlbums'
|
||||
options={{
|
||||
title: 'Albums',
|
||||
}}
|
||||
component={Albums}
|
||||
/>
|
||||
|
||||
<ArtistTabs.Screen
|
||||
name='ArtistEps'
|
||||
options={{
|
||||
title: 'Singles & EPs',
|
||||
}}
|
||||
component={Albums}
|
||||
/>
|
||||
|
||||
{hasFeaturedOn && (
|
||||
<ArtistTabs.Screen
|
||||
name='ArtistFeaturedOn'
|
||||
options={{
|
||||
title: 'Featured On',
|
||||
}}
|
||||
component={Albums}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ArtistTabs.Screen
|
||||
name='SimilarArtists'
|
||||
options={{
|
||||
title: `Similar to ${artist.Name?.slice(0, 20) ?? 'Unknown Artist'}${
|
||||
artist.Name && artist.Name.length > 20 ? '...' : ''
|
||||
}`,
|
||||
}}
|
||||
component={SimilarArtists}
|
||||
/>
|
||||
</ArtistTabs.Navigator>
|
||||
<SectionList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
sections={sections}
|
||||
ListHeaderComponent={ArtistHeader}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
renderItem={({ item }) => <ItemRow item={item} navigation={navigation} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<NativeStackNavigationProp<BaseStackParamList>, '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 (
|
||||
<>
|
||||
<Animated.View style={[animatedBannerStyle]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: artist.Id
|
||||
? getImageApi(api!).getItemImageUrlById(artist.Id, ImageType.Backdrop)
|
||||
: '',
|
||||
}}
|
||||
style={{
|
||||
width: width,
|
||||
height: '100%',
|
||||
backgroundColor: theme.borderColor.val,
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<YStack alignItems='center' marginHorizontal={'$2'} height={'$9'}>
|
||||
<XStack alignItems='center' justifyContent='center' flex={1}>
|
||||
<H5
|
||||
textAlign='center'
|
||||
numberOfLines={1}
|
||||
flex={1}
|
||||
lineBreakStrategyIOS='standard'
|
||||
>
|
||||
{artist.Name}
|
||||
</H5>
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems='center' justifyContent='center' flex={1} gap={'$6'}>
|
||||
<FavoriteButton item={artist} />
|
||||
|
||||
<InstantMixButton item={artist} navigation={stackNavigation} />
|
||||
|
||||
<Icon name='play' onPress={() => playArtist(false)} />
|
||||
|
||||
<Icon name='shuffle' onPress={() => playArtist(true)} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
<MaterialTopTabBar {...tabBarProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -135,12 +135,7 @@ export default function Artists({
|
||||
</XStack>
|
||||
)
|
||||
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
|
||||
<ItemRow
|
||||
circular
|
||||
item={artist}
|
||||
queueName={artist.Name ?? 'Unknown Artist'}
|
||||
navigation={navigation}
|
||||
/>
|
||||
<ItemRow circular item={artist} navigation={navigation} />
|
||||
) : null
|
||||
}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function MultipleArtists({
|
||||
circular
|
||||
item={artist}
|
||||
key={artist.Id}
|
||||
queueName={''}
|
||||
onPress={() => {
|
||||
navigation.popToTop()
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
<Icon small name='heart' color={'$primary'} flex={1} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Spacer flex={0.5} />
|
||||
<Spacer />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ interface HorizontalCardListProps extends FlashListProps<BaseItemDto> {}
|
||||
* @returns
|
||||
*/
|
||||
export default function HorizontalCardList({
|
||||
...props
|
||||
data,
|
||||
renderItem,
|
||||
}: HorizontalCardListProps): React.JSX.Element {
|
||||
return (
|
||||
<FlashList
|
||||
horizontal
|
||||
data={props.data}
|
||||
renderItem={props.renderItem}
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -1,90 +1,187 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { getTokenValue, Token, useTheme } from 'tamagui'
|
||||
import { getTokenValue, Token, View } from 'tamagui'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { ImageStyle } from 'react-native'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import { StyleSheet, ViewStyle } from 'react-native'
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { NitroImage, useImage } from 'react-native-nitro-image'
|
||||
import { Blurhash } from 'react-native-blurhash'
|
||||
import { getBlurhashFromDto } from '../../../utils/blurhash'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { getItemImageUrl } from '../../../api/queries/image/utils'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface ImageProps {
|
||||
interface ItemImageProps {
|
||||
item: BaseItemDto
|
||||
type?: ImageType
|
||||
cornered?: boolean | undefined
|
||||
circular?: boolean | undefined
|
||||
width?: Token | number | undefined
|
||||
height?: Token | number | undefined
|
||||
style?: ImageStyle | undefined
|
||||
width?: Token | number | string | undefined
|
||||
height?: Token | number | string | undefined
|
||||
testID?: string | undefined
|
||||
}
|
||||
|
||||
export default function ItemImage({
|
||||
item,
|
||||
type = ImageType.Primary,
|
||||
cornered,
|
||||
circular,
|
||||
width,
|
||||
height,
|
||||
style,
|
||||
testID,
|
||||
}: ImageProps): React.JSX.Element {
|
||||
}: ItemImageProps): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
const theme = useTheme()
|
||||
|
||||
const imageUrl =
|
||||
api &&
|
||||
((item.AlbumId &&
|
||||
getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary, {
|
||||
tag: item.ImageTags?.Primary,
|
||||
})) ||
|
||||
(item.Id &&
|
||||
getImageApi(api).getItemImageUrlById(item.Id, ImageType.Primary, {
|
||||
tag: item.ImageTags?.Primary,
|
||||
})) ||
|
||||
'')
|
||||
const imageUrl = getItemImageUrl(api, item, type)
|
||||
|
||||
return api && imageUrl ? (
|
||||
<FastImage
|
||||
source={{ uri: imageUrl }}
|
||||
return api ? (
|
||||
<Image
|
||||
item={item}
|
||||
imageUrl={imageUrl!}
|
||||
testID={testID}
|
||||
resizeMode='cover'
|
||||
style={{
|
||||
shadowRadius: getTokenValue('$4'),
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -getTokenValue('$4'),
|
||||
},
|
||||
shadowColor: theme.borderColor.val,
|
||||
borderRadius: getBorderRadius(circular, width),
|
||||
width: !isUndefined(width)
|
||||
? typeof width === 'number'
|
||||
? width
|
||||
: getTokenValue(width)
|
||||
: '100%',
|
||||
height: !isUndefined(height)
|
||||
? typeof height === 'number'
|
||||
? height
|
||||
: getTokenValue(height)
|
||||
: '100%',
|
||||
alignSelf: 'center',
|
||||
backgroundColor: theme.borderColor.val,
|
||||
}}
|
||||
height={height}
|
||||
width={width}
|
||||
circular={circular}
|
||||
cornered={cornered}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatedBlurhash
|
||||
resizeMode={'cover'}
|
||||
style={Styles.blurhash}
|
||||
blurhash={blurhash}
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={imageViewStyle.view} justifyContent='center' alignContent='center'>
|
||||
{image ? (
|
||||
<AnimatedNitroImage
|
||||
resizeMode='cover'
|
||||
recyclingKey={imageUrl}
|
||||
image={image}
|
||||
testID={testID}
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
style={Styles.blurhash}
|
||||
/>
|
||||
) : (
|
||||
<ItemBlurhash item={item} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function InstantMixButton({
|
||||
|
||||
return data ? (
|
||||
<Icon
|
||||
name='compass-outline'
|
||||
name='radio'
|
||||
color={'$success'}
|
||||
onPress={() =>
|
||||
navigation.navigate('InstantMix', {
|
||||
|
||||
@@ -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<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
queueName: string
|
||||
onPress?: () => void
|
||||
queueName?: string
|
||||
circular?: boolean
|
||||
onPress?: () => void
|
||||
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, '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 (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<XStack
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onPressIn={() => warmContext(item)}
|
||||
onLongPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
onPress={() => {
|
||||
if (onPress) {
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
<XStack
|
||||
alignContent='center'
|
||||
minHeight={'$7'}
|
||||
width={'100%'}
|
||||
onPressIn={onPressIn}
|
||||
onPress={onPressCallback}
|
||||
onLongPress={onLongPress}
|
||||
animation={'quick'}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
switch (item.Type) {
|
||||
case 'MusicArtist': {
|
||||
navigation?.navigate('Artist', { artist: item })
|
||||
break
|
||||
}
|
||||
<ItemRowDetails item={item} />
|
||||
|
||||
case 'MusicAlbum': {
|
||||
navigation?.navigate('Album', { album: item })
|
||||
break
|
||||
}
|
||||
}
|
||||
}}
|
||||
paddingVertical={'$2'}
|
||||
paddingRight={'$2'}
|
||||
>
|
||||
<YStack marginHorizontal={'$3'} justifyContent='center'>
|
||||
<ItemImage
|
||||
item={item}
|
||||
height={'$12'}
|
||||
width={'$12'}
|
||||
circular={item.Type === 'MusicArtist' || circular}
|
||||
/>
|
||||
</YStack>
|
||||
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
|
||||
{renderRunTime ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
<FavoriteIcon item={item} />
|
||||
|
||||
<YStack alignContent='center' justifyContent='center' flex={4}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Name ?? ''}
|
||||
</Text>
|
||||
|
||||
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.AlbumArtist ?? 'Untitled Artist'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist) && (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
lineBreakStrategyIOS='standard'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.Genres?.join(', ') ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<XStack
|
||||
justifyContent='flex-end'
|
||||
alignItems='center'
|
||||
flex={['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? 2 : 1}
|
||||
>
|
||||
<FavoriteIcon item={item} />
|
||||
{/* Runtime ticks for Songs */}
|
||||
{['Audio', 'MusicAlbum'].includes(item.Type ?? '') ? (
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
) : ['Playlist'].includes(item.Type ?? '') ? (
|
||||
<Text
|
||||
color={'$borderColor'}
|
||||
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
|
||||
) : null}
|
||||
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon
|
||||
name='dots-horizontal'
|
||||
onPress={() => {
|
||||
navigationRef.navigate('Context', {
|
||||
item,
|
||||
navigation,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
|
||||
<Icon name='dots-horizontal' onPress={onLongPress} />
|
||||
) : null}
|
||||
</XStack>
|
||||
</GestureDetector>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
|
||||
const route = useRoute<RouteProp<BaseStackParamList>>()
|
||||
|
||||
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 (
|
||||
<YStack alignContent='center' justifyContent='center' flex={5}>
|
||||
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Name ?? ''}
|
||||
</Text>
|
||||
|
||||
{shouldRenderArtistName && (
|
||||
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.AlbumArtist ?? 'Untitled Artist'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{shouldRenderProductionYear && (
|
||||
<XStack gap='$2'>
|
||||
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.ProductionYear?.toString() ?? 'Unknown Year'}
|
||||
</Text>
|
||||
|
||||
<Text color={'$borderColor'}>•</Text>
|
||||
|
||||
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{shouldRenderGenres && (
|
||||
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
|
||||
{item.Genres?.join(', ') ?? ''}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ interface ButtonProps extends TamaguiButtonProps {
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps): React.JSX.Element {
|
||||
return <TamaguiButton opacity={props.disabled ? 0.5 : 1} marginVertical={30} {...props} />
|
||||
return <TamaguiButton opacity={props.disabled ? 0.5 : 1} marginVertical={'$2'} {...props} />
|
||||
}
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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<number>
|
||||
currentLineIndex: SharedValue<number>
|
||||
onPress: (startTime: number, index: number) => void
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -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 {
|
||||
<MiniPlayerProgress />
|
||||
<XStack paddingBottom={'$1'} alignItems='center' onPress={openPlayer}>
|
||||
<YStack justify='center' alignItems='center' marginLeft={'$2'}>
|
||||
{api && (
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
key={`${nowPlaying!.item.AlbumId}-album-image`}
|
||||
>
|
||||
<ItemImage
|
||||
item={nowPlaying!.item}
|
||||
width={'$12'}
|
||||
height={'$12'}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
key={`${nowPlaying!.item.AlbumId}-album-image`}
|
||||
>
|
||||
<ItemImage
|
||||
item={nowPlaying!.item}
|
||||
width={'$12'}
|
||||
height={'$12'}
|
||||
/>
|
||||
</Animated.View>
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
|
||||
@@ -7,7 +7,6 @@ import InstantMixButton from '../../Global/components/instant-mix-button'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { usePlaylistContext } from '../../../providers/Playlist'
|
||||
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
@@ -18,10 +17,12 @@ import { mapDtoToTrack } from '../../../utils/mappings'
|
||||
import { QueuingType } from '../../../enums/queuing-type'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import LibraryStackParamList from '@/src/screens/Library/types'
|
||||
import { NitroImage } from 'react-native-nitro-image'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import useStreamingDeviceProfile, {
|
||||
useDownloadingDeviceProfile,
|
||||
} from '../../../stores/device-profile'
|
||||
import ItemImage from '../../Global/components/image'
|
||||
|
||||
export default function PlayliistTracklistHeader(
|
||||
playlist: BaseItemDto,
|
||||
@@ -91,25 +92,7 @@ export default function PlayliistTracklistHeader(
|
||||
>
|
||||
<YStack justifyContent='center' alignContent='center' padding={'$2'}>
|
||||
<Animated.View style={[animatedArtworkStyle]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri:
|
||||
getImageApi(api!).getItemImageUrlById(
|
||||
playlist.Id!,
|
||||
ImageType.Primary,
|
||||
{
|
||||
tag: playlist.ImageTags?.Primary,
|
||||
},
|
||||
) || '',
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: getToken('$2'),
|
||||
alignSelf: 'center',
|
||||
borderRadius: getToken('$2'),
|
||||
}}
|
||||
/>
|
||||
<ItemImage item={playlist} />
|
||||
</Animated.View>
|
||||
</YStack>
|
||||
|
||||
|
||||
@@ -37,14 +37,7 @@ export default function Playlists({
|
||||
}
|
||||
ItemSeparatorComponent={() => <Separator />}
|
||||
renderItem={({ index, item: playlist }) => (
|
||||
<ItemRow
|
||||
item={playlist}
|
||||
onPress={() => {
|
||||
navigation.navigate('Playlist', { playlist, canEdit })
|
||||
}}
|
||||
queueName={playlist.Name ?? 'Untitled Playlist'}
|
||||
navigation={navigation}
|
||||
/>
|
||||
<ItemRow item={playlist} navigation={navigation} />
|
||||
)}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
|
||||
@@ -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<HomeStackParamList>()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+30
-4
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user