From 5f30cfc2b618fc31cfc717b6dd8d37b6896105fc Mon Sep 17 00:00:00 2001 From: Violet Caulfield Date: Wed, 17 Dec 2025 22:45:10 -0600 Subject: [PATCH 01/31] cascade item image test IDs --- src/components/Global/components/image.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Global/components/image.tsx b/src/components/Global/components/image.tsx index 8db6b5e0..4fab5ad6 100644 --- a/src/components/Global/components/image.tsx +++ b/src/components/Global/components/image.tsx @@ -72,12 +72,17 @@ const Styles = StyleSheet.create({ }, }) -function ItemBlurhash({ item, type }: ItemBlurhashProps): React.JSX.Element { +function ItemBlurhash({ item, type, testID }: ItemBlurhashProps): React.JSX.Element { const blurhash = getBlurhashFromDto(item, type) return ( - + ) } @@ -111,7 +116,7 @@ function Image({ const imageSource = { uri: imageUrl } - const blurhash = !isLoaded ? : null + const blurhash = !isLoaded ? : null return ( From 27926cda6e984b0d610f72b2cd2a2ceae6875848 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:33:52 -0600 Subject: [PATCH 02/31] Playlist, Artist Screen Enhancements; Smol Refactors (#861) UI element layout changes for the album screen Playlist screen refresh React Navigation upgrades Rafactor Instant Mix navigation prop structure so it can be more easily used elsewhere --- bun.lock | 26 +- package.json | 10 +- src/api/queries/instant-mix/index.ts | 18 ++ src/api/queries/instant-mix/keys.ts | 9 + .../utils/index.ts} | 4 +- src/components/Album/footer.tsx | 45 +++ src/components/Album/header.tsx | 170 +++++++++++ src/components/Album/index.tsx | 276 ++++-------------- src/components/Artist/header.tsx | 4 +- .../Global/components/SwipeableRow.tsx | 8 +- .../Global/components/instant-mix-button.tsx | 65 +++-- src/components/InstantMix/component.tsx | 25 +- src/components/Playlist/components/header.tsx | 108 ++++--- src/components/Playlist/index.tsx | 140 ++++++--- src/components/Playlists/component.tsx | 2 + .../Settings/components/preferences-tab.tsx | 6 +- .../Settings/components/storage-tab.tsx | 0 .../Storage/DownloadProgressBar.tsx | 61 ---- src/components/Storage/index.tsx | 197 ------------- src/screens/Discover/index.tsx | 3 + .../Settings/storage-management/index.tsx | 39 ++- src/screens/types.d.ts | 1 - src/stores/network/downloads.ts | 2 +- 23 files changed, 589 insertions(+), 630 deletions(-) create mode 100644 src/api/queries/instant-mix/index.ts create mode 100644 src/api/queries/instant-mix/keys.ts rename src/api/queries/{instant-mixes.ts => instant-mix/utils/index.ts} (90%) create mode 100644 src/components/Album/footer.tsx create mode 100644 src/components/Album/header.tsx delete mode 100644 src/components/Settings/components/storage-tab.tsx delete mode 100644 src/components/Storage/DownloadProgressBar.tsx delete mode 100644 src/components/Storage/index.tsx diff --git a/bun.lock b/bun.lock index 4142bf9f..9949f111 100644 --- a/bun.lock +++ b/bun.lock @@ -11,10 +11,10 @@ "@react-native-community/netinfo": "^11.4.1", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-vector-icons/material-design-icons": "12.4.0", - "@react-navigation/bottom-tabs": "7.8.12", - "@react-navigation/material-top-tabs": "7.4.10", - "@react-navigation/native": "7.1.25", - "@react-navigation/native-stack": "7.8.6", + "@react-navigation/bottom-tabs": "7.9.0", + "@react-navigation/material-top-tabs": "7.4.11", + "@react-navigation/native": "7.1.26", + "@react-navigation/native-stack": "7.9.0", "@sentry/react-native": "7.8.0", "@shopify/flash-list": "2.2.0", "@tamagui/config": "1.141.4", @@ -22,7 +22,7 @@ "@tanstack/react-query": "5.90.12", "@tanstack/react-query-persist-client": "5.90.12", "@testing-library/react-native": "13.3.3", - "@typedigital/telemetrydeck-react": "^0.4.1", + "@typedigital/telemetrydeck-react": "0.4.1", "axios": "1.13.2", "bundle": "^2.1.0", "dlx": "^0.2.1", @@ -566,19 +566,19 @@ "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.83.0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-AVnDppwPidQrPrzA4ETr4o9W+40yuijg3EVgFt2hnMldMZkqwPRrgJL2GSreQjCYe1NfM5Yn4Egyy4Kd0yp4Lw=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.12", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-efVt5ydHK+b4ZtjmN81iduaO5dPCmzhLBFwjCR8pV4x4VzUfJmtUJizLqTXpT3WatHdeon2gDPwhhoelsvu/JA=="], + "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.9.0", "", { "dependencies": { "@react-navigation/elements": "^2.9.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.26", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-024FWdHp3ZsE5rP8tmGI4vh+1z3wg8u8E9Frep8eeGoYo1h9rQhvgofQDGxknmrKsb7t8o8Dim+IZSvl57cPFQ=="], - "@react-navigation/core": ["@react-navigation/core@7.13.6", "", { "dependencies": { "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-7QG29HAWOR8wYuPkfTN8L2Po+kE1xn3nsi2sS35sGngq8HYZRHfXvxrhrAZYfFnFq2hUtOhcXnSS6vEWU/5rmA=="], + "@react-navigation/core": ["@react-navigation/core@7.13.7", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-k2ABo3250vq1ovOh/iVwXS6Hwr5PVRGXoPh/ewVFOOuEKTvOx9i//OBzt8EF+HokBxS2HBRlR2b+aCOmscRqBw=="], - "@react-navigation/elements": ["@react-navigation/elements@2.9.2", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g=="], + "@react-navigation/elements": ["@react-navigation/elements@2.9.3", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.26", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-3+eyvWiVPIEf6tN9UdduhOEHcTuNe3R5WovgiVkfH9+jApHMTZDc2loePTpY/i2HDJhObhhChpJzO6BVjrpdYQ=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.10", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "react-native-tab-view": "^4.2.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-sLR7ctxvqhszJwaBmbZa3lPHL++c0LNFiValMnJ39m3JdfpEsahPUXsbsoT4sqBaU7lpm1uYzRsAyfJEci7DrQ=="], + "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.11", "", { "dependencies": { "@react-navigation/elements": "^2.9.3", "color": "^4.2.3", "react-native-tab-view": "^4.2.2" }, "peerDependencies": { "@react-navigation/native": "^7.1.26", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-RSC/f1bSpodnx1oSXw7jNrwe83JddRhb12ehCY8oZZDtrNhm3atSHzlfvHN37i3E8cln7Tmc1ieLxjWrU65n/Q=="], - "@react-navigation/native": ["@react-navigation/native@7.1.25", "", { "dependencies": { "@react-navigation/core": "^7.13.6", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg=="], + "@react-navigation/native": ["@react-navigation/native@7.1.26", "", { "dependencies": { "@react-navigation/core": "^7.13.7", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ=="], - "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.6", "", { "dependencies": { "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-eBY92xb4H53c9jiWriKMOZmQ/Tu9w1qcUrgOA/qjQOvJFbgKF9D6y3e4UuBaDQzjWjLEDZLaiwXe8cwXRb46mg=="], + "@react-navigation/native-stack": ["@react-navigation/native-stack@7.9.0", "", { "dependencies": { "@react-navigation/elements": "^2.9.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.26", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-C/mNPhI0Pnerl7C2cB+6fAkdgSmfKECMERrbyfjx3P6JmEuTC54o+GV1c62FUmlRaRUassVHbtw4EeaY2uLh0g=="], - "@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="], + "@react-navigation/routers": ["@react-navigation/routers@7.5.3", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], @@ -1944,7 +1944,7 @@ "react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="], - "react-native-tab-view": ["react-native-tab-view@4.2.1", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-3fJXlzTVunVg9V+7a83XVcjwIxUO014qkkkpB5z7mMjXf5XVQFxzxUtC6GrdDK57wBA/OglpsP1qx5HvqMPtfw=="], + "react-native-tab-view": ["react-native-tab-view@4.2.2", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], diff --git a/package.json b/package.json index 758c49f2..ccde287d 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,10 @@ "@react-native-community/netinfo": "^11.4.1", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-vector-icons/material-design-icons": "12.4.0", - "@react-navigation/bottom-tabs": "7.8.12", - "@react-navigation/material-top-tabs": "7.4.10", - "@react-navigation/native": "7.1.25", - "@react-navigation/native-stack": "7.8.6", + "@react-navigation/bottom-tabs": "7.9.0", + "@react-navigation/material-top-tabs": "7.4.11", + "@react-navigation/native": "7.1.26", + "@react-navigation/native-stack": "7.9.0", "@sentry/react-native": "7.8.0", "@shopify/flash-list": "2.2.0", "@tamagui/config": "1.141.4", @@ -54,7 +54,7 @@ "@tanstack/react-query": "5.90.12", "@tanstack/react-query-persist-client": "5.90.12", "@testing-library/react-native": "13.3.3", - "@typedigital/telemetrydeck-react": "^0.4.1", + "@typedigital/telemetrydeck-react": "0.4.1", "axios": "1.13.2", "bundle": "^2.1.0", "dlx": "^0.2.1", diff --git a/src/api/queries/instant-mix/index.ts b/src/api/queries/instant-mix/index.ts new file mode 100644 index 00000000..142a14e4 --- /dev/null +++ b/src/api/queries/instant-mix/index.ts @@ -0,0 +1,18 @@ +import { useApi, useJellifyUser } from '../../../../src/stores' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import InstantMixQueryKey from './keys' +import { useQuery } from '@tanstack/react-query' +import { fetchInstantMixFromItem } from './utils' + +const useInstantMix = (item: BaseItemDto) => { + const api = useApi() + + const [user] = useJellifyUser() + + return useQuery({ + queryKey: InstantMixQueryKey(item), + queryFn: () => fetchInstantMixFromItem(api, user, item), + }) +} + +export default useInstantMix diff --git a/src/api/queries/instant-mix/keys.ts b/src/api/queries/instant-mix/keys.ts new file mode 100644 index 00000000..2f1cf66e --- /dev/null +++ b/src/api/queries/instant-mix/keys.ts @@ -0,0 +1,9 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' + +enum InstantMixQueryKeys { + InstantMix = 'INSTANT_MIX', +} + +const InstantMixQueryKey = ({ Id }: BaseItemDto) => [InstantMixQueryKeys.InstantMix, Id] + +export default InstantMixQueryKey diff --git a/src/api/queries/instant-mixes.ts b/src/api/queries/instant-mix/utils/index.ts similarity index 90% rename from src/api/queries/instant-mixes.ts rename to src/api/queries/instant-mix/utils/index.ts index 41b34d5c..5a7566e8 100644 --- a/src/api/queries/instant-mixes.ts +++ b/src/api/queries/instant-mix/utils/index.ts @@ -1,9 +1,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api' import { isUndefined } from 'lodash' -import QueryConfig from '../../configs/query.config' +import QueryConfig from '../../../../configs/query.config' import { Api } from '@jellyfin/sdk' -import { JellifyUser } from '../../types/JellifyUser' +import { JellifyUser } from '../../../../types/JellifyUser' /** * Fetches an instant mix for a given item * @param api The Jellyfin {@link Api} instance diff --git a/src/components/Album/footer.tsx b/src/components/Album/footer.tsx new file mode 100644 index 00000000..81ad7a35 --- /dev/null +++ b/src/components/Album/footer.tsx @@ -0,0 +1,45 @@ +import DiscoverStackParamList from '../../screens/Discover/types' +import HomeStackParamList from '../../screens/Home/types' +import LibraryStackParamList from '../../screens/Library/types' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { useNavigation } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { FlashList } from '@shopify/flash-list' +import { YStack, H5 } from 'tamagui' +import { ItemCard } from '../Global/components/item-card' + +export default function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element { + const navigation = + useNavigation< + NativeStackNavigationProp< + HomeStackParamList | LibraryStackParamList | DiscoverStackParamList + > + >() + + return ( + + {album.ArtistItems && album.ArtistItems.length > 1 && ( + <> +
Featuring
+ + ( + { + navigation.navigate('Artist', { + artist, + }) + }} + /> + )} + /> + + )} +
+ ) +} diff --git a/src/components/Album/header.tsx b/src/components/Album/header.tsx new file mode 100644 index 00000000..d51b2b2e --- /dev/null +++ b/src/components/Album/header.tsx @@ -0,0 +1,170 @@ +import { fetchAlbumDiscs } from '../../api/queries/item' +import { QueryKeys } from '../../enums/query-keys' +import { QueuingType } from '../../enums/queuing-type' +import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' +import { BaseStackParamList } from '../../screens/types' +import { useApi } from '../../stores' +import useStreamingDeviceProfile from '../../stores/device-profile' +import { useNetworkStatus } from '../../stores/network' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { useNavigation } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { useQuery } from '@tanstack/react-query' +import Animated, { FadeInUp, FadeOutDown, LinearTransition } from 'react-native-reanimated' +import { YStack, H5, XStack, Separator } from 'tamagui' +import Icon from '../Global/components/icon' +import ItemImage from '../Global/components/image' +import { RunTimeTicks } from '../Global/helpers/time-codes' +import Button from '../Global/helpers/button' +import { Text } from '../Global/helpers/text' +import { InstantMixButton } from '../Global/components/instant-mix-button' + +/** + * Renders a header for an Album's track list + * @param album The {@link BaseItemDto} of the album to render the header for + * @param navigation The navigation object from the parent {@link Album} + * @param playAlbum The function to call to play the album + * @returns A React component + */ +export default function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element { + const api = useApi() + + const [networkStatus] = useNetworkStatus() + const streamingDeviceProfile = useStreamingDeviceProfile() + + const loadNewQueue = useLoadNewQueue() + + const { data: discs, isPending } = useQuery({ + queryKey: [QueryKeys.ItemTracks, album.Id], + queryFn: () => fetchAlbumDiscs(api, album), + }) + + const navigation = useNavigation>() + + const playAlbum = (shuffled: boolean = false) => { + if (!discs || discs.length === 0) return + + const allTracks = discs.flatMap((disc) => disc.data) ?? [] + if (allTracks.length === 0) return + + loadNewQueue({ + api, + networkStatus, + deviceProfile: streamingDeviceProfile, + track: allTracks[0], + index: 0, + tracklist: allTracks, + queue: album, + queuingType: QueuingType.FromSelection, + shuffled, + startPlayback: true, + }) + } + + return ( + + + + +
+ {album.Name ?? 'Untitled Album'} +
+ + {album.AlbumArtists && ( + + navigation.navigate('Artist', { + artist: album.AlbumArtists![0], + }) + } + textAlign='center' + fontSize={'$5'} + paddingBottom={'$2'} + > + {album.AlbumArtists![0].Name ?? 'Untitled Artist'} + + )} + + + + {album.ProductionYear ? ( + + {album.ProductionYear?.toString() ?? 'Unknown Year'} + + ) : null} + + + + + + {album.RunTimeTicks} + + + + {discs && ( + + + + + + + + + + + + )} +
+
+ ) +} diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index ef431556..61e343c3 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -1,34 +1,25 @@ import { YStack, XStack, Separator, Spinner } from 'tamagui' -import { H5, Text } from '../Global/helpers/text' +import { Text } from '../Global/helpers/text' import { SectionList } from 'react-native' -import { RunTimeTicks } from '../Global/helpers/time-codes' import Track from '../Global/components/track' import FavoriteButton from '../Global/components/favorite-button' -import { ItemCard } from '../Global/components/item-card' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import InstantMixButton from '../Global/components/instant-mix-button' -import ItemImage from '../Global/components/image' import React, { useLayoutEffect } from 'react' import Icon from '../Global/components/icon' -import { useNetworkStatus } from '../../stores/network' -import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' -import { QueuingType } from '../../enums/queuing-type' import { useNavigation } from '@react-navigation/native' -import HomeStackParamList from '../../screens/Home/types' -import LibraryStackParamList from '../../screens/Library/types' -import DiscoverStackParamList from '../../screens/Discover/types' import { BaseStackParamList } from '../../screens/types' -import useStreamingDeviceProfile from '../../stores/device-profile' import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' import { useApi } from '../../stores' import { QueryKeys } from '../../enums/query-keys' import { fetchAlbumDiscs } from '../../api/queries/item' import { useQuery } from '@tanstack/react-query' -import useAddToPendingDownloads, { usePendingDownloads } from '../../stores/network/downloads' -import Button from '../Global/helpers/button' +import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads' +import { useIsDownloaded } from '../../api/queries/download' +import AlbumTrackListFooter from './footer' +import AlbumTrackListHeader from './header' import Animated, { FadeInUp, FadeOutDown, LinearTransition } from 'react-native-reanimated' -import { FlashList } from '@shopify/flash-list' +import { useStorageContext } from '../../providers/Storage' /** * The screen for an Album's track list @@ -41,18 +32,6 @@ import { FlashList } from '@shopify/flash-list' export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { const navigation = useNavigation>() - useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - - - - - ), - }) - }) - const api = useApi() const { data: discs, isPending } = useQuery({ @@ -60,12 +39,12 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { queryFn: () => fetchAlbumDiscs(api, album), }) + const isDownloaded = useIsDownloaded( + discs?.flatMap(({ data }) => data).map(({ Id }) => Id) ?? [], + ) + const addToDownloadQueue = useAddToPendingDownloads() - const pendingDownloads = usePendingDownloads() - - const downloadAlbum = (item: BaseItemDto[]) => addToDownloadQueue(item) - const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({ title, data: Array.isArray(data) ? data : [], @@ -75,6 +54,59 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { const albumTrackList = discs?.flatMap((disc) => disc.data) + const albumDownloadPending = useIsDownloading(albumTrackList ?? []) + + const { deleteDownloads } = useStorageContext() + + const handleDeleteDownload = () => deleteDownloads(albumTrackList?.map(({ Id }) => Id!) ?? []) + + const handleDownload = () => addToDownloadQueue(albumTrackList ?? []) + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + {albumTrackList && + (isDownloaded ? ( + + + + ) : albumDownloadPending ? ( + + ) : ( + + + + ))} + + + ), + }) + }, [ + album, + navigation, + isDownloaded, + handleDeleteDownload, + handleDownload, + albumDownloadPending, + ]) + return ( {`Disc ${section.title}`} - { - if (pendingDownloads.length) { - return - } - downloadAlbum(section.data) - }} - /> ) : null }} @@ -128,175 +150,3 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { /> ) } - -/** - * Renders a header for an Album's track list - * @param album The {@link BaseItemDto} of the album to render the header for - * @param navigation The navigation object from the parent {@link Album} - * @param playAlbum The function to call to play the album - * @returns A React component - */ -function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element { - const api = useApi() - - const [networkStatus] = useNetworkStatus() - const streamingDeviceProfile = useStreamingDeviceProfile() - - const loadNewQueue = useLoadNewQueue() - - const { data: discs, isPending } = useQuery({ - queryKey: [QueryKeys.ItemTracks, album.Id], - queryFn: () => fetchAlbumDiscs(api, album), - }) - - const navigation = useNavigation>() - - const playAlbum = (shuffled: boolean = false) => { - if (!discs || discs.length === 0) return - - const allTracks = discs.flatMap((disc) => disc.data) ?? [] - if (allTracks.length === 0) return - - loadNewQueue({ - api, - networkStatus, - deviceProfile: streamingDeviceProfile, - track: allTracks[0], - index: 0, - tracklist: allTracks, - queue: album, - queuingType: QueuingType.FromSelection, - shuffled, - startPlayback: true, - }) - } - - return ( - - - - -
- {album.Name ?? 'Untitled Album'} -
- - {album.AlbumArtists && ( - - navigation.navigate('Artist', { - artist: album.AlbumArtists![0], - }) - } - textAlign='center' - fontSize={'$5'} - paddingBottom={'$2'} - > - {album.AlbumArtists![0].Name ?? 'Untitled Artist'} - - )} - - - - {album.ProductionYear ? ( - - {album.ProductionYear?.toString() ?? 'Unknown Year'} - - ) : null} - - - - - - {album.RunTimeTicks} - - - - {discs && ( - - - - - - - - )} -
-
- ) -} - -function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element { - const navigation = - useNavigation< - NativeStackNavigationProp< - HomeStackParamList | LibraryStackParamList | DiscoverStackParamList - > - >() - - return ( - - {album.ArtistItems && album.ArtistItems.length > 1 && ( - <> -
Featuring
- - ( - { - navigation.navigate('Artist', { - artist, - }) - }} - /> - )} - /> - - )} -
- ) -} diff --git a/src/components/Artist/header.tsx b/src/components/Artist/header.tsx index 1909e1e0..56981835 100644 --- a/src/components/Artist/header.tsx +++ b/src/components/Artist/header.tsx @@ -5,7 +5,7 @@ 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 { InstantMixIconButton } 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' @@ -86,7 +86,7 @@ export default function ArtistHeader(): React.JSX.Element { - + diff --git a/src/components/Global/components/SwipeableRow.tsx b/src/components/Global/components/SwipeableRow.tsx index 75305e1a..dcca0467 100644 --- a/src/components/Global/components/SwipeableRow.tsx +++ b/src/components/Global/components/SwipeableRow.tsx @@ -457,8 +457,8 @@ export default function SwipeableRow({ backgroundColor={action.color} borderRadius={0} pressStyle={{ opacity: 0.8 }} - accessibilityRole='button' - accessibilityLabel={`Left quick action ${action.icon}`} + role='button' + aria-label={`Left quick action ${action.icon}`} onPress={() => { action.onPress() close() @@ -540,8 +540,8 @@ export default function SwipeableRow({ backgroundColor={action.color} borderRadius={0} pressStyle={{ opacity: 0.8 }} - accessibilityRole='button' - accessibilityLabel={`Right quick action ${action.icon}`} + role='button' + aria-label={`Right quick action ${action.icon}`} onPress={() => { action.onPress() close() diff --git a/src/components/Global/components/instant-mix-button.tsx b/src/components/Global/components/instant-mix-button.tsx index b941f544..3883747f 100644 --- a/src/components/Global/components/instant-mix-button.tsx +++ b/src/components/Global/components/instant-mix-button.tsx @@ -1,43 +1,68 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import React from 'react' -import { QueryKeys } from '../../../enums/query-keys' -import { useQuery } from '@tanstack/react-query' -import { fetchInstantMixFromItem } from '../../../api/queries/instant-mixes' import Icon from './icon' -import { Spacer, Spinner } from 'tamagui' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { BaseStackParamList } from '../../../screens/types' -import { useApi, useJellifyUser } from '../../../stores' +import Button from '../helpers/button' +import { CommonActions } from '@react-navigation/native' +import { Text } from '../helpers/text' +import Animated, { FadeInUp, FadeOutDown, LinearTransition } from 'react-native-reanimated' -export default function InstantMixButton({ +export function InstantMixIconButton({ item, navigation, }: { item: BaseItemDto navigation: Pick, 'navigate' | 'dispatch'> }): React.JSX.Element { - const api = useApi() - const [user] = useJellifyUser() - - const { data, isFetching, refetch } = useQuery({ - queryKey: [QueryKeys.InstantMix, item.Id!], - queryFn: () => fetchInstantMixFromItem(api, user, item), - }) - - return data ? ( + return ( navigation.navigate('InstantMix', { item, - mix: data, }) } /> - ) : isFetching ? ( - - ) : ( - + ) +} + +export function InstantMixButton({ + item, + navigation, +}: { + item: BaseItemDto + navigation: Pick, 'navigate' | 'dispatch'> +}): React.JSX.Element { + return ( + + + ) } diff --git a/src/components/InstantMix/component.tsx b/src/components/InstantMix/component.tsx index 5c3a1f6a..6b7e9ddf 100644 --- a/src/components/InstantMix/component.tsx +++ b/src/components/InstantMix/component.tsx @@ -1,22 +1,23 @@ -import { useCallback } from 'react' import { InstantMixProps } from '../../screens/types' import Track from '../Global/components/track' -import { Separator } from 'tamagui' +import { Separator, useTheme } from 'tamagui' import { FlashList } from '@shopify/flash-list' import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' +import useInstantMix from '../../api/queries/instant-mix' +import { Text } from '../Global/helpers/text' +import { RefreshControl } from 'react-native' export default function InstantMix({ route, navigation }: InstantMixProps): React.JSX.Element { - const { mix } = route.params - const handleScrollBeginDrag = useCallback(() => { - closeAllSwipeableRows() - }, []) + const { data: mix, isFetching, refetch } = useInstantMix(route.params.item) + + const theme = useTheme() return ( } - onScrollBeginDrag={handleScrollBeginDrag} + onScrollBeginDrag={closeAllSwipeableRows} renderItem={({ item, index }) => ( )} + ListEmptyComponent={ + !isFetching ? No mix tracks : undefined // Refresh Control will handle the spinner, which is actually called a "throbber" ;) + } + refreshControl={ + + } /> ) } diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx index a35cb930..a78da6fb 100644 --- a/src/components/Playlist/components/header.tsx +++ b/src/components/Playlist/components/header.tsx @@ -1,10 +1,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { H5, Spacer, XStack, YStack } from 'tamagui' -import InstantMixButton from '../../Global/components/instant-mix-button' +import { InstantMixButton } from '../../Global/components/instant-mix-button' import Icon from '../../Global/components/icon' import { useNetworkStatus } from '../../../stores/network' -import { ActivityIndicator } from 'react-native' import { QueuingType } from '../../../enums/queuing-type' import { useNavigation } from '@react-navigation/native' import LibraryStackParamList from '@/src/screens/Library/types' @@ -13,9 +12,16 @@ import useStreamingDeviceProfile from '../../../stores/device-profile' import ItemImage from '../../Global/components/image' import { useApi } from '../../../stores' import Input from '../../Global/helpers/input' -import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated' +import Animated, { + FadeInDown, + FadeInUp, + FadeOutDown, + LinearTransition, +} from 'react-native-reanimated' import { Dispatch, SetStateAction } from 'react' -import useAddToPendingDownloads, { useIsDownloading } from '../../../stores/network/downloads' +import Button from '../../Global/helpers/button' +import { Text } from '../../Global/helpers/text' +import { RunTimeTicks } from '../../Global/helpers/time-codes' export default function PlaylistTracklistHeader({ playlist, @@ -31,7 +37,7 @@ export default function PlaylistTracklistHeader({ setNewName: Dispatch> }): React.JSX.Element { return ( - + @@ -47,7 +53,7 @@ export default function PlaylistTracklistHeader({ onChangeText={setNewName} placeholder='Playlist Name' textAlign='center' - fontSize={'$9'} + fontSize={'$8'} fontWeight='bold' clearButtonMode='while-editing' marginHorizontal={'$4'} @@ -55,19 +61,18 @@ export default function PlaylistTracklistHeader({ ) : ( -
- {newName ?? 'Untitled Playlist'} -
+ +
+ {newName ?? 'Untitled Playlist'} +
+ + {playlist.RunTimeTicks} +
)} {!editing ? ( - + >() - const downloadPlaylist = () => addToDownloadQueue(playlistTracks) - const playPlaylist = (shuffled: boolean = false) => { if (!playlistTracks || playlistTracks.length === 0) return @@ -119,31 +120,54 @@ function PlaylistHeaderControls({ } return ( - - - - + + + + - - playPlaylist(false)} small /> - + - - playPlaylist(true)} small /> - - - - {!isDownloading ? ( - - ) : ( - - )} - + + + ) } diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx index 22e078f8..941d5373 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -21,10 +21,21 @@ import { updatePlaylist } from '../../../src/api/mutations/playlists' import { usePlaylistTracks } from '../../../src/api/queries/playlist' import useHapticFeedback from '../../hooks/use-haptic-feedback' import { useMutation } from '@tanstack/react-query' -import Animated, { SlideInLeft, SlideOutRight } from 'react-native-reanimated' +import Animated, { + FadeIn, + FadeInUp, + FadeOut, + FadeOutDown, + LinearTransition, + SlideInLeft, + SlideOutRight, +} from 'react-native-reanimated' import { FlashList, ListRenderItem } from '@shopify/flash-list' import { Text } from '../Global/helpers/text' import { RefreshControl } from 'react-native' +import { useIsDownloaded } from '../../api/queries/download' +import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads' +import { useStorageContext } from '../../providers/Storage' export default function Playlist({ playlist, @@ -128,53 +139,104 @@ export default function Playlist({ const [networkStatus] = useNetworkStatus() + const isDownloaded = useIsDownloaded(playlistTracks?.map(({ Id }) => Id) ?? []) + + const playlistDownloadPending = useIsDownloading(playlistTracks ?? []) + + const { deleteDownloads } = useStorageContext() + + const addToDownloadQueue = useAddToPendingDownloads() + + const handleDeleteDownload = () => deleteDownloads(playlistTracks?.map(({ Id }) => Id!) ?? []) + + const handleDownload = () => addToDownloadQueue(playlistTracks ?? []) + useLayoutEffect(() => { navigation.setOptions({ - headerRight: () => - canEdit && ( - - {editing && ( - <> + headerRight: () => ( + + {playlistTracks && + (isDownloaded ? ( + { - navigationRef.dispatch( - StackActions.push('DeletePlaylist', { - playlist, - onDelete: navigation.goBack, - }), - ) - }} + color='$warning' + name='broom' + onPress={handleDeleteDownload} /> - + + ) : playlistDownloadPending ? ( + + ) : ( + - - )} + + ))} + {canEdit && + (editing ? ( + + + { + navigationRef.dispatch( + StackActions.push('DeletePlaylist', { + playlist, + onDelete: navigation.goBack, + }), + ) + }} + /> - {isUpdating || isPreparingEditMode ? ( + + + + ) : isUpdating || isPreparingEditMode ? ( ) : ( - - !editing - ? handleEnterEditMode() - : useUpdatePlaylist({ - playlist, - tracks: playlistTracks ?? [], - newName, - }) - } - /> - )} - - ), + + + !editing + ? handleEnterEditMode() + : useUpdatePlaylist({ + playlist, + tracks: playlistTracks ?? [], + newName, + }) + } + /> + + ))} + ) + + ), }) }, [ editing, diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 0af5bcf8..80e02fc2 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -9,6 +9,7 @@ import { BaseStackParamList } from '@/src/screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' import { RefreshControl } from 'react-native' +import { Text } from '../Global/helpers/text' // Extracted as stable component to prevent recreation on each render function ListSeparatorComponent(): React.JSX.Element { @@ -73,6 +74,7 @@ export default function Playlists({ onEndReached={handleEndReached} removeClippedSubviews onScrollBeginDrag={closeAllSwipeableRows} + ListEmptyComponent={No playlists} /> ) } diff --git a/src/components/Settings/components/preferences-tab.tsx b/src/components/Settings/components/preferences-tab.tsx index 6d4a5b16..e554c363 100644 --- a/src/components/Settings/components/preferences-tab.tsx +++ b/src/components/Settings/components/preferences-tab.tsx @@ -98,9 +98,9 @@ function ThemeOptionCard({ padding='$3' gap='$2' hitSlop={8} - accessibilityRole='button' - accessibilityLabel={`${option.label} theme option`} - accessibilityState={{ selected: isSelected }} + role='button' + aria-label={`${option.label} theme option`} + aria-selected={isSelected} > diff --git a/src/components/Settings/components/storage-tab.tsx b/src/components/Settings/components/storage-tab.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/Storage/DownloadProgressBar.tsx b/src/components/Storage/DownloadProgressBar.tsx deleted file mode 100644 index 82843125..00000000 --- a/src/components/Storage/DownloadProgressBar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// DownloadProgressBar.tsx -import React from 'react' -import { View, Text, StyleSheet } from 'react-native' -import { useQueryClient, useQuery } from '@tanstack/react-query' -import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' - -export const DownloadProgressBar = () => { - const { data: downloads } = useQuery({ - queryKey: ['downloads'], - initialData: {}, - }) - - return ( - - {/* eslint-disable @typescript-eslint/no-explicit-any */} - {Object.entries(downloads || {}).map(([url, item]: any) => { - const animatedWidth = useSharedValue(item.progress) - animatedWidth.value = withTiming(item.progress, { duration: 200 }) - - const animatedStyle = useAnimatedStyle(() => ({ - width: `${animatedWidth.value * 100}%`, - })) - - return ( - - {item.name} - - - - - ) - })} - - ) -} - -const styles = StyleSheet.create({ - container: { - padding: 12, - backgroundColor: '#111', - }, - item: { - marginBottom: 12, - }, - label: { - color: '#fff', - marginBottom: 4, - fontSize: 14, - }, - bar: { - height: 8, - backgroundColor: '#333', - borderRadius: 4, - overflow: 'hidden', - }, - fill: { - height: 8, - backgroundColor: '#00bcd4', - borderRadius: 4, - }, -}) diff --git a/src/components/Storage/index.tsx b/src/components/Storage/index.tsx deleted file mode 100644 index 4d527d97..00000000 --- a/src/components/Storage/index.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { StyleSheet, Pressable, Alert, FlatList } from 'react-native' -import RNFS from 'react-native-fs' -import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils' -import Icon from '../Global/components/icon' -import { View } from 'tamagui' -import { Text } from '../Global/helpers/text' -import { useDownloadProgress } from '@/src/stores/network/downloads' - -// 🔹 Single Download Item with animated progress bar -function DownloadItem({ - name, - progress, - fileName, -}: { - name: string - progress: number - fileName: string -}): React.JSX.Element { - const progressValue = useSharedValue(progress) - - useEffect(() => { - progressValue.value = withTiming(progress, { duration: 300 }) - }, [progress]) - - const animatedStyle = useAnimatedStyle(() => ({ - width: `${progressValue.value * 100}%`, - })) - - return ( - - {fileName} - - - - - ) -} - -// 🔹 Main UI Component -export default function StorageBar(): React.JSX.Element { - const [used, setUsed] = useState(0) - const [total, setTotal] = useState(1) - - const activeDownloadsArray = useDownloadProgress() - - const usageShared = useSharedValue(0) - const percentUsed = used / total - - const storageBarStyle = useAnimatedStyle(() => ({ - width: `${usageShared.value * 100}%`, - })) - - useEffect(() => { - usageShared.value = withTiming(percentUsed, { duration: 500 }) - }, [percentUsed]) - - // Refresh storage info - const refreshStats = async () => { - const files = await RNFS.readDir(RNFS.DocumentDirectoryPath) - let usedBytes = 0 - for (const file of files) { - const stat = await RNFS.stat(file.path) - usedBytes += Number(stat.size) - } - const info = await RNFS.getFSInfo() - setUsed(usedBytes) - setTotal(info.totalSpace) - } - - const deleteAllDownloads = async () => { - const result = await deleteAudioCache() - Alert.alert( - 'Downloads removed', - `Deleted ${result.deletedCount} ${result.deletedCount === 1 ? 'item' : 'items'} and freed ${( - result.freedBytes / - 1024 / - 1024 - ).toFixed(2)} MB`, - ) - refreshStats() - } - - useEffect(() => { - refreshStats() - }, []) - const activeDownloads = Object.values(activeDownloadsArray ?? {}).map((item) => ({ - name: item.name, - progress: item.progress, - songName: item.songName, - })) - - return ( - - {/* Storage Usage */} - 📦 Storage Usage - - {`${(used / 1024 / 1024).toFixed(2)} MB / ${(total / 1024 / 1024 / 1024).toFixed( - 2, - )} GB`} - - - - - - {/* Active Downloads */} - {(activeDownloads ?? []).length > 0 && ( - <> - ⬇️ Active Downloads - download.name} - renderItem={({ item }) => { - return ( - - ) - }} - contentContainerStyle={{ paddingBottom: 40 }} - /> - - )} - - {/* Delete All Downloads */} - - - Delete Downloads - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 20, - backgroundColor: '#1c1c2e', - }, - title: { - color: 'white', - fontSize: 18, - fontWeight: '600', - marginBottom: 4, - }, - usage: { - color: '#aaa', - fontSize: 14, - marginBottom: 12, - }, - progressBackground: { - height: 10, - backgroundColor: '#333', - borderRadius: 5, - overflow: 'hidden', - }, - progressFill: { - height: 10, - backgroundColor: '#ff2d75', - borderRadius: 5, - }, - item: { - marginTop: 16, - }, - label: { - color: '#ccc', - fontSize: 14, - marginBottom: 4, - }, - downloadBar: { - height: 8, - backgroundColor: '#2e2e3f', - borderRadius: 4, - overflow: 'hidden', - }, - downloadFill: { - height: 8, - backgroundColor: '#00bcd4', - }, - deleteButton: { - marginTop: 30, - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'center', - padding: 12, - backgroundColor: '#2a0f13', - borderRadius: 8, - }, - deleteText: { - color: '#ff4d4f', - fontSize: 15, - fontWeight: '600', - }, -}) diff --git a/src/screens/Discover/index.tsx b/src/screens/Discover/index.tsx index 7c474e1e..a6b4b437 100644 --- a/src/screens/Discover/index.tsx +++ b/src/screens/Discover/index.tsx @@ -57,6 +57,9 @@ export function Discover(): React.JSX.Element { component={PlaylistScreen} options={({ route }) => ({ title: route.params.playlist.Name ?? 'Untitled Playlist', + headerTitleStyle: { + color: theme.background.val, + }, })} /> diff --git a/src/screens/Settings/storage-management/index.tsx b/src/screens/Settings/storage-management/index.tsx index ac7e186d..61d29ba1 100644 --- a/src/screens/Settings/storage-management/index.tsx +++ b/src/screens/Settings/storage-management/index.tsx @@ -1,18 +1,16 @@ import React, { useState } from 'react' import { FlashList, ListRenderItem } from '@shopify/flash-list' -import { useFocusEffect, useNavigation } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Pressable, Alert } from 'react-native' import { Card, Paragraph, Separator, SizableText, Spinner, XStack, YStack, Image } from 'tamagui' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useStorageContext, CleanupSuggestion } from '../../../providers/Storage' import Icon from '../../../components/Global/components/icon' import Button from '../../../components/Global/helpers/button' import { formatBytes } from '../../../utils/format-bytes' import { JellifyDownload, JellifyDownloadProgress } from '../../../types/JellifyDownload' -import { SettingsStackParamList } from '../types' import { useDeletionToast } from './useDeletionToast' +import { Text } from '../../../components/Global/helpers/text' const getDownloadSize = (download: JellifyDownload) => (download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0) @@ -44,7 +42,7 @@ export default function StorageManagementScreen(): React.JSX.Element { const [applyingSuggestionId, setApplyingSuggestionId] = useState(null) const insets = useSafeAreaInsets() - const navigation = useNavigation>() + const showDeletionToast = useDeletionToast() const sortedDownloads = !downloads @@ -86,12 +84,12 @@ export default function StorageManagementScreen(): React.JSX.Element { const handleDeleteAll = () => Alert.alert( - 'Delete all downloads?', + 'Clear all downloads?', 'This will remove all downloaded music from your device. This action cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, { - text: 'Delete All', + text: 'Clear All', style: 'destructive', onPress: async () => { if (!downloads) return @@ -109,12 +107,12 @@ export default function StorageManagementScreen(): React.JSX.Element { const handleDeleteSelection = () => Alert.alert( - 'Delete selected items?', - `Are you sure you want to delete ${selectedIds.length} items?`, + 'Clear selected downloads?', + `Are you sure you want to clear ${selectedIds.length} downloads?`, [ { text: 'Cancel', style: 'cancel' }, { - text: 'Delete', + text: 'Clear', style: 'destructive', onPress: async () => { const result = await deleteDownloads(selectedIds) @@ -255,18 +253,20 @@ const StorageSummaryCard = ({ ) } onPress={onRefresh} - accessibilityLabel='Refresh storage overview' + aria-label='Refresh storage overview' /> @@ -388,7 +388,7 @@ const DownloadRow = ({ {download.artwork ? ( @@ -428,12 +428,12 @@ const DownloadRow = ({ circular backgroundColor='transparent' hitSlop={10} - icon={() => } + icon={() => } onPress={(event) => { event.stopPropagation() onDelete() }} - accessibilityLabel='Delete download' + aria-label='Clear download' /> @@ -506,14 +506,13 @@ const SelectionReviewBanner = ({
diff --git a/src/screens/types.d.ts b/src/screens/types.d.ts index 705ca23c..f0291f32 100644 --- a/src/screens/types.d.ts +++ b/src/screens/types.d.ts @@ -33,7 +33,6 @@ export type BaseStackParamList = { InstantMix: { item: BaseItemDto - mix: BaseItemDto[] } Tracks: { diff --git a/src/stores/network/downloads.ts b/src/stores/network/downloads.ts index dc0dfbfb..98fc8aa3 100644 --- a/src/stores/network/downloads.ts +++ b/src/stores/network/downloads.ts @@ -70,7 +70,7 @@ export const useIsDownloading = (items: BaseItemDto[]) => { return ( items.length !== 0 && (pendingDownloads.length !== 0 || currentDownloads.length !== 0) && - items.filter((item) => downloadQueue.has(item.Id)).length === items.length + items.filter((item) => downloadQueue.has(item.Id)).length > 0 ) } From 861d052965a2e0f2ef42c1879056d4438b15557a Mon Sep 17 00:00:00 2001 From: Violet Caulfield Date: Sat, 20 Dec 2025 11:24:46 -0600 Subject: [PATCH 03/31] update README screenshots --- README.md | 2 +- screenshots/add_to_playlist.png | Bin 0 -> 499435 bytes screenshots/album.png | Bin 1029405 -> 772871 bytes screenshots/artist.png | Bin 1243490 -> 1301066 bytes screenshots/home.png | Bin 2348315 -> 1909188 bytes screenshots/player.png | Bin 2833079 -> 896355 bytes screenshots/player_queue.png | Bin 1022857 -> 712350 bytes screenshots/playlist.png | Bin 499435 -> 790137 bytes 8 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 screenshots/add_to_playlist.png diff --git a/README.md b/README.md index 8a6ea9c4..f93d9e81 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility

Track Options - Playlist + Playlist

--- diff --git a/screenshots/add_to_playlist.png b/screenshots/add_to_playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..1264e86dffd2c92a9ee3cb49c0c83777aaf4a4cf GIT binary patch literal 499435 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