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:
Violet Caulfield
2025-09-15 08:00:54 -05:00
committed by GitHub
parent 87aad65ae9
commit d6aa1d5534
29 changed files with 715 additions and 608 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+1
View File
@@ -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([])
+23
View File
@@ -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
}
+1 -5
View File
@@ -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={
-102
View File
@@ -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
/>
)
}
+112
View File
@@ -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>
)
}
+47 -54
View File
@@ -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} />}
/>
)
}
-127
View File
@@ -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} />
</>
)
}
+1 -6
View File
@@ -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',
+148 -51
View File
@@ -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', {
+132 -122
View File
@@ -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>
)
}
+1 -1
View File
@@ -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')]
+3 -4
View File
@@ -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()
+11 -14
View File
@@ -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
+3 -20
View File
@@ -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>
+1 -8
View File
@@ -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 -1
View File
@@ -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>()
+9 -8
View File
@@ -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
View File
@@ -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,
+15 -5
View File
@@ -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"