From 0851e5ab6e9fdc121bb44e750ae1d10fe67a654a Mon Sep 17 00:00:00 2001 From: Violet Caulfield Date: Sat, 29 Nov 2025 19:43:31 -0600 Subject: [PATCH 01/16] align player timecodes *always* --- src/components/Player/components/scrubber.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx index 22859ceb..c34ded11 100644 --- a/src/components/Player/components/scrubber.tsx +++ b/src/components/Player/components/scrubber.tsx @@ -157,16 +157,11 @@ export default function Scrubber(): React.JSX.Element { /> - + {currentSeconds} - + {nowPlaying?.mediaSourceInfo && displayAudioQualityBadge ? ( - + {totalSeconds} From fee4ad3d943cf8b54d72ddfe658d92a8125893d9 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:55:16 -0600 Subject: [PATCH 02/16] hide miniplayer if full screen player is active (#735) reduce cpu overhead by hiding the miniplayer if it's not being displayed --- src/components/Global/components/icon.tsx | 8 ++++++++ src/components/Player/mini-player.tsx | 6 +----- src/hooks/use-mini-player.ts | 10 ++++++++++ src/screens/Tabs/tab-bar.tsx | 15 +++++---------- 4 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 src/hooks/use-mini-player.ts diff --git a/src/components/Global/components/icon.tsx b/src/components/Global/components/icon.tsx index 00c85fc3..1bbe6417 100644 --- a/src/components/Global/components/icon.tsx +++ b/src/components/Global/components/icon.tsx @@ -1,5 +1,6 @@ import React from 'react' import { + AnimationKeys, ColorTokens, getToken, getTokens, @@ -11,6 +12,7 @@ import { YStack, } from 'tamagui' import MaterialDesignIcon from '@react-native-vector-icons/material-design-icons' +import { on } from 'events' const smallSize = 28 @@ -42,8 +44,14 @@ export default function Icon({ const theme = useTheme() const size = large ? largeSize : small ? smallSize : regularSize + const animation = onPress || onPressIn ? 'quick' : undefined + + const pressStyle = animation ? { opacity: 0.6 } : undefined + return ( - + - {showMiniPlayer && ( - /* Hide miniplayer if the queue is empty */ - - )} + {isMiniPlayerActive && isFocused && } From 7bb6e727ceacc918d3fa6ce7a7c1ef2531ecbbb9 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:40:03 -0600 Subject: [PATCH 03/16] minify miniplayer (#736) * minify miniplayer to save some vertical screen real estate --- src/components/Player/components/buttons.tsx | 27 ++++++++- src/components/Player/mini-player.tsx | 64 +++++++------------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/components/Player/components/buttons.tsx b/src/components/Player/components/buttons.tsx index ba48ffee..ee58274e 100644 --- a/src/components/Player/components/buttons.tsx +++ b/src/components/Player/components/buttons.tsx @@ -5,6 +5,7 @@ import { isUndefined } from 'lodash' import { useTogglePlayback } from '../../../providers/Player/hooks/mutations' import { usePlaybackState } from '../../../providers/Player/hooks/queries' import React, { useMemo } from 'react' +import Icon from '../../Global/components/icon' function PlayPauseButtonComponent({ size, @@ -17,7 +18,7 @@ function PlayPauseButtonComponent({ const state = usePlaybackState() - const largeIcon = useMemo(() => isUndefined(size) || size >= 20, [size]) + const largeIcon = useMemo(() => isUndefined(size) || size >= 24, [size]) const button = useMemo(() => { switch (state) { @@ -67,4 +68,28 @@ function PlayPauseButtonComponent({ const PlayPauseButton = React.memo(PlayPauseButtonComponent) +export function PlayPauseIcon(): React.JSX.Element { + const togglePlayback = useTogglePlayback() + const state = usePlaybackState() + + const button = useMemo(() => { + switch (state) { + case State.Playing: { + return + } + + case State.Buffering: + case State.Loading: { + return + } + + default: { + return + } + } + }, [state, togglePlayback]) + + return button +} + export default PlayPauseButton diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 661beba6..25b44471 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -1,11 +1,10 @@ import React, { useMemo, useCallback } from 'react' -import { getToken, Progress, XStack, YStack } from 'tamagui' +import { Progress, XStack, YStack } from 'tamagui' import { useNavigation } from '@react-navigation/native' import { Text } from '../Global/helpers/text' import TextTicker from 'react-native-text-ticker' -import PlayPauseButton from './components/buttons' +import { PlayPauseIcon } from './components/buttons' import { TextTickerConfig } from './component.config' -import { RunTimeSeconds } from '../Global/helpers/time-codes' import { UPDATE_INTERVAL } from '../../player/config' import { Progress as TrackPlayerProgress } from 'react-native-track-player' import { useProgress } from '../../providers/Player/hooks/queries' @@ -23,7 +22,7 @@ import { runOnJS } from 'react-native-worklets' import { RootStackParamList } from '../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import ItemImage from '../Global/components/image' -import { usePrevious, useSkip } from '../../providers/Player/hooks/mutations' +import { usePrevious, useSkip, useTogglePlayback } from '../../providers/Player/hooks/mutations' import { useCurrentTrack } from '../../stores/player/queue' export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { @@ -84,19 +83,28 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { [navigation], ) + const pressStyle = useMemo( + () => ({ + opacity: 0.6, + }), + [], + ) + return ( - + - + @@ -114,8 +122,6 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { marginLeft={'$2'} flex={6} > - - - + @@ -153,43 +159,15 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { ) }) -function MiniPlayerRuntime({ duration }: { duration: number }): React.JSX.Element { - return ( - - - - - - - - / - - - - - {Math.max(0, Math.round(duration))} - - - - - ) -} - -function MiniPlayerRuntimePosition(): React.JSX.Element { - const { position } = useProgress(UPDATE_INTERVAL) - - return {Math.max(0, Math.round(position))} -} - function MiniPlayerProgress(): React.JSX.Element { const progress = useProgress(UPDATE_INTERVAL) return ( From ac7df341e05aba7d41d9b2369e46652992d3a33d Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:36:27 -0600 Subject: [PATCH 04/16] Add Ko-fi username to FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c11d42ab..929abd4f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -3,7 +3,7 @@ github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: anultravioletaurora # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username +ko_fi: jellify # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username From 4b5faacd280f07bb959fd906036883e0493d2b6f Mon Sep 17 00:00:00 2001 From: Ritesh Shukla Date: Tue, 2 Dec 2025 00:54:05 +0530 Subject: [PATCH 05/16] React Compiler (#737) * chore: r2 * compiler * compiler --- babel.config.js | 6 +- bun.lock | 4 +- package.json | 1 + src/components/Context/index.tsx | 43 +++---- src/components/Player/components/buttons.tsx | 12 +- src/components/Player/components/header.tsx | 21 ++-- src/components/Player/components/scrubber.tsx | 114 +++++++---------- src/components/Player/mini-player.tsx | 91 ++++++-------- src/components/Search/index.tsx | 10 +- src/components/Search/suggestions.tsx | 5 +- src/components/Tracks/component.tsx | 77 ++++++------ src/hooks/use-item-context.ts | 21 ++-- src/providers/Artist/index.tsx | 41 +++--- src/providers/Network/index.tsx | 14 +-- src/providers/Player/index.tsx | 108 ++++++++-------- src/providers/Storage/index.tsx | 119 +++++++----------- 16 files changed, 292 insertions(+), 395 deletions(-) diff --git a/babel.config.js b/babel.config.js index 8b0276c4..af461387 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,8 @@ module.exports = { presets: ['module:@react-native/babel-preset'], - plugins: ['react-native-worklets/plugin', 'react-native-worklets-core/plugin'], + plugins: [ + 'babel-plugin-react-compiler', + 'react-native-worklets/plugin', + 'react-native-worklets-core/plugin', + ], } diff --git a/bun.lock b/bun.lock index 6a4a7697..01ec36f7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "jellify", @@ -83,6 +82,7 @@ "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "19.1.0", "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -1033,6 +1033,8 @@ "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.32.0", "", { "dependencies": { "hermes-parser": "0.32.0" } }, "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg=="], "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], diff --git a/package.json b/package.json index 83538529..db800bac 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "19.1.0", "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-react-compiler": "^1.0.0", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index 3410d1c9..63de2abe 100644 --- a/src/components/Context/index.tsx +++ b/src/components/Context/index.tsx @@ -15,7 +15,7 @@ import { fetchAlbumDiscs, fetchItem } from '../../api/queries/item' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { AddToQueueMutation } from '../../providers/Player/interfaces' import { QueuingType } from '../../enums/queuing-type' -import { useCallback, useEffect, useMemo } from 'react' +import { useEffect } from 'react' import navigationRef from '../../../navigation' import { goToAlbumFromContextSheet, goToArtistFromContextSheet } from './utils/navigation' import { getItemName } from '../../utils/text' @@ -98,12 +98,12 @@ export default function ItemContext({ : [] : [] - const itemTracks = useMemo(() => { + const itemTracks = (() => { if (isTrack) return [item] else if (isAlbum && discs) return discs.flatMap((data) => data.data) else if (isPlaylist && tracks) return tracks else return [] - }, [isTrack, isAlbum, discs, isPlaylist, tracks]) + })() useEffect(() => trigger('impactLight'), [item?.Id]) @@ -251,26 +251,20 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id)) - const downloadItems = useCallback(() => { + const downloadItems = () => { if (!api) return const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile)) addToDownloadQueue(tracks) - }, [addToDownloadQueue, items]) + } - const removeDownloads = useCallback( - () => useRemoveDownload(items.map(({ Id }) => Id)), - [useRemoveDownload, items], - ) + const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id)) - const isPending = useMemo( - () => - items.filter( - (item) => - pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0, - ).length > 0, - [items, pendingDownloads], - ) + const isPending = + items.filter( + (item) => + pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0, + ).length > 0 return isPending ? ( { + const goToAlbum = () => { if (stackNavigation && album) stackNavigation.navigate('Album', { album }) else goToAlbumFromContextSheet(album) - }, [album, stackNavigation, navigationRef]) + } return ( { - if (stackNavigation) stackNavigation.navigate('Artist', { artist }) - else goToArtistFromContextSheet(artist) - }, - [stackNavigation, navigationRef], - ) + const goToArtist = (artist: BaseItemDto) => { + if (stackNavigation) stackNavigation.navigate('Artist', { artist }) + else goToArtistFromContextSheet(artist) + } return artist ? ( isUndefined(size) || size >= 24, [size]) + const largeIcon = isUndefined(size) || size >= 24 - const button = useMemo(() => { + const button = (() => { switch (state) { case State.Playing: { return ( @@ -57,7 +57,7 @@ function PlayPauseButtonComponent({ ) } } - }, [state, size, largeIcon, togglePlayback]) + })() return ( @@ -72,7 +72,7 @@ export function PlayPauseIcon(): React.JSX.Element { const togglePlayback = useTogglePlayback() const state = usePlaybackState() - const button = useMemo(() => { + const button = (() => { switch (state) { case State.Playing: { return @@ -87,7 +87,7 @@ export function PlayPauseIcon(): React.JSX.Element { return } } - }, [state, togglePlayback]) + })() return button } diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx index 91d69903..d5a87d34 100644 --- a/src/components/Player/components/header.tsx +++ b/src/components/Player/components/header.tsx @@ -1,6 +1,6 @@ import { XStack, YStack, Spacer, useTheme } from 'tamagui' import { Text } from '../../Global/helpers/text' -import React, { useCallback, useMemo } from 'react' +import React from 'react' import ItemImage from '../../Global/components/image' import Animated, { useAnimatedStyle, @@ -20,16 +20,11 @@ export default function PlayerHeader(): React.JSX.Element { const theme = useTheme() - // If the Queue is a BaseItemDto, display the name of it - const playingFrom = useMemo( - () => - !queueRef - ? 'Untitled' - : typeof queueRef === 'object' - ? (queueRef.Name ?? 'Untitled') - : queueRef, - [queueRef], - ) + const playingFrom = !queueRef + ? 'Untitled' + : typeof queueRef === 'object' + ? (queueRef.Name ?? 'Untitled') + : queueRef return ( @@ -75,10 +70,10 @@ function PlayerArtwork(): React.JSX.Element { opacity: withTiming(nowPlaying ? 1 : 0), })) - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { artworkMaxHeight.set(event.nativeEvent.layout.height) artworkMaxWidth.set(event.nativeEvent.layout.height) - }, []) + } return ( { - return Math.round(duration * ProgressMultiplier) - }, [duration]) + const maxDuration = Math.round(duration * ProgressMultiplier) - const calculatedPosition = useMemo(() => { - return Math.round(position! * ProgressMultiplier) - }, [position]) + const calculatedPosition = Math.round(position! * ProgressMultiplier) // Optimized position update logic with throttling useEffect(() => { @@ -77,70 +72,57 @@ export default function Scrubber(): React.JSX.Element { } }, [nowPlaying?.id]) - // Optimized seek handler with debouncing - const handleSeek = useCallback( - async (position: number) => { - const seekTime = Math.max(0, position / ProgressMultiplier) - lastSeekTimeRef.current = Date.now() + const handleSeek = async (position: number) => { + const seekTime = Math.max(0, position / ProgressMultiplier) + lastSeekTimeRef.current = Date.now() - try { - await seekTo(seekTime) - } catch (error) { - console.warn('handleSeek callback failed', error) + try { + await seekTo(seekTime) + } catch (error) { + console.warn('handleSeek callback failed', error) + isUserInteractingRef.current = false + setDisplayPosition(calculatedPosition) + } finally { + // Small delay to let the seek settle before allowing updates + setTimeout(() => { isUserInteractingRef.current = false - setDisplayPosition(calculatedPosition) - } finally { - // Small delay to let the seek settle before allowing updates - setTimeout(() => { - isUserInteractingRef.current = false - }, 100) - } + }, 100) + } + } + + const currentSeconds = Math.max(0, Math.round(displayPosition / ProgressMultiplier)) + + const totalSeconds = Math.round(duration) + + const sliderProps = { + maxWidth: width / 1.1, + onSlideStart: (event: unknown, value: number) => { + isUserInteractingRef.current = true + trigger('impactLight') + + // Immediately update position for responsive UI + const clampedValue = Math.max(0, Math.min(value, maxDuration)) + setDisplayPosition(clampedValue) }, - [seekTo, setDisplayPosition], - ) + onSlideMove: (event: unknown, value: number) => { + // Throttled haptic feedback for better performance + trigger('clockTick') - // Memoize time calculations to prevent unnecessary re-renders - const currentSeconds = useMemo(() => { - return Math.max(0, Math.round(displayPosition / ProgressMultiplier)) - }, [displayPosition]) + // Update position with proper clamping + const clampedValue = Math.max(0, Math.min(value, maxDuration)) + setDisplayPosition(clampedValue) + }, + onSlideEnd: async (event: unknown, value: number) => { + trigger('notificationSuccess') - const totalSeconds = useMemo(() => { - return Math.round(duration) - }, [duration]) + // Clamp final value and update display + const clampedValue = Math.max(0, Math.min(value, maxDuration)) + setDisplayPosition(clampedValue) - // Memoize slider props to prevent recreation - const sliderProps = useMemo( - () => ({ - maxWidth: width / 1.1, - onSlideStart: (event: unknown, value: number) => { - isUserInteractingRef.current = true - trigger('impactLight') - - // Immediately update position for responsive UI - const clampedValue = Math.max(0, Math.min(value, maxDuration)) - setDisplayPosition(clampedValue) - }, - onSlideMove: (event: unknown, value: number) => { - // Throttled haptic feedback for better performance - trigger('clockTick') - - // Update position with proper clamping - const clampedValue = Math.max(0, Math.min(value, maxDuration)) - setDisplayPosition(clampedValue) - }, - onSlideEnd: async (event: unknown, value: number) => { - trigger('notificationSuccess') - - // Clamp final value and update display - const clampedValue = Math.max(0, Math.min(value, maxDuration)) - setDisplayPosition(clampedValue) - - // Perform the seek operation - await handleSeek(clampedValue) - }, - }), - [maxDuration, handleSeek, calculatedPosition, width], - ) + // Perform the seek operation + await handleSeek(clampedValue) + }, + } return ( diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 25b44471..44b3e931 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react' +import React from 'react' import { Progress, XStack, YStack } from 'tamagui' import { useNavigation } from '@react-navigation/native' import { Text } from '../Global/helpers/text' @@ -35,60 +35,47 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { const translateX = useSharedValue(0) const translateY = useSharedValue(0) - const handleSwipe = useCallback( - (direction: string) => { - if (direction === 'Swiped Left') { - // Inverted: Swipe left -> next - skip(undefined) - } else if (direction === 'Swiped Right') { - // Inverted: Swipe right -> previous - previous() - } else if (direction === 'Swiped Up') { - // Navigate to the big player - navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + const handleSwipe = (direction: string) => { + if (direction === 'Swiped Left') { + // Inverted: Swipe left -> next + skip(undefined) + } else if (direction === 'Swiped Right') { + // Inverted: Swipe right -> previous + previous() + } else if (direction === 'Swiped Up') { + // Navigate to the big player + navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) + } + } + + const gesture = Gesture.Pan() + .onUpdate((event) => { + translateX.value = event.translationX + translateY.value = event.translationY + }) + .onEnd((event) => { + const threshold = 100 + + if (event.translationX > threshold) { + runOnJS(handleSwipe)('Swiped Right') + translateX.value = withSpring(200) + } else if (event.translationX < -threshold) { + runOnJS(handleSwipe)('Swiped Left') + translateX.value = withSpring(-200) + } else if (event.translationY < -threshold) { + runOnJS(handleSwipe)('Swiped Up') + translateY.value = withSpring(-200) + } else { + translateX.value = withSpring(0) + translateY.value = withSpring(0) } - }, - [skip, previous, navigation], - ) + }) - const gesture = useMemo( - () => - Gesture.Pan() - .onUpdate((event) => { - translateX.value = event.translationX - translateY.value = event.translationY - }) - .onEnd((event) => { - const threshold = 100 + const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }) - if (event.translationX > threshold) { - runOnJS(handleSwipe)('Swiped Right') - translateX.value = withSpring(200) - } else if (event.translationX < -threshold) { - runOnJS(handleSwipe)('Swiped Left') - translateX.value = withSpring(-200) - } else if (event.translationY < -threshold) { - runOnJS(handleSwipe)('Swiped Up') - translateY.value = withSpring(-200) - } else { - translateX.value = withSpring(0) - translateY.value = withSpring(0) - } - }), - [translateX, translateY, handleSwipe], - ) - - const openPlayer = useCallback( - () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' }), - [navigation], - ) - - const pressStyle = useMemo( - () => ({ - opacity: 0.6, - }), - [], - ) + const pressStyle = { + opacity: 0.6, + } return ( diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index db05d45c..644cccc0 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useState } from 'react' import Input from '../Global/helpers/input' import ItemRow from '../Global/components/item-row' import { NativeStackNavigationProp } from '@react-navigation/native-stack' @@ -45,7 +45,7 @@ export default function Search({ queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId), }) - const search = useCallback(() => { + const search = () => { let timeout: ReturnType return () => { @@ -55,16 +55,16 @@ export default function Search({ refetchSuggestions() }, 1000) } - }, []) + } const handleSearchStringUpdate = (value: string | undefined) => { setSearchString(value) search() } - const handleScrollBeginDrag = useCallback(() => { + const handleScrollBeginDrag = () => { closeAllSwipeableRows() - }, []) + } return ( >() - const handleScrollBeginDrag = useCallback(() => { + const handleScrollBeginDrag = () => { closeAllSwipeableRows() - }, []) + } return ( (null) - const stickyHeaderIndicies = useMemo(() => { + const stickyHeaderIndicies = (() => { if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return [] return tracksInfiniteQuery.data .map((track, index) => (typeof track === 'string' ? index : 0)) .filter((value, index, indices) => indices.indexOf(value) === index) - }, [showAlphabeticalSelector, tracksInfiniteQuery.data]) + })() const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) - // Memoize the expensive tracks processing to prevent memory leaks - const tracksToDisplay = React.useMemo( - () => tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? [], - [tracksInfiniteQuery.data], - ) + const tracksToDisplay = + tracksInfiniteQuery.data?.filter((track) => typeof track === 'object') ?? [] - // Memoize key extraction for FlashList performance - const keyExtractor = React.useCallback( - (item: string | number | BaseItemDto) => - typeof item === 'object' ? item.Id! : item.toString(), - [], - ) + const keyExtractor = (item: string | number | BaseItemDto) => + typeof item === 'object' ? item.Id! : item.toString() /** * Memoize render item to prevent recreation @@ -66,31 +59,35 @@ export default function Tracks({ * it factors in the list headings, meaning pressing a track may not * play that exact track, since the index was offset by the headings */ - const renderItem = useCallback( - ({ item: track, index }: { index: number; item: string | number | BaseItemDto }) => - typeof track === 'string' ? ( - - ) : typeof track === 'number' ? null : typeof track === 'object' ? ( - - ) : null, - [tracksToDisplay, queue, navigation, queue], - ) + const renderItem = ({ + item: track, + index, + }: { + index: number + item: string | number | BaseItemDto + }) => + typeof track === 'string' ? ( + + ) : typeof track === 'number' ? null : typeof track === 'object' ? ( + + ) : null - const ItemSeparatorComponent = useCallback( - ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => - typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( - - ), - [], - ) + const ItemSeparatorComponent = ({ + leadingItem, + trailingItem, + }: { + leadingItem: unknown + trailingItem: unknown + }) => + typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : // Effect for handling the pending alphabet selector letter useEffect(() => { @@ -129,9 +126,9 @@ export default function Tracks({ } }, [pendingLetterRef.current, tracksInfiniteQuery.data]) - const handleScrollBeginDrag = useCallback(() => { + const handleScrollBeginDrag = () => { closeAllSwipeableRows() - }, []) + } return ( diff --git a/src/hooks/use-item-context.ts b/src/hooks/use-item-context.ts index 71a1fe71..43870611 100644 --- a/src/hooks/use-item-context.ts +++ b/src/hooks/use-item-context.ts @@ -7,7 +7,7 @@ import { fetchMediaInfo } from '../api/queries/media/utils' import { fetchAlbumDiscs, fetchItem } from '../api/queries/item' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import fetchUserData from '../api/queries/user-data/utils' -import { useCallback, useRef } from 'react' +import { useRef } from 'react' import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile' import UserDataQueryKey from '../api/queries/user-data/keys' import MediaInfoQueryKey from '../api/queries/media/keys' @@ -23,20 +23,17 @@ export default function useItemContext(): (item: BaseItemDto) => void { const prefetchedContext = useRef>(new Set()) - return useCallback( - (item: BaseItemDto) => { - const effectSig = `${item.Id}-${item.Type}` + return (item: BaseItemDto) => { + const effectSig = `${item.Id}-${item.Type}` - // If we've already warmed the cache for this item, return - if (prefetchedContext.current.has(effectSig)) return + // If we've already warmed the cache for this item, return + if (prefetchedContext.current.has(effectSig)) return - // Mark this item's context as warmed, preventing reruns - prefetchedContext.current.add(effectSig) + // Mark this item's context as warmed, preventing reruns + prefetchedContext.current.add(effectSig) - warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile) - }, - [api, user, streamingDeviceProfile, downloadingDeviceProfile], - ) + warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile) + } } function warmItemContext( diff --git a/src/providers/Artist/index.tsx b/src/providers/Artist/index.tsx index 5c610a84..0dea031c 100644 --- a/src/providers/Artist/index.tsx +++ b/src/providers/Artist/index.tsx @@ -2,7 +2,7 @@ import fetchSimilar from '../../api/queries/similar' import { QueryKeys } from '../../enums/query-keys' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { useQuery } from '@tanstack/react-query' -import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react' +import { createContext, ReactNode, useContext } from 'react' import { SharedValue, useSharedValue } from 'react-native-reanimated' import { isUndefined } from 'lodash' import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist' @@ -65,38 +65,25 @@ export const ArtistProvider = ({ enabled: !isUndefined(artist.Id), }) - const refresh = useCallback(() => { + const refresh = () => { refetchAlbums() refetchFeaturedOn() refetchSimilar() - }, [refetchAlbums, refetchFeaturedOn, refetchSimilar]) + } const scroll = useSharedValue(0) - const value = useMemo( - () => ({ - artist, - albums, - featuredOn, - similarArtists, - fetchingAlbums, - fetchingFeaturedOn, - fetchingSimilarArtists, - refresh, - scroll, - }), - [ - artist, - albums, - featuredOn, - similarArtists, - fetchingAlbums, - fetchingFeaturedOn, - fetchingSimilarArtists, - refresh, - scroll, - ], - ) + const value = { + artist, + albums, + featuredOn, + similarArtists, + fetchingAlbums, + fetchingFeaturedOn, + fetchingSimilarArtists, + refresh, + scroll, + } return {children} } diff --git a/src/providers/Network/index.tsx b/src/providers/Network/index.tsx index bb932383..64a2fcd7 100644 --- a/src/providers/Network/index.tsx +++ b/src/providers/Network/index.tsx @@ -1,4 +1,4 @@ -import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react' +import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react' import { JellifyDownloadProgress } from '../../types/JellifyDownload' import { saveAudio } from '../../api/mutations/download/offlineModeUtils' import JellifyTrack from '../../types/JellifyTrack' @@ -97,17 +97,7 @@ export const NetworkContextProvider: ({ }) => React.JSX.Element = ({ children }: { children: ReactNode }) => { const context = NetworkContextInitializer() - // Memoize the context value to prevent unnecessary re-renders - const value = useMemo( - () => context, - [ - context.downloadedTracks?.length, - context.pendingDownloads.length, - context.downloadingDownloads.length, - context.completedDownloads.length, - context.failedDownloads.length, - ], - ) + const value = context return {children} } diff --git a/src/providers/Player/index.tsx b/src/providers/Player/index.tsx index f5cac90c..6154b38c 100644 --- a/src/providers/Player/index.tsx +++ b/src/providers/Player/index.tsx @@ -1,6 +1,6 @@ import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player' -import { createContext, useCallback, useEffect, useState } from 'react' +import { createContext, useEffect, useState } from 'react' import { handleActiveTrackChanged } from './functions' import JellifyTrack from '../../types/JellifyTrack' import { useAutoDownload } from '../../stores/settings/usage' @@ -43,69 +43,61 @@ export const PlayerProvider: () => React.JSX.Element = () => { usePerformanceMonitor('PlayerProvider', 3) - const eventHandler = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (event: any) => { - switch (event.type) { - case Event.PlaybackActiveTrackChanged: { - // When we load a new queue, our index is updated before RNTP - // Because of this, we only need to respond to this event - // if the index from the event differs from what we have stored - if (event.track && enableAudioNormalization) { - const volume = calculateTrackVolume(event.track) - await TrackPlayer.setVolume(volume) - } else if (event.track) { - try { - await reportPlaybackStarted(api, event.track) - } catch (error) { - console.error('Unable to report playback started for track', error) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventHandler = async (event: any) => { + switch (event.type) { + case Event.PlaybackActiveTrackChanged: { + // When we load a new queue, our index is updated before RNTP + // Because of this, we only need to respond to this event + // if the index from the event differs from what we have stored + if (event.track && enableAudioNormalization) { + const volume = calculateTrackVolume(event.track) + await TrackPlayer.setVolume(volume) + } else if (event.track) { + try { + await reportPlaybackStarted(api, event.track) + } catch (error) { + console.error('Unable to report playback started for track', error) } - - await handleActiveTrackChanged() - - if (event.lastTrack) { - try { - if ( - isPlaybackFinished( - event.lastPosition, - event.lastTrack.duration ?? 1, - ) - ) - await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack) - else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack) - } catch (error) { - console.error('Unable to report playback stopped for lastTrack', error) - } - } - break - } - case Event.PlaybackProgressUpdated: { - const currentTrack = usePlayerQueueStore.getState().currentTrack - - if (event.position / event.duration > 0.3 && autoDownload && currentTrack) { - await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true) - } - - break } - case Event.PlaybackState: { - const currentTrack = usePlayerQueueStore.getState().currentTrack - switch (event.state) { - case State.Playing: - if (currentTrack) await reportPlaybackStarted(api, currentTrack) - break - default: - if (currentTrack) await reportPlaybackStopped(api, currentTrack) - break + await handleActiveTrackChanged() + + if (event.lastTrack) { + try { + if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1)) + await reportPlaybackCompleted(api, event.lastTrack as JellifyTrack) + else await reportPlaybackStopped(api, event.lastTrack as JellifyTrack) + } catch (error) { + console.error('Unable to report playback stopped for lastTrack', error) } - break } + break } - }, - [api, autoDownload, enableAudioNormalization], - ) + case Event.PlaybackProgressUpdated: { + const currentTrack = usePlayerQueueStore.getState().currentTrack + + if (event.position / event.duration > 0.3 && autoDownload && currentTrack) { + await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true) + } + + break + } + + case Event.PlaybackState: { + const currentTrack = usePlayerQueueStore.getState().currentTrack + switch (event.state) { + case State.Playing: + if (currentTrack) await reportPlaybackStarted(api, currentTrack) + break + default: + if (currentTrack) await reportPlaybackStopped(api, currentTrack) + break + } + break + } + } + } useTrackPlayerEvents(PLAYER_EVENTS, eventHandler) diff --git a/src/providers/Storage/index.tsx b/src/providers/Storage/index.tsx index 54c7ac4d..d34a4918 100644 --- a/src/providers/Storage/index.tsx +++ b/src/providers/Storage/index.tsx @@ -1,11 +1,4 @@ -import React, { - PropsWithChildren, - createContext, - useCallback, - useContext, - useMemo, - useState, -} from 'react' +import React, { PropsWithChildren, createContext, useContext, useState } from 'react' import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download' import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload' import { @@ -80,12 +73,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem const [isDeleting, setIsDeleting] = useState(false) const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false) - const activeDownloadsCount = useMemo( - () => Object.keys(activeDownloads ?? {}).length, - [activeDownloads], - ) + const activeDownloadsCount = Object.keys(activeDownloads ?? {}).length - const summary = useMemo(() => { + const summary: StorageSummary | undefined = (() => { if (!downloads || !storageInfo) return undefined const audioBytes = downloads.reduce( @@ -110,9 +100,9 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem artworkBytes, audioBytes, } - }, [downloads, storageInfo]) + })() - const suggestions = useMemo(() => { + const suggestions: CleanupSuggestion[] = (() => { if (!downloads || downloads.length === 0) return [] const now = Date.now() @@ -168,86 +158,69 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem }) return list - }, [downloads]) + })() - const toggleSelection = useCallback((itemId: string) => { + const toggleSelection = (itemId: string) => { setSelection((prev) => ({ ...prev, [itemId]: !prev[itemId], })) - }, []) + } - const clearSelection = useCallback(() => setSelection({}), []) + const clearSelection = () => setSelection({}) - const deleteDownloads = useCallback( - async (itemIds: string[]): Promise => { - if (!itemIds.length) return undefined - setIsDeleting(true) - try { - const result = await deleteDownloadsByIds(itemIds) - await Promise.all([refetchDownloads(), refetchStorageInfo()]) - setSelection((prev) => { - const updated = { ...prev } - itemIds.forEach((id) => delete updated[id]) - return updated - }) - return result - } finally { - setIsDeleting(false) - } - }, - [refetchDownloads, refetchStorageInfo], - ) + const deleteDownloads = async ( + itemIds: string[], + ): Promise => { + if (!itemIds.length) return undefined + setIsDeleting(true) + try { + const result = await deleteDownloadsByIds(itemIds) + await Promise.all([refetchDownloads(), refetchStorageInfo()]) + setSelection((prev) => { + const updated = { ...prev } + itemIds.forEach((id) => delete updated[id]) + return updated + }) + return result + } finally { + setIsDeleting(false) + } + } - const deleteSelection = useCallback(async () => { + const deleteSelection = async () => { const idsToDelete = Object.entries(selection) .filter(([, isSelected]) => isSelected) .map(([id]) => id) return deleteDownloads(idsToDelete) - }, [selection, deleteDownloads]) + } - const refresh = useCallback(async () => { + const refresh = async () => { setIsManuallyRefreshing(true) try { await Promise.all([refetchDownloads(), refetchStorageInfo()]) } finally { setIsManuallyRefreshing(false) } - }, [refetchDownloads, refetchStorageInfo]) + } const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing - const value = useMemo( - () => ({ - downloads, - summary, - suggestions, - selection, - toggleSelection, - clearSelection, - deleteSelection, - deleteDownloads, - isDeleting, - refresh, - refreshing, - activeDownloadsCount, - activeDownloads, - }), - [ - downloads, - summary, - suggestions, - selection, - toggleSelection, - clearSelection, - deleteSelection, - deleteDownloads, - isDeleting, - refresh, - refreshing, - activeDownloadsCount, - ], - ) + const value: StorageContextValue = { + downloads, + summary, + suggestions, + selection, + toggleSelection, + clearSelection, + deleteSelection, + deleteDownloads, + isDeleting, + refresh, + refreshing, + activeDownloadsCount, + activeDownloads, + } return {children} } From b418b76269512f72ed84cb3ada86542dac876c29 Mon Sep 17 00:00:00 2001 From: anultravioletaurora Date: Mon, 1 Dec 2025 20:04:58 +0000 Subject: [PATCH 06/16] [skip actions] version bump --- android/app/build.gradle | 4 ++-- ios/Jellify.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7c0afa7f..bfbf4f9b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,8 +91,8 @@ android { applicationId "com.cosmonautical.jellify" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 155 - versionName "0.21.3" + versionCode 156 + versionName "0.22.0" } signingConfigs { debug { diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj index cac3896a..98834729 100644 --- a/ios/Jellify.xcodeproj/project.pbxproj +++ b/ios/Jellify.xcodeproj/project.pbxproj @@ -543,7 +543,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 264; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_BITCODE = NO; @@ -554,7 +554,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.21.3; + MARKETING_VERSION = 0.22.0; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -585,7 +585,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 264; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -595,7 +595,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.21.3; + MARKETING_VERSION = 0.22.0; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -821,7 +821,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 264; + CURRENT_PROJECT_VERSION = 265; DEVELOPMENT_TEAM = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -832,7 +832,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.21.3; + MARKETING_VERSION = 0.22.0; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index db800bac..2b147680 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellify", - "version": "0.21.3", + "version": "0.22.0", "private": true, "scripts": { "init-android": "bun i", From 0f048671e74f1c1c78eb624e207d625ae5003c17 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:18:31 -0600 Subject: [PATCH 07/16] remove unnecessary memoization --- bun.lock | 1 + src/components/Album/index.tsx | 22 ++---- src/components/Playlist/index.tsx | 124 +++++++++++++----------------- 3 files changed, 63 insertions(+), 84 deletions(-) diff --git a/bun.lock b/bun.lock index 01ec36f7..b27f4902 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "jellify", diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index a044a3c9..3afcbdcd 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -9,7 +9,7 @@ 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, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { useSafeAreaFrame } from 'react-native-safe-area-context' import Icon from '../Global/components/icon' import { mapDtoToTrack } from '../../utils/mappings' @@ -50,22 +50,14 @@ export function Album(): React.JSX.Element { addToDownloadQueue(jellifyTracks) } - const sections = useMemo( - () => - (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({ - title, - data: Array.isArray(data) ? data : [], - })), - [discs], - ) + const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({ + title, + data: Array.isArray(data) ? data : [], + })) const hasMultipleSections = sections.length > 1 - const albumTrackList = useMemo(() => discs?.flatMap((disc) => disc.data), [discs]) - - const handleScrollBeginDrag = useCallback(() => { - closeAllSwipeableRows() - }, []) + const albumTrackList = discs?.flatMap((disc) => disc.data) return ( : No tracks found} )} - onScrollBeginDrag={handleScrollBeginDrag} + onScrollBeginDrag={closeAllSwipeableRows} /> ) } diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx index 8f09723f..16d611fd 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -1,4 +1,4 @@ -import { ScrollView, Spinner, useTheme, XStack, YStack } from 'tamagui' +import { ScrollView, Spinner, useTheme, XStack } from 'tamagui' import Track from '../Global/components/track' import Icon from '../Global/components/icon' import { PlaylistProps } from './interfaces' @@ -7,7 +7,7 @@ import { StackActions, useNavigation } from '@react-navigation/native' import { RootStackParamList } from '../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Sortable from 'react-native-sortables' -import { useCallback, useLayoutEffect } from 'react' +import { useLayoutEffect } from 'react' import { useReducedHapticsSetting } from '../../stores/settings/app' import { RenderItemInfo } from 'react-native-sortables/dist/typescript/types' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' @@ -110,79 +110,65 @@ export default function Playlist({ const rootNavigation = useNavigation>() - const renderItem = useCallback( - ({ item: track, index }: RenderItemInfo) => { - const handlePress = async () => { - await loadNewQueue({ - track, - tracklist: playlistTracks ?? [], - api, - networkStatus, - deviceProfile: streamingDeviceProfile, - index, - queue: playlist, - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - } + const renderItem = ({ item: track, index }: RenderItemInfo) => { + const handlePress = async () => { + await loadNewQueue({ + track, + tracklist: playlistTracks ?? [], + api, + networkStatus, + deviceProfile: streamingDeviceProfile, + index, + queue: playlist, + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + } - return ( - - {editing && ( - - - - )} + return ( + + {editing && ( + + + + )} + { + if (!editing) + rootNavigation.navigate('Context', { + item: track, + navigation, + }) + }} + > + + + + {editing && ( { - if (!editing) - rootNavigation.navigate('Context', { - item: track, - navigation, - }) + onTap={() => { + setPlaylistTracks( + (playlistTracks ?? []).filter(({ Id }) => Id !== track.Id), + ) }} > - + - - {editing && ( - { - setPlaylistTracks( - (playlistTracks ?? []).filter(({ Id }) => Id !== track.Id), - ) - }} - > - - - )} - - ) - }, - [ - navigation, - playlist, - playlistTracks, - editing, - setPlaylistTracks, - loadNewQueue, - api, - networkStatus, - streamingDeviceProfile, - rootNavigation, - ], - ) + )} + + ) + } return ( Date: Mon, 1 Dec 2025 15:56:19 -0600 Subject: [PATCH 08/16] rendering fiixes to playlist and albums bump react native sortables --- bun.lock | 4 +- package.json | 2 +- src/components/Album/index.tsx | 28 ++-- src/components/Playlist/components/header.tsx | 46 ++---- src/components/Playlist/index.tsx | 92 +++++++++--- src/providers/Album/index.tsx | 47 ------ src/providers/Playlist/index.tsx | 138 ------------------ src/screens/Album/index.tsx | 7 +- src/screens/Playlist/index.tsx | 15 +- 9 files changed, 114 insertions(+), 265 deletions(-) delete mode 100644 src/providers/Album/index.tsx delete mode 100644 src/providers/Playlist/index.tsx diff --git a/bun.lock b/bun.lock index b27f4902..d31158fd 100644 --- a/bun.lock +++ b/bun.lock @@ -51,7 +51,7 @@ "react-native-reanimated": "4.1.5", "react-native-safe-area-context": "5.6.2", "react-native-screens": "4.18.0", - "react-native-sortables": "^1.9.3", + "react-native-sortables": "1.9.4", "react-native-text-ticker": "^1.15.0", "react-native-toast-message": "^2.3.3", "react-native-track-player": "5.0.0-alpha0", @@ -1942,7 +1942,7 @@ "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="], - "react-native-sortables": ["react-native-sortables@1.9.3", "", { "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-VLhW9+3AVEaJNwwQSgN+n/Qe+YRB0C0mNWTjHhyzcZ+YjY4BmJao4bZxl5lD6EsfqZ1Ij6B2ZdxjNlSkUXrvow=="], + "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.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="], diff --git a/package.json b/package.json index 2b147680..53fc10ce 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "react-native-reanimated": "4.1.5", "react-native-safe-area-context": "5.6.2", "react-native-screens": "4.18.0", - "react-native-sortables": "^1.9.3", + "react-native-sortables": "1.9.4", "react-native-text-ticker": "^1.15.0", "react-native-toast-message": "^2.3.3", "react-native-track-player": "5.0.0-alpha0", diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index 3afcbdcd..192f6504 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -17,7 +17,6 @@ import { useNetworkContext } from '../../providers/Network' import { useNetworkStatus } from '../../stores/network' import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' import { QueuingType } from '../../enums/queuing-type' -import { useAlbumContext } from '../../providers/Album' import { useNavigation } from '@react-navigation/native' import HomeStackParamList from '../../screens/Home/types' import LibraryStackParamList from '../../screens/Library/types' @@ -26,6 +25,9 @@ import { BaseStackParamList } from '../../screens/types' import useStreamingDeviceProfile, { useDownloadingDeviceProfile } 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' /** * The screen for an Album's track list @@ -35,12 +37,16 @@ import { useApi } from '../../stores' * * @returns A React component */ -export function Album(): React.JSX.Element { +export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { const navigation = useNavigation>() - const { album, discs, isPending } = useAlbumContext() - const api = useApi() + + const { data: discs, isPending } = useQuery({ + queryKey: [QueryKeys.ItemTracks, album.Id], + queryFn: () => fetchAlbumDiscs(api, album), + }) + const { addToDownloadQueue, pendingDownloads } = useNetworkContext() const downloadingDeviceProfile = useDownloadingDeviceProfile() @@ -92,7 +98,7 @@ export function Album(): React.JSX.Element { ) : null }} - ListHeaderComponent={AlbumTrackListHeader} + ListHeaderComponent={() => } renderItem={({ item: track, index }) => ( )} - ListFooterComponent={AlbumTrackListFooter} + ListFooterComponent={() => } ListEmptyComponent={() => ( {isPending ? : No tracks found} @@ -120,7 +126,7 @@ export function Album(): React.JSX.Element { * @param playAlbum The function to call to play the album * @returns A React component */ -function AlbumTrackListHeader(): React.JSX.Element { +function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element { const api = useApi() const { width } = useSafeAreaFrame() @@ -130,7 +136,10 @@ function AlbumTrackListHeader(): React.JSX.Element { const loadNewQueue = useLoadNewQueue() - const { album, discs } = useAlbumContext() + const { data: discs, isPending } = useQuery({ + queryKey: [QueryKeys.ItemTracks, album.Id], + queryFn: () => fetchAlbumDiscs(api, album), + }) const navigation = useNavigation>() @@ -235,8 +244,7 @@ function AlbumTrackListHeader(): React.JSX.Element { ) } -function AlbumTrackListFooter(): React.JSX.Element { - const { album } = useAlbumContext() +function AlbumTrackListFooter({ album }: { album: BaseItemDto }): React.JSX.Element { const navigation = useNavigation< NativeStackNavigationProp< diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx index 036b00b6..bf9647d5 100644 --- a/src/components/Playlist/components/header.tsx +++ b/src/components/Playlist/components/header.tsx @@ -3,7 +3,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { H5, Spacer, XStack, YStack } from 'tamagui' import InstantMixButton from '../../Global/components/instant-mix-button' import Icon from '../../Global/components/icon' -import { usePlaylistContext } from '../../../providers/Playlist' import { useNetworkStatus } from '../../../../src/stores/network' import { useNetworkContext } from '../../../../src/providers/Network' import { ActivityIndicator } from 'react-native' @@ -19,15 +18,21 @@ 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 { Dispatch, SetStateAction } from 'react' export default function PlaylistTracklistHeader({ - canEdit, + playlist, + playlistTracks, + editing, + newName, + setNewName, }: { - canEdit?: boolean + playlist: BaseItemDto + playlistTracks: BaseItemDto[] | undefined + editing: boolean + newName: string + setNewName: Dispatch> }): React.JSX.Element { - const { playlist, playlistTracks, editing, setEditing, newName, setNewName } = - usePlaylistContext() - return ( @@ -68,10 +73,8 @@ export default function PlaylistTracklistHeader({ ) : ( @@ -83,16 +86,12 @@ export default function PlaylistTracklistHeader({ function PlaylistHeaderControls({ editing, - setEditing, playlist, playlistTracks, - canEdit, }: { editing: boolean - setEditing: (editing: boolean) => void playlist: BaseItemDto playlistTracks: BaseItemDto[] - canEdit: boolean | undefined }): React.JSX.Element { const { addToDownloadQueue, pendingDownloads } = useNetworkContext() const streamingDeviceProfile = useStreamingDeviceProfile() @@ -133,18 +132,7 @@ function PlaylistHeaderControls({ return ( - {editing && canEdit ? ( - { - navigation.push('DeletePlaylist', { playlist }) - }} - small - /> - ) : ( - - )} + @@ -155,16 +143,6 @@ function PlaylistHeaderControls({ playPlaylist(true)} small /> - {canEdit && ( - - setEditing(!editing)} - small - /> - - )} {!isDownloading ? ( (false) + + const [newName, setNewName] = useState(playlist.Name ?? '') + + const [playlistTracks, setPlaylistTracks] = useState(undefined) + + const trigger = useHapticFeedback() + + const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist) + + const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({ + mutationFn: ({ + playlist, + tracks, + newName, + }: { + playlist: BaseItemDto + tracks: BaseItemDto[] + newName: string + }) => { + return updatePlaylist( + api, + playlist.Id!, + newName, + tracks.map((track) => track.Id!), + ) + }, + onSuccess: () => { + trigger('notificationSuccess') + + // Refresh playlist component data + refetch() + }, + onError: () => { + trigger('notificationError') + setNewName(playlist.Name ?? '') + setPlaylistTracks(tracks ?? []) + }, + onSettled: () => { + setEditing(false) + }, + }) + + const handleCancel = () => { + setEditing(false) + setNewName(playlist.Name ?? '') + setPlaylistTracks(tracks) + } + + useEffect(() => { + if (!isPending && isSuccess) setPlaylistTracks(tracks) + }, [tracks, isPending, isSuccess]) + + useEffect(() => { + if (!editing) refetch() + }, [editing]) const loadNewQueue = useLoadNewQueue() @@ -128,9 +176,11 @@ export default function Playlist({ return ( {editing && ( - - - + + + + + )} } > - + fetchAlbumDiscs(api, album), - }) - - return { - album, - discs, - isPending, - } -} - -const AlbumContext = createContext({ - album: {}, - discs: undefined, - isPending: false, -}) - -export const AlbumProvider: ({ - album, - children, -}: { - album: BaseItemDto - children: ReactNode -}) => React.JSX.Element = ({ album, children }) => { - const context = AlbumContextInitializer(album) - - return {children} -} - -export const useAlbumContext = () => useContext(AlbumContext) diff --git a/src/providers/Playlist/index.tsx b/src/providers/Playlist/index.tsx deleted file mode 100644 index 2701c725..00000000 --- a/src/providers/Playlist/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' -import { UseMutateFunction, useMutation } from '@tanstack/react-query' -import { createContext, ReactNode, useContext, useEffect, useState } from 'react' -import { updatePlaylist } from '../../api/mutations/playlists' -import { SharedValue, useSharedValue } from 'react-native-reanimated' -import useHapticFeedback from '../../hooks/use-haptic-feedback' -import { useApi } from '../../stores' -import { usePlaylistTracks } from '../../api/queries/playlist' - -interface PlaylistContext { - playlist: BaseItemDto - playlistTracks: BaseItemDto[] | undefined - refetch: () => void - isPending: boolean - editing: boolean - setEditing: (editing: boolean) => void - newName: string - setNewName: (name: string) => void - setPlaylistTracks: (tracks: BaseItemDto[]) => void - useUpdatePlaylist: UseMutateFunction< - void, - Error, - { - playlist: BaseItemDto - tracks: BaseItemDto[] - newName: string - }, - unknown - > - isUpdating?: boolean - handleCancel: () => void -} - -const PlaylistContextInitializer = (playlist: BaseItemDto) => { - const api = useApi() - - const canEdit = playlist.CanDelete - const [editing, setEditing] = useState(false) - - const [newName, setNewName] = useState(playlist.Name ?? '') - - const [playlistTracks, setPlaylistTracks] = useState(undefined) - - const trigger = useHapticFeedback() - - const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist) - - const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({ - mutationFn: ({ - playlist, - tracks, - newName, - }: { - playlist: BaseItemDto - tracks: BaseItemDto[] - newName: string - }) => { - return updatePlaylist( - api, - playlist.Id!, - newName, - tracks.map((track) => track.Id!), - ) - }, - onSuccess: () => { - trigger('notificationSuccess') - - // Refresh playlist component data - refetch() - }, - onError: () => { - trigger('notificationError') - setNewName(playlist.Name ?? '') - setPlaylistTracks(tracks ?? []) - }, - onSettled: () => { - setEditing(false) - }, - }) - - const handleCancel = () => { - setEditing(false) - setNewName(playlist.Name ?? '') - setPlaylistTracks(tracks) - } - - useEffect(() => { - if (!isPending && isSuccess) setPlaylistTracks(tracks) - }, [tracks, isPending, isSuccess]) - - useEffect(() => { - if (!editing) refetch() - }, [editing]) - - return { - playlist, - playlistTracks, - refetch, - isPending, - editing, - setEditing, - newName, - setNewName, - setPlaylistTracks, - useUpdatePlaylist, - handleCancel, - isUpdating, - } -} - -const PlaylistContext = createContext({ - playlist: {}, - playlistTracks: undefined, - refetch: () => {}, - isPending: false, - editing: false, - setEditing: () => {}, - newName: '', - setNewName: () => {}, - setPlaylistTracks: () => {}, - useUpdatePlaylist: () => {}, - handleCancel: () => {}, - isUpdating: false, -}) - -export const PlaylistProvider = ({ - playlist, - children, -}: { - playlist: BaseItemDto - children: ReactNode -}) => { - const context = PlaylistContextInitializer(playlist) - - return {children} -} - -export const usePlaylistContext = () => useContext(PlaylistContext) diff --git a/src/screens/Album/index.tsx b/src/screens/Album/index.tsx index c3c205be..ba5fc779 100644 --- a/src/screens/Album/index.tsx +++ b/src/screens/Album/index.tsx @@ -1,13 +1,8 @@ import { Album } from '../../components/Album' import { AlbumProps } from '../types' -import { AlbumProvider } from '../../providers/Album' export default function AlbumScreen({ route, navigation }: AlbumProps): React.JSX.Element { const { album } = route.params - return ( - - - - ) + return } diff --git a/src/screens/Playlist/index.tsx b/src/screens/Playlist/index.tsx index c036df8f..f1083a8e 100644 --- a/src/screens/Playlist/index.tsx +++ b/src/screens/Playlist/index.tsx @@ -1,9 +1,8 @@ -import { BaseStackParamList, RootStackParamList } from '../types' +import { BaseStackParamList } from '../types' import { RouteProp } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React from 'react' import Playlist from '../../components/Playlist/index' -import { PlaylistProvider } from '../../providers/Playlist' export function PlaylistScreen({ route, @@ -13,12 +12,10 @@ export function PlaylistScreen({ navigation: NativeStackNavigationProp }): React.JSX.Element { return ( - - - + ) } From 0f8a9e91c532040175eb6570b53c7bfdc3f48aa5 Mon Sep 17 00:00:00 2001 From: Violet Caulfield Date: Tue, 2 Dec 2025 01:20:08 -0600 Subject: [PATCH 09/16] home screen animation and indicator improvements --- .../Home/helpers/frequent-artists.tsx | 20 ++- .../Home/helpers/frequent-tracks.tsx | 22 ++- .../Home/helpers/recent-artists.tsx | 18 ++- .../Home/helpers/recently-played.tsx | 126 +++++++++--------- src/components/Home/index.tsx | 11 +- 5 files changed, 119 insertions(+), 78 deletions(-) diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx index 811b3052..a8b1410e 100644 --- a/src/components/Home/helpers/frequent-artists.tsx +++ b/src/components/Home/helpers/frequent-artists.tsx @@ -2,7 +2,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React, { useCallback } from 'react' import { ItemCard } from '../../../components/Global/components/item-card' -import { H5, View, XStack } from 'tamagui' +import { H5, XStack } from 'tamagui' import Icon from '../../Global/components/icon' import { useDisplayContext } from '../../../providers/Display/display-provider' import { useNavigation } from '@react-navigation/native' @@ -11,6 +11,7 @@ import { RootStackParamList } from '../../../screens/types' import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { pickFirstGenre } from '../../../utils/genre-formatting' +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' export default function FrequentArtists(): React.JSX.Element { const navigation = useNavigation>() @@ -42,8 +43,15 @@ export default function FrequentArtists(): React.JSX.Element { [], ) - return ( - + return frequentArtistsInfiniteQuery.data ? ( + { @@ -57,9 +65,11 @@ export default function FrequentArtists(): React.JSX.Element { - + + ) : ( + <> ) } diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx index 680daa3d..851072a9 100644 --- a/src/components/Home/helpers/frequent-tracks.tsx +++ b/src/components/Home/helpers/frequent-tracks.tsx @@ -1,5 +1,5 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { H5, View, XStack } from 'tamagui' +import { H5, XStack } from 'tamagui' import HorizontalCardList from '../../../components/Global/components/horizontal-list' import { ItemCard } from '../../../components/Global/components/item-card' import { QueuingType } from '../../../enums/queuing-type' @@ -13,6 +13,7 @@ import { useNetworkStatus } from '../../../stores/network' import useStreamingDeviceProfile from '../../../stores/device-profile' import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents' import { useApi } from '../../../stores' +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' export default function FrequentlyPlayedTracks(): React.JSX.Element { const api = useApi() @@ -30,8 +31,15 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element { const loadNewQueue = useLoadNewQueue() const { horizontalItems } = useDisplayContext() - return ( - + return tracksInfiniteQuery.data ? ( + { @@ -46,8 +54,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element { horizontalItems) - ? tracksInfiniteQuery.data?.slice(0, horizontalItems) + tracksInfiniteQuery.data.length > horizontalItems + ? tracksInfiniteQuery.data.slice(0, horizontalItems) : tracksInfiniteQuery.data } renderItem={({ item: track, index }) => ( @@ -81,6 +89,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element { /> )} /> - + + ) : ( + <> ) } diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx index 69dff96f..1d9fbbac 100644 --- a/src/components/Home/helpers/recent-artists.tsx +++ b/src/components/Home/helpers/recent-artists.tsx @@ -11,6 +11,7 @@ import HomeStackParamList from '../../../screens/Home/types' import { useRecentArtists } from '../../../api/queries/recents' import { pickFirstGenre } from '../../../utils/genre-formatting' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto' +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' export default function RecentArtists(): React.JSX.Element { const recentArtistsInfiniteQuery = useRecentArtists() @@ -50,17 +51,26 @@ export default function RecentArtists(): React.JSX.Element { [navigation, rootNavigation], ) - return ( - + return recentArtistsInfiniteQuery.data ? ( +
Recent Artists
-
+ + ) : ( + <> ) } diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx index f2ea49b3..7da08dda 100644 --- a/src/components/Home/helpers/recently-played.tsx +++ b/src/components/Home/helpers/recently-played.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react' -import { H5, View, XStack } from 'tamagui' +import React from 'react' +import { H5, XStack } from 'tamagui' import { ItemCard } from '../../Global/components/item-card' import { RootStackParamList } from '../../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' @@ -13,8 +13,8 @@ import HomeStackParamList from '../../../screens/Home/types' import { useNetworkStatus } from '../../../stores/network' import useStreamingDeviceProfile from '../../../stores/device-profile' import { useRecentlyPlayedTracks } from '../../../api/queries/recents' -import { useCurrentTrack } from '../../../stores/player/queue' import { useApi } from '../../../stores' +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' export default function RecentlyPlayed(): React.JSX.Element { const api = useApi() @@ -23,8 +23,6 @@ export default function RecentlyPlayed(): React.JSX.Element { const deviceProfile = useStreamingDeviceProfile() - const nowPlaying = useCurrentTrack() - const navigation = useNavigation>() const rootNavigation = useNavigation>() @@ -33,60 +31,68 @@ export default function RecentlyPlayed(): React.JSX.Element { const tracksInfiniteQuery = useRecentlyPlayedTracks() const { horizontalItems } = useDisplayContext() - return useMemo(() => { - return ( - - { - navigation.navigate('RecentTracks', { - tracksInfiniteQuery, - }) - }} - > -
Play it again
- -
- horizontalItems) - ? tracksInfiniteQuery.data?.slice(0, horizontalItems) - : tracksInfiniteQuery.data - } - renderItem={({ index, item: recentlyPlayedTrack }) => ( - { - loadNewQueue({ - api, - deviceProfile, - networkStatus, - track: recentlyPlayedTrack, - index: index, - tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack], - queue: 'Recently Played', - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - }} - onLongPress={() => { - rootNavigation.navigate('Context', { - item: recentlyPlayedTrack, - navigation, - }) - }} - marginHorizontal={'$1'} - captionAlign='left' - /> - )} - /> -
- ) - }, [tracksInfiniteQuery.data, nowPlaying]) + return tracksInfiniteQuery.data ? ( + + { + navigation.navigate('RecentTracks', { + tracksInfiniteQuery, + }) + }} + > +
Play it again
+ +
+ + horizontalItems) + ? tracksInfiniteQuery.data.slice(0, horizontalItems) + : tracksInfiniteQuery.data + } + renderItem={({ index, item: recentlyPlayedTrack }) => ( + { + loadNewQueue({ + api, + deviceProfile, + networkStatus, + track: recentlyPlayedTrack, + index: index, + tracklist: tracksInfiniteQuery.data ?? [recentlyPlayedTrack], + queue: 'Recently Played', + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + }} + onLongPress={() => { + rootNavigation.navigate('Context', { + item: recentlyPlayedTrack, + navigation, + }) + }} + marginHorizontal={'$1'} + captionAlign='left' + /> + )} + /> +
+ ) : ( + <> + ) } diff --git a/src/components/Home/index.tsx b/src/components/Home/index.tsx index 7e497e9b..5c127de9 100644 --- a/src/components/Home/index.tsx +++ b/src/components/Home/index.tsx @@ -7,6 +7,8 @@ import FrequentlyPlayedTracks from './helpers/frequent-tracks' import { usePreventRemove } from '@react-navigation/native' import useHomeQueries from '../../api/mutations/home' import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' +import { useIsRestoring } from '@tanstack/react-query' +import { useRecentlyPlayedTracks } from '../../api/queries/recents' const COMPONENT_NAME = 'Home' @@ -17,18 +19,21 @@ export function Home(): React.JSX.Element { usePerformanceMonitor(COMPONENT_NAME, 5) - const { isPending: refreshing, mutate: refresh } = useHomeQueries() + const { isPending: refreshing, mutateAsync: refresh } = useHomeQueries() + + const { isPending: loadingInitialData } = useRecentlyPlayedTracks() + + const isRestoring = useIsRestoring() return ( From 36069ba3ecf8e34f12c4fbb1d5cd30016938ef38 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:21:39 -0600 Subject: [PATCH 10/16] combine useEffect that sets the library selector and playlist library (#739) useRef instead of useState for playlist to prevent an additional rerender Co-authored-by: Ritesh Shukla --- .../Global/components/library-selector.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/components/Global/components/library-selector.tsx b/src/components/Global/components/library-selector.tsx index b1810b39..e93c4555 100644 --- a/src/components/Global/components/library-selector.tsx +++ b/src/components/Global/components/library-selector.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { Spinner, ToggleGroup, XStack, YStack } from 'tamagui' import { H2, Text } from '../helpers/text' import Button from '../helpers/button' import { SafeAreaView } from 'react-native-safe-area-context' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { BaseItemDto, CollectionType } from '@jellyfin/sdk/lib/generated-client/models' import { QueryKeys } from '../../../enums/query-keys' import { fetchUserViews } from '../../../api/queries/libraries' import { useQuery } from '@tanstack/react-query' @@ -57,7 +57,7 @@ export default function LibrarySelector({ const [selectedLibraryId, setSelectedLibraryId] = useState( library?.musicLibraryId, ) - const [playlistLibrary, setPlaylistLibrary] = useState(undefined) + const playlistLibrary = useRef(undefined) const handleLibrarySelection = () => { if (!selectedLibraryId || !libraries) return @@ -65,23 +65,24 @@ export default function LibrarySelector({ const selectedLibrary = libraries.find((lib) => lib.Id === selectedLibraryId) if (selectedLibrary) { - onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary) + onLibrarySelected(selectedLibraryId, selectedLibrary, playlistLibrary.current) } } const hasMultipleLibraries = musicLibraries.length > 1 - useEffect(() => { - if (libraries) { - setMusicLibraries(libraries.filter((library) => library.CollectionType === 'music')) - } - }, [libraries, isPending]) - useEffect(() => { if (!isPending && isSuccess && libraries) { + setMusicLibraries( + libraries.filter((library) => library.CollectionType === CollectionType.Music), + ) + // Find the playlist library - const foundPlaylistLibrary = libraries.find((lib) => lib.CollectionType === 'playlists') - setPlaylistLibrary(foundPlaylistLibrary) + const foundPlaylistLibrary = libraries.find( + (lib) => lib.CollectionType === CollectionType.Playlists, + ) + + if (foundPlaylistLibrary) playlistLibrary.current = foundPlaylistLibrary } }, [isPending, isSuccess, libraries]) From a111f057ba860145314294dbf3721eccf97c672d Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 3 Dec 2025 05:27:11 -0600 Subject: [PATCH 11/16] Bugfix/downloads not working after react compiler (#742) * build out download store in zustand * add download processor use effect to top level of authenticated app --- bun.lock | 30 ++-- ios/Podfile.lock | 12 +- package.json | 16 +- src/components/Album/index.tsx | 16 +- src/components/Context/index.tsx | 33 ++--- src/components/Playlist/components/header.tsx | 22 +-- src/components/Storage/index.tsx | 4 +- src/components/jellify.tsx | 18 +-- src/configs/download.config.ts | 1 + src/hooks/use-download-processor.ts | 64 ++++++++ src/providers/Network/index.tsx | 105 -------------- src/providers/Storage/index.tsx | 8 +- .../Settings/storage-management/index.tsx | 111 ++++++-------- src/stores/network/downloads.ts | 137 ++++++++++++++++++ src/stores/{network.ts => network/index.ts} | 2 +- 15 files changed, 315 insertions(+), 264 deletions(-) create mode 100644 src/configs/download.config.ts create mode 100644 src/hooks/use-download-processor.ts delete mode 100644 src/providers/Network/index.tsx create mode 100644 src/stores/network/downloads.ts rename src/stores/{network.ts => network/index.ts} (89%) diff --git a/bun.lock b/bun.lock index d31158fd..740f555c 100644 --- a/bun.lock +++ b/bun.lock @@ -11,17 +11,17 @@ "@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.6", - "@react-navigation/material-top-tabs": "7.4.4", - "@react-navigation/native": "7.1.21", - "@react-navigation/native-stack": "7.8.0", + "@react-navigation/bottom-tabs": "7.8.10", + "@react-navigation/material-top-tabs": "7.4.7", + "@react-navigation/native": "7.1.23", + "@react-navigation/native-stack": "7.8.4", "@sentry/react-native": "7.6.0", "@shopify/flash-list": "2.2.0", "@tamagui/config": "1.137.1", "@tanstack/query-async-storage-persister": "5.89.0", "@tanstack/react-query": "5.89.0", "@tanstack/react-query-persist-client": "5.89.0", - "@testing-library/react-native": "^13.2.3", + "@testing-library/react-native": "13.3.3", "@typedigital/telemetrydeck-react": "^0.4.1", "axios": "1.12.2", "bundle": "^2.1.0", @@ -45,8 +45,8 @@ "react-native-linear-gradient": "^2.8.3", "react-native-mmkv": "3.3.3", "react-native-nitro-fetch": "^0.1.6", - "react-native-nitro-modules": "^0.31.9", - "react-native-nitro-ota": "^0.4.0", + "react-native-nitro-modules": "0.31.10", + "react-native-nitro-ota": "0.7.2", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "4.1.5", "react-native-safe-area-context": "5.6.2", @@ -566,17 +566,17 @@ "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.82.1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.6", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-0wGtU+I1rCUjvAqKtzD2dwQaTICFf5J233vkg20cLrx8LNQPAgSsbnsDSM6S315OOoVLCIL1dcrNv7ExLBlWfw=="], + "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.10", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-NxKjtlRwkGU3O3hPxpS+Aq7mVNfgtLzBe4xpGjQNphLzklRbxa6Me//m2eKzogpitZhLR2xZb1A49HrLuWe2ww=="], - "@react-navigation/core": ["@react-navigation/core@7.13.2", "", { "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-A0pFeZlKp+FJob2lVr7otDt3M4rsSJrnAfXWoWR9JVeFtfEXsH/C0s7xtpDCMRUO58kzSBoTF1GYzoMC5DLD4g=="], + "@react-navigation/core": ["@react-navigation/core@7.13.4", "", { "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-JM9bkb7fr4P5YUOVEwoAZq3xPeSL9V6Nd1KKTyAwCgGUVhESbSRSy3Ri/PGu6ZcLb/t7/tM1NqP5tV1e1bAwUg=="], - "@react-navigation/elements": ["@react-navigation/elements@2.8.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.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-0c5nSDPP3bUFujgkSVqqMShaAup3XIxNe1KTK9LSmwKgWEneyo6OPIjIdiEwPlZvJZKi7ag5hDjacQLGwO0LGA=="], + "@react-navigation/elements": ["@react-navigation/elements@2.9.0", "", { "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.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-423uE+q/esaiMbXVLckFOd9MbWG06/vCYOP2hwzEUj9ZwzUgSpsIPovcu78qa8UMuvKD8wkyouM01Wvav1y/KQ=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-8OCT+tW4dlkEPhjmQWFEw867CKTL3och5N9TLt56lA+3pm55x1kljsVO6DF6BxF41iHrhIJIr09UrojVJDr5TQ=="], + "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.7", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-0fv+Ym9kOO7DLf8GRmkt9zNKPTbnYU62ATacv0zirNA+vBDT/fhlE67orUXsQa/nORXlUMvllCaKPf/oyD7UcQ=="], - "@react-navigation/native": ["@react-navigation/native@7.1.21", "", { "dependencies": { "@react-navigation/core": "^7.13.2", "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-mhpAewdivBL01ibErr91FUW9bvKhfAF6Xv/yr6UOJtDhv0jU6iUASUcA3i3T8VJCOB/vxmoke7VDp8M+wBFs/Q=="], + "@react-navigation/native": ["@react-navigation/native@7.1.23", "", { "dependencies": { "@react-navigation/core": "^7.13.4", "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-V+drzVkoVA8VO83cJ59UYe7dfdnMFpGDAybp7d5O1ufxt321Z5tOtNDOzhMGzHUENqo9QWc4P/HuCUmz7KMy+A=="], - "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.0", "", { "dependencies": { "@react-navigation/elements": "^2.8.3", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.21", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-iRqQY+IYB610BJY/335/kdNDhXQ8L9nPUlIT+DSk88FA86+C+4/vek8wcKw8IrfwdorT4m+6TY0v7Qnrt+WLKQ=="], + "@react-navigation/native-stack": ["@react-navigation/native-stack@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.9.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.23", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-7kpYHoZZ81SPtDDG9ttZtI4nXR8GbVsLL1KnT/7RiLkFdqHXlriGpVhG5BKJRS1CYXrGEn40NogYW2+OBplglg=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="], @@ -1930,9 +1930,9 @@ "react-native-nitro-fetch": ["react-native-nitro-fetch@0.1.6", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.2", "react-native-worklets-core": "^1.6.0" }, "optionalPeers": ["react-native-worklets-core"] }, "sha512-DbE/vN5B67SJM8Q0myHOwSSc7ASqJPaKYXVsWdNGIPS+csr9gygCKILT4RQ+xZ92iJGKn4bfyq+rRsacRWBV9A=="], - "react-native-nitro-modules": ["react-native-nitro-modules@0.31.9", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-w7NtHq4wP6LZgvDs7zbFU3B2uHpRx/bJlSTckw0By8NyEX39fURPGgHyi4a67q1O7I3iFJvbRNWUiiOBbNvHDg=="], + "react-native-nitro-modules": ["react-native-nitro-modules@0.31.10", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ=="], - "react-native-nitro-ota": ["react-native-nitro-ota@0.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-/JAoM2m3WsvnO7dC51bf5jCghxO78yrP3vHyq3/itK+MqiwU8HPk8bGbXLhE+/GYRPS8DbUHGrzptzO2KOoutQ=="], + "react-native-nitro-ota": ["react-native-nitro-ota@0.7.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.8" } }, "sha512-DUa2/QhFJBhSbzrTHGrc+qm1pSuJctccUcHlHZXjPV4fCEpi+4Y17QqI9U4D9MUnnP77afKEZJKFy+0NQeSAdA=="], "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ed299344..7a809916 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -42,7 +42,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - NitroModules (0.31.9): + - NitroModules (0.31.10): - boost - DoubleConversion - fast_float @@ -71,7 +71,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - NitroOta (0.4.0): + - NitroOta (0.7.2): - boost - DoubleConversion - fast_float @@ -102,7 +102,7 @@ PODS: - SocketRocket - SSZipArchive - Yoga - - NitroOtaBundleManager (0.4.0): + - NitroOtaBundleManager (0.7.2): - boost - DoubleConversion - fast_float @@ -3449,9 +3449,9 @@ SPEC CHECKSUMS: google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1 - NitroModules: 224bf833d249b0c7ce32831368f2887008579b13 - NitroOta: b4f7cdbe660e8f07f80f5eb9f169d70f698ea284 - NitroOtaBundleManager: 5e7c0f8c3f76cc06f9fe07a63879fe35496c27c7 + NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23 + NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679 + NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 diff --git a/package.json b/package.json index 53fc10ce..f947a874 100644 --- a/package.json +++ b/package.json @@ -43,19 +43,19 @@ "@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.6", - "@react-navigation/material-top-tabs": "7.4.4", - "@react-navigation/native": "7.1.21", - "@react-navigation/native-stack": "7.8.0", + "@react-navigation/bottom-tabs": "7.8.10", + "@react-navigation/material-top-tabs": "7.4.7", + "@react-navigation/native": "7.1.23", + "@react-navigation/native-stack": "7.8.4", "@sentry/react-native": "7.6.0", "@shopify/flash-list": "2.2.0", "@tamagui/config": "1.137.1", "@tanstack/query-async-storage-persister": "5.89.0", "@tanstack/react-query": "5.89.0", "@tanstack/react-query-persist-client": "5.89.0", - "@testing-library/react-native": "^13.2.3", + "@testing-library/react-native": "13.3.3", "@typedigital/telemetrydeck-react": "^0.4.1", - "axios": "1.12.2", + "axios": "1.13.2", "bundle": "^2.1.0", "dlx": "^0.2.1", "invert-color": "^2.0.0", @@ -77,8 +77,8 @@ "react-native-linear-gradient": "^2.8.3", "react-native-mmkv": "3.3.3", "react-native-nitro-fetch": "^0.1.6", - "react-native-nitro-modules": "^0.31.9", - "react-native-nitro-ota": "^0.4.0", + "react-native-nitro-modules": "0.31.10", + "react-native-nitro-ota": "0.7.2", "react-native-pager-view": "^6.9.1", "react-native-reanimated": "4.1.5", "react-native-safe-area-context": "5.6.2", diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index 192f6504..c607f4a8 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -12,8 +12,6 @@ import ItemImage from '../Global/components/image' import React, { useCallback } from 'react' import { useSafeAreaFrame } from 'react-native-safe-area-context' import Icon from '../Global/components/icon' -import { mapDtoToTrack } from '../../utils/mappings' -import { useNetworkContext } from '../../providers/Network' import { useNetworkStatus } from '../../stores/network' import { useLoadNewQueue } from '../../providers/Player/hooks/mutations' import { QueuingType } from '../../enums/queuing-type' @@ -22,12 +20,13 @@ 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, { useDownloadingDeviceProfile } from '../../stores/device-profile' +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' /** * The screen for an Album's track list @@ -47,14 +46,11 @@ export function Album({ album }: { album: BaseItemDto }): React.JSX.Element { queryFn: () => fetchAlbumDiscs(api, album), }) - const { addToDownloadQueue, pendingDownloads } = useNetworkContext() - const downloadingDeviceProfile = useDownloadingDeviceProfile() + const addToDownloadQueue = useAddToPendingDownloads() - const downloadAlbum = (item: BaseItemDto[]) => { - if (!api) return - const jellifyTracks = item.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile)) - addToDownloadQueue(jellifyTracks) - } + const pendingDownloads = usePendingDownloads() + + const downloadAlbum = (item: BaseItemDto[]) => addToDownloadQueue(item) const sections = (Array.isArray(discs) ? discs : []).map(({ title, data }) => ({ title, diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index 63de2abe..82565562 100644 --- a/src/components/Context/index.tsx +++ b/src/components/Context/index.tsx @@ -3,7 +3,7 @@ import { BaseItemKind, MediaSourceInfo, } from '@jellyfin/sdk/lib/generated-client/models' -import { ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui' +import { ListItem, Spinner, View, YGroup } from 'tamagui' import { BaseStackParamList, RootStackParamList } from '../../screens/types' import { Text } from '../Global/helpers/text' import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row' @@ -25,14 +25,17 @@ import TextTicker from 'react-native-text-ticker' import { TextTickerConfig } from '../Player/component.config' import { useAddToQueue } from '../../providers/Player/hooks/mutations' import { useNetworkStatus } from '../../stores/network' -import { useNetworkContext } from '../../providers/Network' -import { mapDtoToTrack } from '../../utils/mappings' -import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile' +import useStreamingDeviceProfile from '../../stores/device-profile' import { useIsDownloaded } from '../../api/queries/download' import { useDeleteDownloads } from '../../api/mutations/download' import useHapticFeedback from '../../hooks/use-haptic-feedback' import { Platform } from 'react-native' import { useApi } from '../../stores' +import useAddToPendingDownloads, { + useIsDownloading, + usePendingDownloads, +} from '../../stores/network/downloads' +import { networkStatusTypes } from '../Network/internetConnectionWatcher' type StackNavigation = Pick, 'navigate' | 'dispatch'> @@ -55,6 +58,8 @@ export default function ItemContext({ const trigger = useHapticFeedback() + const [networkStatus] = useNetworkStatus() + const isArtist = item.Type === BaseItemKind.MusicArtist const isAlbum = item.Type === BaseItemKind.MusicAlbum const isTrack = item.Type === BaseItemKind.Audio @@ -242,29 +247,15 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele } function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element { - const api = useApi() - const { addToDownloadQueue, pendingDownloads } = useNetworkContext() + const addToDownloadQueue = useAddToPendingDownloads() const useRemoveDownload = useDeleteDownloads() - const deviceProfile = useDownloadingDeviceProfile() - const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id)) - const downloadItems = () => { - if (!api) return - - const tracks = items.map((item) => mapDtoToTrack(api, item, deviceProfile)) - addToDownloadQueue(tracks) - } - const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id)) - const isPending = - items.filter( - (item) => - pendingDownloads.filter((download) => download.item.Id === item.Id).length > 0, - ).length > 0 + const isPending = useIsDownloading(items) return isPending ? ( addToDownloadQueue(items)} pressStyle={{ opacity: 0.5 }} > >() - const downloadPlaylist = () => { - if (!api) return - const jellifyTracks = playlistTracks.map((item) => - mapDtoToTrack(api, item, downloadingDeviceProfile), - ) - addToDownloadQueue(jellifyTracks) - } + const downloadPlaylist = () => addToDownloadQueue(playlistTracks) const playPlaylist = (shuffled: boolean = false) => { if (!playlistTracks || playlistTracks.length === 0) return diff --git a/src/components/Storage/index.tsx b/src/components/Storage/index.tsx index ae7fd2eb..50fc1e53 100644 --- a/src/components/Storage/index.tsx +++ b/src/components/Storage/index.tsx @@ -4,9 +4,9 @@ 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 { useNetworkContext } from '../../providers/Network' import { getToken, 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({ @@ -43,7 +43,7 @@ export default function StorageBar(): React.JSX.Element { const [used, setUsed] = useState(0) const [total, setTotal] = useState(1) - const { activeDownloads: activeDownloadsArray } = useNetworkContext() + const activeDownloadsArray = useDownloadProgress() const usageShared = useSharedValue(0) const percentUsed = used / total diff --git a/src/components/jellify.tsx b/src/components/jellify.tsx index a8dbc19b..e0f3094e 100644 --- a/src/components/jellify.tsx +++ b/src/components/jellify.tsx @@ -2,7 +2,6 @@ import _ from 'lodash' import React, { useEffect } from 'react' import Root from '../screens' import { PlayerProvider } from '../providers/Player' -import { NetworkContextProvider } from '../providers/Network' import { DisplayProvider } from '../providers/Display/display-provider' import { createTelemetryDeck, @@ -20,6 +19,7 @@ import { StorageProvider } from '../providers/Storage' import { useSelectPlayerEngine } from '../stores/player/engine' import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app' import { GLITCHTIP_DSN } from '../configs/config' +import useDownloadProcessor from '../hooks/use-download-processor' /** * The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider} * @returns The {@link Jellify} component @@ -76,14 +76,14 @@ function App(): React.JSX.Element { } }, [sendMetrics]) + useDownloadProcessor() + return ( - - - - - - - - + + + + + + ) } diff --git a/src/configs/download.config.ts b/src/configs/download.config.ts new file mode 100644 index 00000000..11ad1c67 --- /dev/null +++ b/src/configs/download.config.ts @@ -0,0 +1 @@ +export const MAX_CONCURRENT_DOWNLOADS = 1 diff --git a/src/hooks/use-download-processor.ts b/src/hooks/use-download-processor.ts new file mode 100644 index 00000000..66bf0779 --- /dev/null +++ b/src/hooks/use-download-processor.ts @@ -0,0 +1,64 @@ +import { useEffect } from 'react' +import { + useAddToCompletedDownloads, + useAddToCurrentDownloads, + useAddToFailedDownloads, + useDownloadsStore, + useRemoveFromCurrentDownloads, + useRemoveFromPendingDownloads, +} from '../stores/network/downloads' +import { MAX_CONCURRENT_DOWNLOADS } from '../configs/download.config' +import { useAllDownloadedTracks } from '../api/queries/download' +import { saveAudio } from '../api/mutations/download/offlineModeUtils' + +const useDownloadProcessor = () => { + const { pendingDownloads, currentDownloads } = useDownloadsStore() + + const { data: downloadedTracks } = useAllDownloadedTracks() + + const addToCurrentDownloads = useAddToCurrentDownloads() + + const removeFromCurrentDownloads = useRemoveFromCurrentDownloads() + + const removeFromPendingDownloads = useRemoveFromPendingDownloads() + + const addToCompletedDownloads = useAddToCompletedDownloads() + + const addToFailedDownloads = useAddToFailedDownloads() + + const { refetch: refetchDownloadedTracks } = useAllDownloadedTracks() + + return useEffect(() => { + if (pendingDownloads.length > 0 && currentDownloads.length < MAX_CONCURRENT_DOWNLOADS) { + const availableSlots = MAX_CONCURRENT_DOWNLOADS - currentDownloads.length + const filesToStart = pendingDownloads.slice(0, availableSlots) + + console.debug('Downloading from queue') + + filesToStart.forEach((file) => { + addToCurrentDownloads(file) + removeFromPendingDownloads(file) + if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) { + removeFromCurrentDownloads(file) + addToCompletedDownloads(file) + return + } + + saveAudio(file, () => {}, false).then((success) => { + removeFromCurrentDownloads(file) + + if (success) { + addToCompletedDownloads(file) + } else { + addToFailedDownloads(file) + } + }) + }) + } + if (pendingDownloads.length === 0 && currentDownloads.length === 0) { + refetchDownloadedTracks() + } + }, [pendingDownloads.length, currentDownloads.length]) +} + +export default useDownloadProcessor diff --git a/src/providers/Network/index.tsx b/src/providers/Network/index.tsx deleted file mode 100644 index 64a2fcd7..00000000 --- a/src/providers/Network/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react' -import { JellifyDownloadProgress } from '../../types/JellifyDownload' -import { saveAudio } from '../../api/mutations/download/offlineModeUtils' -import JellifyTrack from '../../types/JellifyTrack' -import { useAllDownloadedTracks } from '../../api/queries/download' -import { usePerformanceMonitor } from '../../hooks/use-performance-monitor' - -interface NetworkContext { - activeDownloads: JellifyDownloadProgress | undefined - pendingDownloads: JellifyTrack[] - downloadingDownloads: JellifyTrack[] - completedDownloads: JellifyTrack[] - failedDownloads: JellifyTrack[] - addToDownloadQueue: (items: JellifyTrack[]) => boolean -} - -const MAX_CONCURRENT_DOWNLOADS = 1 - -const COMPONENT_NAME = 'NetworkProvider' - -const NetworkContextInitializer = () => { - usePerformanceMonitor(COMPONENT_NAME, 5) - const [downloadProgress, setDownloadProgress] = useState({}) - - // Mutiple Downloads - const [pending, setPending] = useState([]) - const [downloading, setDownloading] = useState([]) - const [completed, setCompleted] = useState([]) - const [failed, setFailed] = useState([]) - - const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks() - - useEffect(() => { - if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) { - const availableSlots = MAX_CONCURRENT_DOWNLOADS - downloading.length - const filesToStart = pending.slice(0, availableSlots) - - filesToStart.forEach((file) => { - setDownloading((prev) => [...prev, file]) - setPending((prev) => prev.filter((f) => f.item.Id !== file.item.Id)) - if (downloadedTracks?.some((t) => t.item.Id === file.item.Id)) { - setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id)) - setCompleted((prev) => [...prev, file]) - return - } - - saveAudio(file, setDownloadProgress, false).then((success) => { - setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id)) - setDownloadProgress((prev) => { - const next = { ...prev } - delete next[file.url] - if (file.artwork) delete next[file.artwork] - return next - }) - if (success) { - setCompleted((prev) => [...prev, file]) - } else { - setFailed((prev) => [...prev, file]) - } - }) - }) - } - if (pending.length === 0 && downloading.length === 0) { - refetchDownloadedTracks() - } - }, [pending, downloading]) - - const addToDownloadQueue = (items: JellifyTrack[]) => { - setPending((prev) => [...prev, ...items]) - return true - } - - return { - activeDownloads: downloadProgress, - downloadedTracks, - pendingDownloads: pending, - downloadingDownloads: downloading, - completedDownloads: completed, - failedDownloads: failed, - addToDownloadQueue, - } -} - -const NetworkContext = createContext({ - activeDownloads: {}, - pendingDownloads: [], - downloadingDownloads: [], - completedDownloads: [], - failedDownloads: [], - addToDownloadQueue: () => true, -}) - -export const NetworkContextProvider: ({ - children, -}: { - children: ReactNode -}) => React.JSX.Element = ({ children }: { children: ReactNode }) => { - const context = NetworkContextInitializer() - - const value = context - - return {children} -} - -export const useNetworkContext = () => useContext(NetworkContext) diff --git a/src/providers/Storage/index.tsx b/src/providers/Storage/index.tsx index d34a4918..58af2c57 100644 --- a/src/providers/Storage/index.tsx +++ b/src/providers/Storage/index.tsx @@ -1,11 +1,11 @@ -import React, { PropsWithChildren, createContext, useContext, useState } from 'react' +import React, { PropsWithChildren, createContext, use, useContext, useState } from 'react' import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download' import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload' import { DeleteDownloadsResult, deleteDownloadsByIds, } from '../../api/mutations/download/offlineModeUtils' -import { useNetworkContext } from '../Network' +import { useDownloadProgress } from '../../stores/network/downloads' export type StorageSummary = { totalSpace: number @@ -67,7 +67,7 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem refetch: refetchStorageInfo, isFetching: isFetchingStorage, } = useStorageInUse() - const { activeDownloads } = useNetworkContext() + const activeDownloads = useDownloadProgress() const [selection, setSelection] = useState({}) const [isDeleting, setIsDeleting] = useState(false) @@ -226,7 +226,7 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem } export const useStorageContext = () => { - const context = useContext(StorageContext) + const context = use(StorageContext) if (!context) throw new Error('StorageContext must be used within a StorageProvider') return context } diff --git a/src/screens/Settings/storage-management/index.tsx b/src/screens/Settings/storage-management/index.tsx index b3dfe0d3..d5893b86 100644 --- a/src/screens/Settings/storage-management/index.tsx +++ b/src/screens/Settings/storage-management/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +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' @@ -47,62 +47,44 @@ export default function StorageManagementScreen(): React.JSX.Element { const navigation = useNavigation>() const showDeletionToast = useDeletionToast() - useFocusEffect( - useCallback(() => { - void refresh() - }, [refresh]), - ) + const sortedDownloads = !downloads + ? [] + : [...downloads].sort( + (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(), + ) - const sortedDownloads = useMemo(() => { - if (!downloads) return [] - return [...downloads].sort( - (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(), - ) - }, [downloads]) + const selectedIds = Object.entries(selection) + .filter(([, isSelected]) => isSelected) + .map(([id]) => id) - const selectedIds = useMemo( - () => - Object.entries(selection) - .filter(([, isSelected]) => isSelected) - .map(([id]) => id), - [selection], - ) + const selectedBytes = + !selectedIds.length || !downloads + ? 0 + : downloads.reduce((total, download) => { + return new Set(selectedIds).has(download.item.Id as string) + ? total + getDownloadSize(download) + : total + }, 0) - const selectedBytes = useMemo(() => { - if (!selectedIds.length || !downloads) return 0 - const selectedSet = new Set(selectedIds) - return downloads.reduce((total, download) => { - return selectedSet.has(download.item.Id as string) - ? total + getDownloadSize(download) - : total - }, 0) - }, [downloads, selectedIds]) - - const handleApplySuggestion = useCallback( - async (suggestion: CleanupSuggestion) => { - if (!suggestion.itemIds.length) return - setApplyingSuggestionId(suggestion.id) - try { - const result = await deleteDownloads(suggestion.itemIds) - if (result?.deletedCount) - showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes) - } finally { - setApplyingSuggestionId(null) - } - }, - [deleteDownloads, showDeletionToast], - ) - - const handleDeleteSingle = useCallback( - async (download: JellifyDownload) => { - const result = await deleteDownloads([download.item.Id as string]) + const handleApplySuggestion = async (suggestion: CleanupSuggestion) => { + if (!suggestion.itemIds.length) return + setApplyingSuggestionId(suggestion.id) + try { + const result = await deleteDownloads(suggestion.itemIds) if (result?.deletedCount) - showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes) - }, - [deleteDownloads, showDeletionToast], - ) + showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes) + } finally { + setApplyingSuggestionId(null) + } + } - const handleDeleteAll = useCallback(() => { + const handleDeleteSingle = async (download: JellifyDownload) => { + const result = await deleteDownloads([download.item.Id as string]) + if (result?.deletedCount) + showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes) + } + + const handleDeleteAll = () => Alert.alert( 'Delete all downloads?', 'This will remove all downloaded music from your device. This action cannot be undone.', @@ -124,9 +106,8 @@ export default function StorageManagementScreen(): React.JSX.Element { }, ], ) - }, [downloads, deleteDownloads, showDeletionToast]) - const handleDeleteSelection = useCallback(() => { + const handleDeleteSelection = () => Alert.alert( 'Delete selected items?', `Are you sure you want to delete ${selectedIds.length} items?`, @@ -148,20 +129,16 @@ export default function StorageManagementScreen(): React.JSX.Element { }, ], ) - }, [selectedIds, deleteDownloads, showDeletionToast, clearSelection]) - const renderDownloadItem: ListRenderItem = useCallback( - ({ item }) => ( - toggleSelection(item.item.Id as string)} - onDelete={() => { - void handleDeleteSingle(item) - }} - /> - ), - [selection, toggleSelection, handleDeleteSingle], + const renderDownloadItem: ListRenderItem = ({ item }) => ( + toggleSelection(item.item.Id as string)} + onDelete={() => { + void handleDeleteSingle(item) + }} + /> ) const topPadding = 16 diff --git a/src/stores/network/downloads.ts b/src/stores/network/downloads.ts new file mode 100644 index 00000000..90634af4 --- /dev/null +++ b/src/stores/network/downloads.ts @@ -0,0 +1,137 @@ +import { mmkvStateStorage } from '../../constants/storage' +import { JellifyDownloadProgress } from '@/src/types/JellifyDownload' +import JellifyTrack from '@/src/types/JellifyTrack' +import { mapDtoToTrack } from '../../utils/mappings' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { create } from 'zustand' +import { createJSONStorage, devtools, persist } from 'zustand/middleware' +import { useApi } from '..' +import { useDownloadingDeviceProfile } from '../device-profile' + +type DownloadsStore = { + downloadProgress: JellifyDownloadProgress + setDownloadProgress: (progress: JellifyDownloadProgress) => void + pendingDownloads: JellifyTrack[] + setPendingDownloads: (items: JellifyTrack[]) => void + currentDownloads: JellifyTrack[] + setCurrentDownloads: (items: JellifyTrack[]) => void + completedDownloads: JellifyTrack[] + setCompletedDownloads: (items: JellifyTrack[]) => void + failedDownloads: JellifyTrack[] + setFailedDownloads: (items: JellifyTrack[]) => void +} + +export const useDownloadsStore = create()( + devtools( + persist( + (set) => ({ + downloadProgress: {}, + setDownloadProgress: (progress) => + set({ + downloadProgress: progress, + }), + pendingDownloads: [], + setPendingDownloads: (items) => + set({ + pendingDownloads: items, + }), + currentDownloads: [], + setCurrentDownloads: (items) => set({ currentDownloads: items }), + completedDownloads: [], + setCompletedDownloads: (items) => set({ completedDownloads: items }), + failedDownloads: [], + setFailedDownloads: (items) => set({ failedDownloads: items }), + }), + { + name: 'downloads-store', + storage: createJSONStorage(() => mmkvStateStorage), + }, + ), + ), +) + +export const useDownloadProgress = () => useDownloadsStore((state) => state.downloadProgress) + +export const usePendingDownloads = () => useDownloadsStore((state) => state.pendingDownloads) + +export const useCurrentDownloads = () => useDownloadsStore((state) => state.currentDownloads) + +export const useFailedDownloads = () => useDownloadsStore((state) => state.failedDownloads) + +export const useIsDownloading = (items: BaseItemDto[]) => { + const pendingDownloads = usePendingDownloads() + const currentDownloads = useCurrentDownloads() + + const downloadQueue = new Set([ + ...pendingDownloads.map((download) => download.item.Id), + ...currentDownloads.map((download) => download.item.Id), + ]) + + const itemIds = items.map((item) => item.Id) + + return itemIds.filter((id) => downloadQueue.has(id)).length === items.length +} + +export const useAddToCompletedDownloads = () => { + const currentDownloads = useCurrentDownloads() + const setCompletedDownloads = useDownloadsStore((state) => state.setCompletedDownloads) + + return (item: JellifyTrack) => setCompletedDownloads([...currentDownloads, item]) +} + +export const useAddToCurrentDownloads = () => { + const currentDownloads = useCurrentDownloads() + const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads) + + return (item: JellifyTrack) => setCurrentDownloads([...currentDownloads, item]) +} + +export const useRemoveFromCurrentDownloads = () => { + const currentDownloads = useCurrentDownloads() + + const setCurrentDownloads = useDownloadsStore((state) => state.setCurrentDownloads) + + return (item: JellifyTrack) => + setCurrentDownloads( + currentDownloads.filter((download) => download.item.Id !== item.item.Id), + ) +} + +export const useRemoveFromPendingDownloads = () => { + const pendingDownloads = usePendingDownloads() + + const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads) + + return (item: JellifyTrack) => + setPendingDownloads( + pendingDownloads.filter((download) => download.item.Id !== item.item.Id), + ) +} + +export const useAddToFailedDownloads = () => (item: JellifyTrack) => { + const failedDownloads = useFailedDownloads() + + const setFailedDownloads = useDownloadsStore((state) => state.setFailedDownloads) + + return setFailedDownloads([...failedDownloads, item]) +} + +const useAddToPendingDownloads = () => { + const api = useApi() + + const downloadingDeviceProfile = useDownloadingDeviceProfile() + + const pendingDownloads = usePendingDownloads() + + const setPendingDownloads = useDownloadsStore((state) => state.setPendingDownloads) + + return (items: BaseItemDto[]) => { + const downloads = api + ? items.map((item) => mapDtoToTrack(api, item, downloadingDeviceProfile)) + : [] + + return setPendingDownloads([...pendingDownloads, ...downloads]) + } +} + +export default useAddToPendingDownloads diff --git a/src/stores/network.ts b/src/stores/network/index.ts similarity index 89% rename from src/stores/network.ts rename to src/stores/network/index.ts index 4a995269..a72118ad 100644 --- a/src/stores/network.ts +++ b/src/stores/network/index.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { networkStatusTypes } from '../components/Network/internetConnectionWatcher' +import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher' type NetworkStore = { networkStatus: networkStatusTypes | null From 93c465feccf47f4e0edcb01179f9bf8fb70ac963 Mon Sep 17 00:00:00 2001 From: anultravioletaurora Date: Wed, 3 Dec 2025 12:14:10 +0000 Subject: [PATCH 12/16] [skip actions] version bump --- android/app/build.gradle | 4 ++-- ios/Jellify.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index bfbf4f9b..c0f489b3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,8 +91,8 @@ android { applicationId "com.cosmonautical.jellify" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 156 - versionName "0.22.0" + versionCode 157 + versionName "0.22.1" } signingConfigs { debug { diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj index 98834729..95712e53 100644 --- a/ios/Jellify.xcodeproj/project.pbxproj +++ b/ios/Jellify.xcodeproj/project.pbxproj @@ -543,7 +543,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_BITCODE = NO; @@ -554,7 +554,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.22.0; + MARKETING_VERSION = 0.22.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -585,7 +585,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -595,7 +595,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.22.0; + MARKETING_VERSION = 0.22.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", @@ -821,7 +821,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 265; + CURRENT_PROJECT_VERSION = 266; DEVELOPMENT_TEAM = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -832,7 +832,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.22.0; + MARKETING_VERSION = 0.22.1; NEW_SETTING = ""; OTHER_LDFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index f947a874..817addb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellify", - "version": "0.22.0", + "version": "0.22.1", "private": true, "scripts": { "init-android": "bun i", From f2761e3d8850696d2b59c033d2e01cc7df90ebef Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:50:15 -0600 Subject: [PATCH 13/16] add store links to readme --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8bb8537..fea07e2f 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Jellify logo

-[![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) -[![publish-beta](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml/badge.svg?branch=main)](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [![Publish Over-the-Air Update](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml/badge.svg)](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml) +[![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) [![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612) [![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify) + [![Sponsors](https://img.shields.io/github/sponsors/anultravioletaurora?label=Project%20Sponsors&color=magenta)](https://github.com/sponsors/anultravioletaurora) [![Patreon](https://img.shields.io/badge/Patreon-F96854?logo=patreon&logoColor=white)](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink) @@ -65,6 +65,10 @@ These projects are **not** required to use _Jellify_, but are recommended by us ### Android +[![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify) + +#### Direct .APK Download + Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly. Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository. @@ -73,6 +77,8 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the ### iOS +[![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612) + #### The TestFlight Way Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there From 238dd0340a693203b3caaaa30c405150cba7f96c Mon Sep 17 00:00:00 2001 From: skalthoff <32023561+skalthoff@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:07:30 -0800 Subject: [PATCH 14/16] Fixing Le Bugers: UI Polish & Performance Tuning (#724) * fix: update sort icon name and label in ArtistTabBar * fix: optimize image URL generation and improve performance in Playlists and Tracks components * homescreen improvements * deduplicate tracks in FrequentlyPlayedTracks and RecentlyPlayed components * enhance storage management with versioning and slimmed track persistence * refactor HorizontalCardList to allow customizable estimatedItemSize * update sort button label in ArtistTabBar for clarity * refactor media info fetching and improve search debounce logic * refactor navigation parameters in artist and track components for simplicity * refactor PlayPauseButton to manage optimistic UI state and improve playback handling * Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads * Revert "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads" This reverts commit 1c63b748b66a193ff99bb30b9b574801ef6717a8. * Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads * Revert "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads" This reverts commit f9e0e82e579e091fa3b8dc286bef08ccc8907d68. * Reapply "Cut queue remap churn, speed lyric lookup, clean cast listener cleanup, and avoid redundant HEADs on downloads" This reverts commit 6710d3404ce9a3366fd0be3fde7f70e32bbf276a. * Update project configuration: refine build phases, adjust code signing identity, and format flags * Fix TypeScript errors in Search component and improve playback state handler in Player queries * Refactor ItemRow component to accept queueName prop and update queue handling * lot's o fixes to item cards and item rows * memoize tracks component * fix jest * simplify navigation in FrequentArtists, FrequentlyPlayedTracks, RecentArtists, and RecentlyPlayed components * Update axios version and enhance image handling options in components * Enhance ItemImage component with imageOptions for better image handling in PlayerHeader and Miniplayer * moves buffers to player config --------- Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Co-authored-by: Violet Caulfield --- App.tsx | 80 +++++++------- bun.lock | 5 +- .../mutations/download/offlineModeUtils.ts | 52 +++++++-- src/api/queries/image/utils/index.ts | 23 ++++ src/api/queries/media/index.ts | 2 + src/components/Artist/TabBar.tsx | 7 +- src/components/Artist/header.tsx | 1 + .../Global/components/horizontal-list.tsx | 9 +- src/components/Global/components/image.tsx | 15 ++- .../Global/components/item-card.tsx | 2 +- src/components/Global/components/item-row.tsx | 28 +++-- src/components/Global/components/track.tsx | 102 ++++++++++++------ .../Home/helpers/frequent-artists.tsx | 4 +- .../Home/helpers/frequent-tracks.tsx | 4 +- .../Home/helpers/recent-artists.tsx | 6 +- .../Home/helpers/recently-played.tsx | 4 +- src/components/Player/components/header.tsx | 6 +- src/components/Player/components/lyrics.tsx | 28 +++-- src/components/Player/mini-player.tsx | 7 +- src/components/Playlists/component.tsx | 38 +++++-- src/constants/versioned-storage.ts | 74 +++++++++++++ src/hooks/use-performance-monitor.ts | 11 ++ src/player/config.ts | 12 +++ src/player/types/queue-item.ts | 15 +-- src/providers/Player/hooks/queries.ts | 25 ++++- src/screens/Home/types.d.ts | 19 +--- src/stores/player/engine.ts | 16 +-- src/stores/player/queue.ts | 99 ++++++++++++++++- src/types/JellifyTrack.ts | 43 ++++++++ 29 files changed, 562 insertions(+), 175 deletions(-) create mode 100644 src/constants/versioned-storage.ts diff --git a/App.tsx b/App.tsx index e576beba..367c3326 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,5 @@ import './gesture-handler' -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import 'react-native-url-polyfill/auto' import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import Jellify from './src/components/jellify' @@ -24,7 +24,7 @@ import ErrorBoundary from './src/components/ErrorBoundary' import OTAUpdateScreen from './src/components/OtaUpdates' import { usePerformanceMonitor } from './src/hooks/use-performance-monitor' import navigationRef from './navigation' -import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config' +import { BUFFERS, PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config' import { useThemeSetting } from './src/stores/settings/app' LogBox.ignoreAllLogs() @@ -34,47 +34,47 @@ export default function App(): React.JSX.Element { const performanceMetrics = usePerformanceMonitor('App', 3) const [playerIsReady, setPlayerIsReady] = useState(false) + const playerInitializedRef = useRef(false) - /** - * Enhanced Android buffer settings for gapless playback - * - * @see - */ - const buffers = - Platform.OS === 'android' - ? { - maxCacheSize: 50 * 1024, // 50MB cache - maxBuffer: 30, // 30 seconds buffer - playBuffer: 2.5, // 2.5 seconds play buffer - backBuffer: 5, // 5 seconds back buffer - } - : {} + useEffect(() => { + // Guard against double initialization (React StrictMode, hot reload) + if (playerInitializedRef.current) return + playerInitializedRef.current = true - TrackPlayer.setupPlayer({ - autoHandleInterruptions: true, - iosCategory: IOSCategory.Playback, - iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth], - androidAudioContentType: AndroidAudioContentType.Music, - minBuffer: 30, // 30 seconds minimum buffer - ...buffers, - }) - .then(() => - TrackPlayer.updateOptions({ - capabilities: CAPABILITIES, - notificationCapabilities: CAPABILITIES, - // Reduced interval for smoother progress tracking and earlier prefetch detection - progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL, - // Stop playback and remove notification when app is killed to prevent battery drain - android: { - appKilledPlaybackBehavior: - AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, - }, - }), - ) - .finally(() => { - setPlayerIsReady(true) - requestStoragePermission() + TrackPlayer.setupPlayer({ + autoHandleInterruptions: true, + iosCategory: IOSCategory.Playback, + iosCategoryOptions: [ + IOSCategoryOptions.AllowAirPlay, + IOSCategoryOptions.AllowBluetooth, + ], + androidAudioContentType: AndroidAudioContentType.Music, + minBuffer: 30, // 30 seconds minimum buffer + ...BUFFERS, }) + .then(() => + TrackPlayer.updateOptions({ + capabilities: CAPABILITIES, + notificationCapabilities: CAPABILITIES, + // Reduced interval for smoother progress tracking and earlier prefetch detection + progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL, + // Stop playback and remove notification when app is killed to prevent battery drain + android: { + appKilledPlaybackBehavior: + AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, + }, + }), + ) + .catch((error) => { + // Player may already be initialized (e.g., after hot reload) + // This is expected and not a fatal error + console.log('[TrackPlayer] Setup caught:', error?.message ?? error) + }) + .finally(() => { + setPlayerIsReady(true) + requestStoragePermission() + }) + }, []) // Empty deps - only run once on mount const [reloader, setReloader] = useState(0) diff --git a/bun.lock b/bun.lock index 740f555c..6886cf7e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "jellify", @@ -23,7 +22,7 @@ "@tanstack/react-query-persist-client": "5.89.0", "@testing-library/react-native": "13.3.3", "@typedigital/telemetrydeck-react": "^0.4.1", - "axios": "1.12.2", + "axios": "1.13.2", "bundle": "^2.1.0", "dlx": "^0.2.1", "invert-color": "^2.0.0", @@ -1018,7 +1017,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], diff --git a/src/api/mutations/download/offlineModeUtils.ts b/src/api/mutations/download/offlineModeUtils.ts index 9756099c..97e886dd 100644 --- a/src/api/mutations/download/offlineModeUtils.ts +++ b/src/api/mutations/download/offlineModeUtils.ts @@ -18,6 +18,27 @@ type DownloadedFileInfo = { size: number } +const getExtensionFromUrl = (url: string): string | null => { + const sanitized = url.split('?')[0] + const lastSegment = sanitized.split('/').pop() ?? '' + const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/) + return match?.[1] ?? null +} + +const normalizeExtension = (ext: string | undefined | null) => { + if (!ext) return null + const clean = ext.toLowerCase() + return clean === 'mpeg' ? 'mp3' : clean +} + +const extensionFromContentType = (contentType: string | undefined): string | null => { + if (!contentType) return null + if (!contentType.includes('/')) return null + const [, subtypeRaw] = contentType.split('/') + const container = subtypeRaw.split(';')[0] + return normalizeExtension(container) +} + export type DeleteDownloadsResult = { deletedCount: number freedBytes: number @@ -29,23 +50,30 @@ export async function downloadJellyfinFile( name: string, songName: string, setDownloadProgress: JellifyDownloadProgressState, + preferredExtension?: string | null, ): Promise { try { - // Fetch the file - const headRes = await axios.head(url) - const contentType = headRes.headers['content-type'] + const urlExtension = normalizeExtension(getExtensionFromUrl(url)) + const hintedExtension = normalizeExtension(preferredExtension) - // Step 2: Get extension from content-type - let extension = 'mp3' // default extension - if (contentType && contentType.includes('/')) { - const parts = contentType.split('/') - const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8" - if (container !== 'mpeg') { - extension = container // don't use mpeg as an extension, use the default extension + let extension = urlExtension ?? hintedExtension ?? null + + if (!extension) { + try { + const headRes = await axios.head(url) + const headExtension = extensionFromContentType(headRes.headers['content-type']) + if (headExtension) extension = headExtension + } catch (error) { + console.warn( + 'HEAD request failed when determining download type, using default', + error, + ) } } - // Step 3: Build path + if (!extension) extension = 'bin' // fallback without assuming a specific codec + + // Build path const fileName = `${name}.${extension}` const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}` @@ -138,6 +166,7 @@ export const saveAudio = async ( track.item.Id as string, track.title as string, setDownloadProgress, + track.mediaSourceInfo?.Container, ) let downloadedArtworkFile: DownloadedFileInfo | undefined if (track.artwork) { @@ -146,6 +175,7 @@ export const saveAudio = async ( track.item.Id as string, track.title as string, setDownloadProgress, + undefined, ) } track.url = downloadedTrackFile.uri diff --git a/src/api/queries/image/utils/index.ts b/src/api/queries/image/utils/index.ts index f9e6014d..3132112d 100644 --- a/src/api/queries/image/utils/index.ts +++ b/src/api/queries/image/utils/index.ts @@ -2,21 +2,44 @@ import { Api } from '@jellyfin/sdk' import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' +// Default image size for list thumbnails (optimized for common row heights) +const DEFAULT_THUMBNAIL_SIZE = 200 + +export interface ImageUrlOptions { + /** Maximum width of the requested image */ + maxWidth?: number + /** Maximum height of the requested image */ + maxHeight?: number + /** Image quality (0-100) */ + quality?: number +} + export function getItemImageUrl( api: Api | undefined, item: BaseItemDto, type: ImageType, + options?: ImageUrlOptions, ): string | undefined { const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item if (!api) return undefined + // Use provided dimensions or default thumbnail size for list performance + const imageParams = { + tag: undefined as string | undefined, + maxWidth: options?.maxWidth ?? DEFAULT_THUMBNAIL_SIZE, + maxHeight: options?.maxHeight ?? DEFAULT_THUMBNAIL_SIZE, + quality: options?.quality ?? 90, + } + return AlbumId ? getImageApi(api).getItemImageUrlById(AlbumId, type, { + ...imageParams, tag: AlbumPrimaryImageTag ?? undefined, }) : Id ? getImageApi(api).getItemImageUrlById(Id, type, { + ...imageParams, tag: ImageTags ? ImageTags[type] : undefined, }) : undefined diff --git a/src/api/queries/media/index.ts b/src/api/queries/media/index.ts index 6121d5fa..adceddd7 100644 --- a/src/api/queries/media/index.ts +++ b/src/api/queries/media/index.ts @@ -31,6 +31,7 @@ const useStreamedMediaInfo = (itemId: string | null | undefined) => { return useQuery({ queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), + enabled: Boolean(api && deviceProfile && itemId), staleTime: ONE_DAY, // Only refetch when the user's device profile changes gcTime: ONE_DAY, }) @@ -60,6 +61,7 @@ export const useDownloadedMediaInfo = (itemId: string | null | undefined) => { return useQuery({ queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), + enabled: Boolean(api && deviceProfile && itemId), staleTime: ONE_DAY, // Only refetch when the user's device profile changes gcTime: ONE_DAY, }) diff --git a/src/components/Artist/TabBar.tsx b/src/components/Artist/TabBar.tsx index b2ae418a..f4612f12 100644 --- a/src/components/Artist/TabBar.tsx +++ b/src/components/Artist/TabBar.tsx @@ -77,11 +77,12 @@ export default function ArtistTabBar({ > - + />{' '} {sortBy === ItemSortBy.DateCreated ? 'Date Added' : 'A-Z'} diff --git a/src/components/Artist/header.tsx b/src/components/Artist/header.tsx index 4add85de..a542dbf5 100644 --- a/src/components/Artist/header.tsx +++ b/src/components/Artist/header.tsx @@ -76,6 +76,7 @@ export default function ArtistHeader(): React.JSX.Element { height={'$20'} type={ImageType.Backdrop} cornered + imageOptions={{ maxWidth: width * 2, maxHeight: 640 }} /> diff --git a/src/components/Global/components/horizontal-list.tsx b/src/components/Global/components/horizontal-list.tsx index 1530169d..b98d36f8 100644 --- a/src/components/Global/components/horizontal-list.tsx +++ b/src/components/Global/components/horizontal-list.tsx @@ -2,7 +2,9 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item import { FlashList, FlashListProps } from '@shopify/flash-list' import React from 'react' -interface HorizontalCardListProps extends FlashListProps {} +type HorizontalCardListProps = Omit, 'estimatedItemSize'> & { + estimatedItemSize?: number +} /** * Displays a Horizontal FlatList of 20 ItemCards @@ -13,14 +15,17 @@ interface HorizontalCardListProps extends FlashListProps {} export default function HorizontalCardList({ data, renderItem, + estimatedItemSize = 150, ...props }: HorizontalCardListProps): React.JSX.Element { return ( - horizontal data={data} renderItem={renderItem} removeClippedSubviews + // @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect + estimatedItemSize={estimatedItemSize} style={{ overflow: 'hidden', }} diff --git a/src/components/Global/components/image.tsx b/src/components/Global/components/image.tsx index b0109c70..7361f087 100644 --- a/src/components/Global/components/image.tsx +++ b/src/components/Global/components/image.tsx @@ -6,7 +6,7 @@ import { ImageType } from '@jellyfin/sdk/lib/generated-client/models' 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 { getItemImageUrl, ImageUrlOptions } from '../../../api/queries/image/utils' import { memo, useCallback, useMemo, useState } from 'react' import { useApi } from '../../../stores' @@ -18,6 +18,8 @@ interface ItemImageProps { width?: Token | number | string | undefined height?: Token | number | string | undefined testID?: string | undefined + /** Image resolution options for requesting higher quality images */ + imageOptions?: ImageUrlOptions } const ItemImage = memo( @@ -29,10 +31,14 @@ const ItemImage = memo( width, height, testID, + imageOptions, }: ItemImageProps): React.JSX.Element { const api = useApi() - const imageUrl = useMemo(() => getItemImageUrl(api, item, type), [api, item.Id, type]) + const imageUrl = useMemo( + () => getItemImageUrl(api, item, type, imageOptions), + [api, item.Id, type, imageOptions], + ) return imageUrl ? ( { if (item.Type === 'Audio') warmContext(item) - }, [item.Id, warmContext]) + }, [item.Id, item.Type, warmContext]) const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress]) diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index c9de8d2b..24aa63d6 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -30,12 +30,14 @@ import { useIsFavorite } from '../../../api/queries/user-data' import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite' import { useApi } from '../../../stores' import { useHideRunTimesSetting } from '../../../stores/settings/app' +import { Queue } from '../../../player/types/queue-item' interface ItemRowProps { item: BaseItemDto circular?: boolean onPress?: () => void navigation?: Pick, 'navigate' | 'dispatch'> + queueName?: Queue } /** @@ -50,7 +52,13 @@ interface ItemRowProps { * @returns */ const ItemRow = memo( - function ItemRow({ item, circular, navigation, onPress }: ItemRowProps): React.JSX.Element { + function ItemRow({ + item, + circular, + navigation, + onPress, + queueName, + }: ItemRowProps): React.JSX.Element { const artworkAreaWidth = useSharedValue(0) const api = useApi() @@ -91,7 +99,7 @@ const ItemRow = memo( track: item, tracklist: [item], index: 0, - queue: 'Search', + queue: queueName ?? 'Search', queuingType: QueuingType.FromSelection, startPlayback: true, }) @@ -115,7 +123,7 @@ const ItemRow = memo( break } } - }, [loadNewQueue, item.Id, navigation]) + }, [onPress, loadNewQueue, item.Id, navigation, queueName]) const renderRunTime = useMemo( () => item.Type === BaseItemKind.Audio && !hideRunTimes, @@ -229,14 +237,12 @@ const ItemRow = memo( ) }, - (prevProps, nextProps) => { - return ( - prevProps.item.Id === nextProps.item.Id && - prevProps.circular === nextProps.circular && - !!prevProps.onPress === !!nextProps.onPress && - prevProps.navigation === nextProps.navigation - ) - }, + (prevProps, nextProps) => + prevProps.item.Id === nextProps.item.Id && + prevProps.circular === nextProps.circular && + prevProps.navigation === nextProps.navigation && + prevProps.queueName === nextProps.queueName && + !!prevProps.onPress === !!nextProps.onPress, ) const ItemRowDetails = memo( diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 95ab108d..980a1c90 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -17,7 +17,6 @@ import ItemImage from './image' import Animated, { useAnimatedStyle } from 'react-native-reanimated' import { useAddToQueue, useLoadNewQueue } from '../../../providers/Player/hooks/mutations' import useStreamingDeviceProfile from '../../../stores/device-profile' -import useStreamedMediaInfo from '../../../api/queries/media' import { useDownloadedTrack } from '../../../api/queries/download' import SwipeableRow from './SwipeableRow' import { useSwipeSettingsStore } from '../../../stores/settings/swipe' @@ -29,6 +28,10 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori import { StackActions } from '@react-navigation/native' import { useSwipeableRowContext } from './swipeable-row-context' import { useHideRunTimesSetting } from '../../../stores/settings/app' +import { queryClient, ONE_HOUR } from '../../../constants/query-client' +import { fetchMediaInfo } from '../../../api/queries/media/utils' +import MediaInfoQueryKey from '../../../api/queries/media/keys' +import JellifyTrack from '../../../types/JellifyTrack' export interface TrackProps { track: BaseItemDto @@ -45,6 +48,19 @@ export interface TrackProps { editing?: boolean | undefined } +const queueItemsCache = new WeakMap() + +const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => { + if (!queue?.length) return [] + + const cached = queueItemsCache.get(queue) + if (cached) return cached + + const mapped = queue.map((entry) => entry.item) + queueItemsCache.set(queue, mapped) + return mapped +} + const Track = memo( function Track({ track, @@ -75,8 +91,6 @@ const Track = memo( const addToQueue = useAddToQueue() const [networkStatus] = useNetworkStatus() - const { data: mediaInfo } = useStreamedMediaInfo(track.Id) - const offlineAudio = useDownloadedTrack(track.Id) const { mutate: addFavorite } = useAddFavorite() @@ -98,7 +112,7 @@ const Track = memo( // Memoize tracklist for queue loading const memoizedTracklist = useMemo( - () => tracklist ?? playQueue?.map((track) => track.item) ?? [], + () => tracklist ?? getQueueItems(playQueue), [tracklist, playQueue], ) @@ -119,40 +133,61 @@ const Track = memo( startPlayback: true, }) } - }, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue]) + }, [ + onPress, + api, + deviceProfile, + networkStatus, + track, + index, + memoizedTracklist, + queue, + loadNewQueue, + ]) + + const fetchStreamingMediaSourceInfo = useCallback(async () => { + if (!api || !deviceProfile || !track.Id) return undefined + + const queryKey = MediaInfoQueryKey({ api, deviceProfile, itemId: track.Id }) + + try { + const info = await queryClient.ensureQueryData({ + queryKey, + queryFn: () => fetchMediaInfo(api, deviceProfile, track.Id), + staleTime: ONE_HOUR, + gcTime: ONE_HOUR, + }) + + return info.MediaSources?.[0] + } catch (error) { + console.warn('Failed to fetch media info for context sheet', error) + return undefined + } + }, [api, deviceProfile, track.Id]) + + const openContextSheet = useCallback(async () => { + const streamingMediaSourceInfo = await fetchStreamingMediaSourceInfo() + + navigationRef.navigate('Context', { + item: track, + navigation, + streamingMediaSourceInfo, + downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, + }) + }, [fetchStreamingMediaSourceInfo, track, navigation, offlineAudio?.mediaSourceInfo]) const handleLongPress = useCallback(() => { if (onLongPress) { onLongPress() - } else { - navigationRef.navigate('Context', { - item: track, - navigation, - streamingMediaSourceInfo: mediaInfo?.MediaSources - ? mediaInfo!.MediaSources![0] - : undefined, - downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, - }) + return } - }, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio]) + + void openContextSheet() + }, [onLongPress, openContextSheet]) const handleIconPress = useCallback(() => { - navigationRef.navigate('Context', { - item: track, - navigation, - streamingMediaSourceInfo: mediaInfo?.MediaSources - ? mediaInfo!.MediaSources![0] - : undefined, - downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, - }) - }, [track, isNested, mediaInfo?.MediaSources, offlineAudio]) - - // Memoize text color to prevent recalculation - const textColor = useMemo(() => { - if (isPlaying) return theme.primary.val - if (isOffline) return offlineAudio ? theme.color : theme.neutral.val - return theme.color - }, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val]) + void openContextSheet() + }, [openContextSheet]) // Memoize artists text const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists]) @@ -216,6 +251,11 @@ const Track = memo( [leftSettings, rightSettings, swipeHandlers], ) + const textColor = useMemo( + () => (isPlaying ? theme.primary.val : theme.color.val), + [isPlaying], + ) + const runtimeComponent = useMemo( () => hideRunTimes ? ( diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx index a8b1410e..6d096c05 100644 --- a/src/components/Home/helpers/frequent-artists.tsx +++ b/src/components/Home/helpers/frequent-artists.tsx @@ -55,9 +55,7 @@ export default function FrequentArtists(): React.JSX.Element { { - navigation.navigate('MostPlayedArtists', { - artistsInfiniteQuery: frequentArtistsInfiniteQuery, - }) + navigation.navigate('MostPlayedArtists') }} >
Most Played
diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx index 851072a9..feafa02c 100644 --- a/src/components/Home/helpers/frequent-tracks.tsx +++ b/src/components/Home/helpers/frequent-tracks.tsx @@ -43,9 +43,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element { { - navigation.navigate('MostPlayedTracks', { - tracksInfiniteQuery, - }) + navigation.navigate('MostPlayedTracks') }} >
On Repeat
diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx index 1d9fbbac..7336e209 100644 --- a/src/components/Home/helpers/recent-artists.tsx +++ b/src/components/Home/helpers/recent-artists.tsx @@ -23,10 +23,8 @@ export default function RecentArtists(): React.JSX.Element { const { horizontalItems } = useDisplayContext() const handleHeaderPress = useCallback(() => { - navigation.navigate('RecentArtists', { - artistsInfiniteQuery: recentArtistsInfiniteQuery, - }) - }, [navigation, recentArtistsInfiniteQuery]) + navigation.navigate('RecentArtists') + }, [navigation]) const renderItem = useCallback( ({ item: recentArtist }: { item: BaseItemDto }) => ( diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx index 7da08dda..37368a02 100644 --- a/src/components/Home/helpers/recently-played.tsx +++ b/src/components/Home/helpers/recently-played.tsx @@ -44,9 +44,7 @@ export default function RecentlyPlayed(): React.JSX.Element { { - navigation.navigate('RecentTracks', { - tracksInfiniteQuery, - }) + navigation.navigate('RecentTracks') }} >
Play it again
diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx index d5a87d34..e066ac0a 100644 --- a/src/components/Player/components/header.tsx +++ b/src/components/Player/components/header.tsx @@ -93,7 +93,11 @@ function PlayerArtwork(): React.JSX.Element { ...animatedStyle, }} > - + )}
diff --git a/src/components/Player/components/lyrics.tsx b/src/components/Player/components/lyrics.tsx index 2f91cde3..063a1a07 100644 --- a/src/components/Player/components/lyrics.tsx +++ b/src/components/Player/components/lyrics.tsx @@ -201,22 +201,36 @@ export default function Lyrics({ } }, [lyrics]) + const lyricStartTimes = useMemo( + () => parsedLyrics.map((line) => line.startTime), + [parsedLyrics], + ) + // Track manually selected lyric for immediate feedback const manuallySelectedIndex = useSharedValue(-1) const manualSelectTimeout = useRef(null) // Find current lyric line based on playback position const currentLyricIndex = useMemo(() => { - if (!position || parsedLyrics.length === 0) return -1 + if (position === null || position === undefined || lyricStartTimes.length === 0) return -1 - // Find the last lyric that has started - for (let i = parsedLyrics.length - 1; i >= 0; i--) { - if (position >= parsedLyrics[i].startTime) { - return i + // Binary search to find the last startTime <= position + let low = 0 + let high = lyricStartTimes.length - 1 + let found = -1 + + while (low <= high) { + const mid = Math.floor((low + high) / 2) + if (position >= lyricStartTimes[mid]) { + found = mid + low = mid + 1 + } else { + high = mid - 1 } } - return -1 - }, [position, parsedLyrics]) + + return found + }, [position, lyricStartTimes]) // Simple auto-scroll that keeps highlighted lyric in center const scrollToCurrentLyric = useCallback(() => { diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 44b3e931..0d3d65fd 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -99,7 +99,12 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element { exiting={FadeOut} key={`${nowPlaying!.item.AlbumId}-album-image`} > - +
diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 2610e2f9..9ac69660 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -1,3 +1,4 @@ +import React, { useCallback } from 'react' import { RefreshControl } from 'react-native-gesture-handler' import { Separator, useTheme } from 'tamagui' import { FlashList } from '@shopify/flash-list' @@ -8,6 +9,12 @@ import { useNavigation } from '@react-navigation/native' import { BaseStackParamList } from '@/src/screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' +// Extracted as stable component to prevent recreation on each render +function ListSeparatorComponent(): React.JSX.Element { + return +} +const ListSeparator = React.memo(ListSeparatorComponent) + export interface PlaylistsProps { canEdit?: boolean | undefined playlists: BaseItemDto[] | undefined @@ -30,10 +37,29 @@ export default function Playlists({ const navigation = useNavigation>() + // Memoized key extractor to prevent recreation on each render + const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []) + + // Memoized render item to prevent recreation on each render + const renderItem = useCallback( + ({ item: playlist }: { index: number; item: BaseItemDto }) => ( + + ), + [navigation], + ) + + // Memoized end reached handler + const handleEndReached = useCallback(() => { + if (hasNextPage) { + fetchNextPage() + } + }, [hasNextPage, fetchNextPage]) + return ( } - ItemSeparatorComponent={() => } - renderItem={({ index, item: playlist }) => ( - - )} - onEndReached={() => { - if (hasNextPage) { - fetchNextPage() - } - }} + ItemSeparatorComponent={ListSeparator} + renderItem={renderItem} + onEndReached={handleEndReached} removeClippedSubviews /> ) diff --git a/src/constants/versioned-storage.ts b/src/constants/versioned-storage.ts new file mode 100644 index 00000000..918d4d9a --- /dev/null +++ b/src/constants/versioned-storage.ts @@ -0,0 +1,74 @@ +import { MMKV } from 'react-native-mmkv' +import { StateStorage } from 'zustand/middleware' +import { storage } from './storage' + +// Import app version from package.json +const APP_VERSION = '0.21.3' // This should match package.json version + +const STORAGE_VERSION_KEY = 'storage-schema-version' + +/** + * Storage schema versions - increment when making breaking changes to persisted state + * This allows clearing specific stores when their schema changes + */ +export const STORAGE_SCHEMA_VERSIONS: Record = { + 'player-queue-storage': 2, // Bumped to v2 for slim persistence +} + +/** + * Checks if a specific store needs to be cleared due to version bump + * and clears it if necessary + */ +export function migrateStorageIfNeeded(storeName: string, storage: MMKV): void { + const versionKey = `${STORAGE_VERSION_KEY}:${storeName}` + const storedVersion = storage.getNumber(versionKey) + const currentVersion = STORAGE_SCHEMA_VERSIONS[storeName] ?? 1 + + if (storedVersion !== currentVersion) { + // Clear the stale storage for this specific store + storage.delete(storeName) + // Update the version + storage.set(versionKey, currentVersion) + console.log( + `[Storage] Migrated ${storeName} from v${storedVersion ?? 0} to v${currentVersion}`, + ) + } +} + +/** + * Creates a versioned MMKV state storage that automatically clears stale data + * when the schema version changes. This is useful for stores that persist + * data that may become incompatible between app versions. + * + * @param storeName The unique name for this store (used as the MMKV key) + * @returns A StateStorage compatible object for Zustand's persist middleware + */ +export function createVersionedMmkvStorage(storeName: string): StateStorage { + // Run migration check on storage creation + migrateStorageIfNeeded(storeName, storage) + + return { + getItem: (key: string) => { + const value = storage.getString(key) + return value === undefined ? null : value + }, + setItem: (key: string, value: string) => { + storage.set(key, value) + }, + removeItem: (key: string) => { + storage.delete(key) + }, + } +} + +/** + * Clears all versioned storage entries. Useful for debugging or forcing + * a complete cache reset. + */ +export function clearAllVersionedStorage(): void { + Object.keys(STORAGE_SCHEMA_VERSIONS).forEach((storeName) => { + storage.delete(storeName) + storage.delete(`${STORAGE_VERSION_KEY}:${storeName}`) + }) + console.log('[Storage] Cleared all versioned storage') +} diff --git a/src/hooks/use-performance-monitor.ts b/src/hooks/use-performance-monitor.ts index f39f6e26..86680e06 100644 --- a/src/hooks/use-performance-monitor.ts +++ b/src/hooks/use-performance-monitor.ts @@ -7,6 +7,14 @@ interface PerformanceMetrics { totalRenderTime: number } +// No-op metrics for production builds +const EMPTY_METRICS: PerformanceMetrics = { + renderCount: 0, + lastRenderTime: 0, + averageRenderTime: 0, + totalRenderTime: 0, +} + /** * Hook to monitor component performance and detect excessive re-renders * @param componentName - Name of the component for logging @@ -17,6 +25,7 @@ export function usePerformanceMonitor( componentName: string, threshold: number = 10, ): PerformanceMetrics { + // Skip all performance monitoring in production for zero overhead const renderCount = useRef(0) const renderTimes = useRef([]) const lastRenderStart = useRef(Date.now()) @@ -56,6 +65,8 @@ export function usePerformanceMonitor( lastRenderStart.current = Date.now() }) + if (!__DEV__) return EMPTY_METRICS + const averageRenderTime = renderTimes.current.length > 0 ? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length diff --git a/src/player/config.ts b/src/player/config.ts index 92dd8fe1..55f50fae 100644 --- a/src/player/config.ts +++ b/src/player/config.ts @@ -1,3 +1,5 @@ +import { Platform } from 'react-native' + /** * Interval in milliseconds for progress updates from the track player * Lower value provides smoother scrubber movement but uses more resources @@ -16,3 +18,13 @@ export const SKIP_TO_PREVIOUS_THRESHOLD: number = 4 * event will be emitted from the track player */ export const PROGRESS_UPDATE_EVENT_INTERVAL: number = 30 + +export const BUFFERS = + Platform.OS === 'android' + ? { + maxCacheSize: 50 * 1024, // 50MB cache + maxBuffer: 30, // 30 seconds buffer + playBuffer: 2.5, // 2.5 seconds play buffer + backBuffer: 5, // 5 seconds back buffer + } + : {} diff --git a/src/player/types/queue-item.ts b/src/player/types/queue-item.ts index 739440c5..cf4eb3dc 100644 --- a/src/player/types/queue-item.ts +++ b/src/player/types/queue-item.ts @@ -1,12 +1,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' -export type Queue = - | BaseItemDto - | 'Recently Played' - | 'Search' - | 'Favorite Tracks' - | 'Downloaded Tracks' - | 'On Repeat' - | 'Instant Mix' - | 'Library' - | 'Artist Tracks' +/** + * Describes where playback was initiated from. + * Allows known queue labels (e.g., "Recently Played") as well as dynamic strings like search terms. + */ +export type Queue = BaseItemDto | string diff --git a/src/providers/Player/hooks/queries.ts b/src/providers/Player/hooks/queries.ts index 9727ca19..f08dbdc7 100644 --- a/src/providers/Player/hooks/queries.ts +++ b/src/providers/Player/hooks/queries.ts @@ -7,7 +7,7 @@ import { import usePlayerEngineStore from '../../../stores/player/engine' import { PlayerEngine } from '../../../stores/player/engine' import { MediaPlayerState, useRemoteMediaClient, useStreamPosition } from 'react-native-google-cast' -import { useMemo, useState } from 'react' +import { useEffect, useState } from 'react' export const useProgress = (UPDATE_INTERVAL: number): Progress => { const { position, duration, buffered } = useProgressRNTP(UPDATE_INTERVAL) @@ -58,16 +58,33 @@ export const usePlaybackState = (): State | undefined => { const isCasting = playerEngineData === PlayerEngine.GOOGLE_CAST const [playbackState, setPlaybackState] = useState(state) - useMemo(() => { + useEffect(() => { + let unsubscribe: (() => void) | undefined + if (client && isCasting) { - client.onMediaStatusUpdated((status) => { + const handler = (status: { playerState?: MediaPlayerState | null } | null) => { if (status?.playerState) { setPlaybackState(castToRNTPState(status.playerState)) } - }) + } + + const maybeUnsubscribe = client.onMediaStatusUpdated(handler) + // EmitterSubscription has a remove() method, wrap it as a function + if ( + maybeUnsubscribe && + typeof maybeUnsubscribe === 'object' && + 'remove' in maybeUnsubscribe + ) { + const subscription = maybeUnsubscribe as { remove: () => void } + unsubscribe = () => subscription.remove() + } } else { setPlaybackState(state) } + + return () => { + if (unsubscribe) unsubscribe() + } }, [client, isCasting, state]) return playbackState diff --git a/src/screens/Home/types.d.ts b/src/screens/Home/types.d.ts index 4fa3dd99..ebc76964 100644 --- a/src/screens/Home/types.d.ts +++ b/src/screens/Home/types.d.ts @@ -1,24 +1,13 @@ import { BaseStackParamList } from '../types' import { NativeStackScreenProps } from '@react-navigation/native-stack' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' -import { UseInfiniteQueryResult } from '@tanstack/react-query' -import { NavigatorScreenParams } from '@react-navigation/native' type HomeStackParamList = BaseStackParamList & { HomeScreen: undefined - RecentArtists: { - artistsInfiniteQuery: UseInfiniteQueryResult - } - MostPlayedArtists: { - artistsInfiniteQuery: UseInfiniteQueryResult - } - RecentTracks: { - tracksInfiniteQuery: UseInfiniteQueryResult - } - MostPlayedTracks: { - tracksInfiniteQuery: UseInfiniteQueryResult - } + RecentArtists: undefined + MostPlayedArtists: undefined + RecentTracks: undefined + MostPlayedTracks: undefined } export default HomeStackParamList diff --git a/src/stores/player/engine.ts b/src/stores/player/engine.ts index c15d0f91..2da9c97a 100644 --- a/src/stores/player/engine.ts +++ b/src/stores/player/engine.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { useCastState, CastState } from 'react-native-google-cast' @@ -31,12 +32,15 @@ const usePlayerEngineStore = create()( export const useSelectPlayerEngine = () => { const setPlayerEngineData = usePlayerEngineStore((state) => state.setPlayerEngineData) const castState = useCastState() - if (castState === CastState.CONNECTED) { - setPlayerEngineData(PlayerEngine.GOOGLE_CAST) - TrackPlayer.pause() // pause the track player to avoid conflicts - return - } - setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER) + + useEffect(() => { + if (castState === CastState.CONNECTED) { + setPlayerEngineData(PlayerEngine.GOOGLE_CAST) + void TrackPlayer.pause() // pause the track player to avoid conflicts + return + } + setPlayerEngineData(PlayerEngine.REACT_NATIVE_TRACK_PLAYER) + }, [castState, setPlayerEngineData]) } export default usePlayerEngineStore diff --git a/src/stores/player/queue.ts b/src/stores/player/queue.ts index af7bad26..68f64756 100644 --- a/src/stores/player/queue.ts +++ b/src/stores/player/queue.ts @@ -1,11 +1,27 @@ import { Queue } from '@/src/player/types/queue-item' -import JellifyTrack from '@/src/types/JellifyTrack' -import { mmkvStateStorage } from '../../constants/storage' +import JellifyTrack, { + PersistedJellifyTrack, + toPersistedTrack, + fromPersistedTrack, +} from '../../types/JellifyTrack' +import { createVersionedMmkvStorage } from '../../constants/versioned-storage' import { create } from 'zustand' -import { createJSONStorage, devtools, persist } from 'zustand/middleware' +import { + createJSONStorage, + devtools, + persist, + PersistStorage, + StorageValue, +} from 'zustand/middleware' import { RepeatMode } from 'react-native-track-player' import { useShallow } from 'zustand/react/shallow' +/** + * Maximum number of tracks to persist in storage. + * This prevents storage overflow when users have very large queues. + */ +const MAX_PERSISTED_QUEUE_SIZE = 500 + type PlayerQueueStore = { shuffled: boolean setShuffled: (shuffled: boolean) => void @@ -29,6 +45,81 @@ type PlayerQueueStore = { setCurrentIndex: (index: number | undefined) => void } +/** + * Persisted state shape - uses slimmed track types to reduce storage size + */ +type PersistedPlayerQueueState = { + shuffled: boolean + repeatMode: RepeatMode + queueRef: Queue + unShuffledQueue: PersistedJellifyTrack[] + queue: PersistedJellifyTrack[] + currentTrack: PersistedJellifyTrack | undefined + currentIndex: number | undefined +} + +/** + * Custom storage that serializes/deserializes tracks to their slim form + * This prevents the "RangeError: String length exceeds limit" error + */ +const queueStorage: PersistStorage = { + getItem: (name) => { + const storage = createVersionedMmkvStorage('player-queue-storage') + const str = storage.getItem(name) as string | null + if (!str) return null + + try { + const parsed = JSON.parse(str) as StorageValue + const state = parsed.state + + // Hydrate persisted tracks back to full JellifyTrack format + return { + ...parsed, + state: { + ...state, + queue: (state.queue ?? []).map(fromPersistedTrack), + unShuffledQueue: (state.unShuffledQueue ?? []).map(fromPersistedTrack), + currentTrack: state.currentTrack + ? fromPersistedTrack(state.currentTrack) + : undefined, + } as unknown as PlayerQueueStore, + } + } catch (e) { + console.error('[Queue Storage] Failed to parse stored queue:', e) + return null + } + }, + setItem: (name, value) => { + const storage = createVersionedMmkvStorage('player-queue-storage') + const state = value.state + + // Slim down tracks before persisting to prevent storage overflow + const persistedState: PersistedPlayerQueueState = { + shuffled: state.shuffled, + repeatMode: state.repeatMode, + queueRef: state.queueRef, + // Limit queue size to prevent storage overflow + queue: (state.queue ?? []).slice(0, MAX_PERSISTED_QUEUE_SIZE).map(toPersistedTrack), + unShuffledQueue: (state.unShuffledQueue ?? []) + .slice(0, MAX_PERSISTED_QUEUE_SIZE) + .map(toPersistedTrack), + currentTrack: state.currentTrack ? toPersistedTrack(state.currentTrack) : undefined, + currentIndex: state.currentIndex, + } + + const toStore: StorageValue = { + ...value, + state: persistedState, + } + + storage.setItem(name, JSON.stringify(toStore)) + }, + removeItem: (name) => { + const storage = createVersionedMmkvStorage('player-queue-storage') + storage.removeItem(name) + }, +} + export const usePlayerQueueStore = create()( devtools( persist( @@ -71,7 +162,7 @@ export const usePlayerQueueStore = create()( }), { name: 'player-queue-storage', - storage: createJSONStorage(() => mmkvStateStorage), + storage: queueStorage, }, ), ), diff --git a/src/types/JellifyTrack.ts b/src/types/JellifyTrack.ts index 8b27edab..98450946 100644 --- a/src/types/JellifyTrack.ts +++ b/src/types/JellifyTrack.ts @@ -41,4 +41,47 @@ interface JellifyTrack extends Track { QueuingType?: QueuingType | undefined } +/** + * A slimmed-down version of JellifyTrack for persistence. + * Excludes large fields like mediaSourceInfo and transient data + * to prevent storage overflow (RangeError: String length exceeds limit). + * + * When hydrating from storage, these fields will need to be rebuilt + * from the API or left undefined until playback is requested. + */ +export type PersistedJellifyTrack = Omit & { + /** Store only essential media source fields for persistence */ + mediaSourceInfo?: Pick | undefined +} + +/** + * Converts a full JellifyTrack to a PersistedJellifyTrack for storage + */ +export function toPersistedTrack(track: JellifyTrack): PersistedJellifyTrack { + const { mediaSourceInfo, headers, ...rest } = track as JellifyTrack & { headers?: unknown } + + return { + ...rest, + // Only persist essential media source fields + mediaSourceInfo: mediaSourceInfo + ? { + Id: mediaSourceInfo.Id, + Container: mediaSourceInfo.Container, + Bitrate: mediaSourceInfo.Bitrate, + } + : undefined, + } +} + +/** + * Converts a PersistedJellifyTrack back to a JellifyTrack + * Note: Some fields like full mediaSourceInfo and headers will be undefined + * and need to be rebuilt when playback is requested + */ +export function fromPersistedTrack(persisted: PersistedJellifyTrack): JellifyTrack { + // Cast is safe because PersistedJellifyTrack has all required fields + // except the omitted ones (mediaSourceInfo, headers) which are optional in JellifyTrack + return persisted as unknown as JellifyTrack +} + export default JellifyTrack From cadec335b073fda37c61bc62a7f84657d9c7082d Mon Sep 17 00:00:00 2001 From: skalthoff <32023561+skalthoff@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:33:47 -0800 Subject: [PATCH 15/16] fix swipeable row conflicts and closure on scroll for Albums, Artists, Library, and Playlists components (#747) --- src/components/Albums/component.tsx | 2 ++ src/components/Artists/component.tsx | 2 ++ src/components/Library/component.tsx | 1 + src/components/Playlists/component.tsx | 1 + 4 files changed, 6 insertions(+) diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 42624e4e..02d04583 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -13,6 +13,7 @@ import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetic import { isString } from 'lodash' import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header' import { useLibrarySortAndFilterContext } from '../../providers/Library' +import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' interface AlbumsProps { albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error> @@ -144,6 +145,7 @@ export default function Albums({ ItemSeparatorComponent={ItemSeparatorComponent} refreshControl={refreshControl} stickyHeaderIndices={stickyHeaderIndices} + onScrollBeginDrag={closeAllSwipeableRows} removeClippedSubviews /> diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 89c0533f..33d0eb0d 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -13,6 +13,7 @@ import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import LibraryStackParamList from '../../screens/Library/types' import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header' +import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' export interface ArtistsProps { artistsInfiniteQuery: UseInfiniteQueryResult< @@ -155,6 +156,7 @@ export default function Artists({ if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching) artistsInfiniteQuery.fetchNextPage() }} + onScrollBeginDrag={closeAllSwipeableRows} removeClippedSubviews /> diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx index 5225756e..d7c2d639 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -21,6 +21,7 @@ export default function LibraryScreen({ } screenOptions={{ + swipeEnabled: false, // Disable tab swiping to prevent conflicts with SwipeableRow gestures tabBarIndicatorStyle: { borderColor: theme.background.val, borderBottomWidth: getTokenValue('$2'), diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 9ac69660..8a8f1d8c 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -8,6 +8,7 @@ import { FetchNextPageOptions } from '@tanstack/react-query' import { useNavigation } from '@react-navigation/native' import { BaseStackParamList } from '@/src/screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry' // Extracted as stable component to prevent recreation on each render function ListSeparatorComponent(): React.JSX.Element { From af5c02c71a669219b0fb8d97f7ba482e89ba6b01 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:42:46 -0600 Subject: [PATCH 16/16] remove memoization since we're using the compiler (#746) * remove memoization since we're using the compiler * remove memoization on the track and item rows * remove memoization from artists list * remove additional memoization --- src/components/Albums/component.tsx | 79 ++- src/components/Artists/component.tsx | 68 +-- .../components/alphabetical-selector.tsx | 132 +++-- src/components/Global/components/item-row.tsx | 524 ++++++++---------- src/components/Global/components/track.tsx | 509 +++++++---------- 5 files changed, 576 insertions(+), 736 deletions(-) diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 02d04583..d12e4e7b 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -1,6 +1,6 @@ -import { ActivityIndicator, RefreshControl } from 'react-native' +import { RefreshControl } from 'react-native' import { Separator, useTheme, XStack, YStack } from 'tamagui' -import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' +import React, { RefObject, useEffect, useRef } from 'react' import { Text } from '../Global/helpers/text' import { FlashList, FlashListRef } from '@shopify/flash-list' import { UseInfiniteQueryResult } from '@tanstack/react-query' @@ -39,55 +39,52 @@ export default function Albums({ const pendingLetterRef = useRef(null) // Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations - const stickyHeaderIndices = React.useMemo(() => { - if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return [] - - return albumsInfiniteQuery.data - .map((album, index) => (typeof album === 'string' ? index : 0)) - .filter((value, index, indices) => indices.indexOf(value) === index) - }, [showAlphabeticalSelector, albumsInfiniteQuery.data]) + const stickyHeaderIndices = + !showAlphabeticalSelector || !albumsInfiniteQuery.data + ? [] + : albumsInfiniteQuery.data + .map((album, index) => (typeof album === 'string' ? index : 0)) + .filter((value, index, indices) => indices.indexOf(value) === index) const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) - const refreshControl = useMemo( - () => ( - - ), - [albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch], + const refreshControl = ( + ) - const ItemSeparatorComponent = useCallback( - ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => - typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( - - ), - [], - ) + const ItemSeparatorComponent = ({ + leadingItem, + trailingItem, + }: { + leadingItem: unknown + trailingItem: unknown + }) => + typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : - const keyExtractor = useCallback( - (item: BaseItemDto | string | number) => - typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, - [], - ) + const keyExtractor = (item: BaseItemDto | string | number) => + typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id! - const renderItem = useCallback( - ({ index, item: album }: { index: number; item: BaseItemDto | string | number }) => - typeof album === 'string' ? ( - - ) : typeof album === 'number' ? null : typeof album === 'object' ? ( - - ) : null, - [navigation], - ) + const renderItem = ({ + index, + item: album, + }: { + index: number + item: BaseItemDto | string | number + }) => + typeof album === 'string' ? ( + + ) : typeof album === 'number' ? null : typeof album === 'object' ? ( + + ) : null - const onEndReached = useCallback(() => { + const onEndReached = () => { if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage() - }, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage]) + } // Effect for handling the pending alphabet selector letter useEffect(() => { diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 33d0eb0d..369a606a 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -1,5 +1,5 @@ -import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' -import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui' +import React, { RefObject, useEffect, useRef } from 'react' +import { Separator, useTheme, XStack, YStack } from 'tamagui' import { Text } from '../Global/helpers/text' import { RefreshControl } from 'react-native' import ItemRow from '../Global/components/item-row' @@ -50,41 +50,41 @@ export default function Artists({ const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) - const stickyHeaderIndices = useMemo(() => { - if (!showAlphabeticalSelector || !artists) return [] + const stickyHeaderIndices = + !showAlphabeticalSelector || !artists + ? [] + : artists + .map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) + .filter((value, index, indices) => indices.indexOf(value) === index) - return artists - .map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) - .filter((value, index, indices) => indices.indexOf(value) === index) - }, [showAlphabeticalSelector, artists]) + const ItemSeparatorComponent = ({ + leadingItem, + trailingItem, + }: { + leadingItem: unknown + trailingItem: unknown + }) => + typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : - const ItemSeparatorComponent = useCallback( - ({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => - typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( - - ), - [], - ) + const KeyExtractor = (item: BaseItemDto | string | number, index: number) => + typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id! - const KeyExtractor = useCallback( - (item: BaseItemDto | string | number, index: number) => - typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, - [], - ) - - const renderItem = useCallback( - ({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) => - typeof artist === 'string' ? ( - // Don't render the letter if we don't have any artists that start with it - // If the index is the last index, or the next index is not an object, then don't render the letter - index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( - - ) - ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( - - ) : null, - [navigation], - ) + const renderItem = ({ + index, + item: artist, + }: { + index: number + item: BaseItemDto | number | string + }) => + typeof artist === 'string' ? ( + // Don't render the letter if we don't have any artists that start with it + // If the index is the last index, or the next index is not an object, then don't render the letter + index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( + + ) + ) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( + + ) : null // Effect for handling the pending alphabet selector letter useEffect(() => { diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index 5dbcfa6d..b81de253 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -1,4 +1,4 @@ -import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, useEffect, useRef, useState } from 'react' import { LayoutChangeEvent, Platform, View as RNView } from 'react-native' import { getToken, Spinner, useTheme, View, YStack } from 'tamagui' import { Gesture, GestureDetector } from 'react-native-gesture-handler' @@ -61,78 +61,70 @@ export default function AZScroller({ }) } - const panGesture = useMemo( - () => - Gesture.Pan() - .runOnJS(true) - .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onUpdate((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onEnd(() => { - if (selectedLetter.value) { - scheduleOnRN(async () => { - setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { - scheduleOnRN(hideOverlay) - setOperationPending(false) - }) - }) - } else { + const panGesture = Gesture.Pan() + .runOnJS(true) + .onBegin((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onUpdate((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onEnd(() => { + if (selectedLetter.value) { + scheduleOnRN(async () => { + setOperationPending(true) + onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { scheduleOnRN(hideOverlay) - } - }), - [onLetterSelect], - ) + setOperationPending(false) + }) + }) + } else { + scheduleOnRN(hideOverlay) + } + }) - const tapGesture = useMemo( - () => - Gesture.Tap() - .runOnJS(true) - .onBegin((e) => { - const relativeY = e.absoluteY - alphabetSelectorTopY.current - setOverlayPositionY(relativeY - letterHeight.current * 1.5) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabet[index]) { - const letter = alphabet[index] - selectedLetter.value = letter - setOverlayLetter(letter) - scheduleOnRN(showOverlay) - } - }) - .onEnd(() => { - if (selectedLetter.value) { - scheduleOnRN(async () => { - setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { - scheduleOnRN(hideOverlay) - setOperationPending(false) - }) - }) - } else { + const tapGesture = Gesture.Tap() + .runOnJS(true) + .onBegin((e) => { + const relativeY = e.absoluteY - alphabetSelectorTopY.current + setOverlayPositionY(relativeY - letterHeight.current * 1.5) + const index = Math.floor(relativeY / letterHeight.current) + if (alphabet[index]) { + const letter = alphabet[index] + selectedLetter.value = letter + setOverlayLetter(letter) + scheduleOnRN(showOverlay) + } + }) + .onEnd(() => { + if (selectedLetter.value) { + scheduleOnRN(async () => { + setOperationPending(true) + onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { scheduleOnRN(hideOverlay) - } - }), - [onLetterSelect], - ) + setOperationPending(false) + }) + }) + } else { + scheduleOnRN(hideOverlay) + } + }) const gesture = Gesture.Simultaneous(panGesture, tapGesture) diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index 24aa63d6..81473e31 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network' import useStreamingDeviceProfile from '../../../stores/device-profile' import useItemContext from '../../../hooks/use-item-context' import { RouteProp, useRoute } from '@react-navigation/native' -import React, { memo, useCallback, useMemo, useState } from 'react' +import React from 'react' import { LayoutChangeEvent } from 'react-native' import Animated, { SharedValue, @@ -51,322 +51,264 @@ interface ItemRowProps { * @param navigation - The navigation object. * @returns */ -const ItemRow = memo( - function ItemRow({ - item, - circular, - navigation, - onPress, - queueName, - }: ItemRowProps): React.JSX.Element { - const artworkAreaWidth = useSharedValue(0) +function ItemRow({ + item, + circular, + navigation, + onPress, + queueName, +}: ItemRowProps): React.JSX.Element { + const artworkAreaWidth = useSharedValue(0) - const api = useApi() + const api = useApi() - const [networkStatus] = useNetworkStatus() + const [networkStatus] = useNetworkStatus() - const deviceProfile = useStreamingDeviceProfile() + const deviceProfile = useStreamingDeviceProfile() - const loadNewQueue = useLoadNewQueue() - const addToQueue = useAddToQueue() - const { mutate: addFavorite } = useAddFavorite() - const { mutate: removeFavorite } = useRemoveFavorite() - const [hideRunTimes] = useHideRunTimesSetting() + const loadNewQueue = useLoadNewQueue() + const addToQueue = useAddToQueue() + const { mutate: addFavorite } = useAddFavorite() + const { mutate: removeFavorite } = useRemoveFavorite() + const [hideRunTimes] = useHideRunTimesSetting() - const warmContext = useItemContext() - const { data: isFavorite } = useIsFavorite(item) + const warmContext = useItemContext() + const { data: isFavorite } = useIsFavorite(item) - const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id]) + const onPressIn = () => warmContext(item) - const onLongPress = useCallback( - () => - navigationRef.navigate('Context', { - item, - navigation, - }), - [navigationRef, navigation, item.Id], - ) + const onLongPress = () => + navigationRef.navigate('Context', { + item, + navigation, + }) - const onPressCallback = useCallback(async () => { - if (onPress) await onPress() - else - switch (item.Type) { - case 'Audio': { - loadNewQueue({ - api, - networkStatus, - deviceProfile, - track: item, - tracklist: [item], - index: 0, - queue: queueName ?? 'Search', - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - break - } - case 'MusicArtist': { - navigation?.navigate('Artist', { artist: item }) - break - } - - case 'MusicAlbum': { - navigation?.navigate('Album', { album: item }) - break - } - - case 'Playlist': { - navigation?.navigate('Playlist', { playlist: item, canEdit: true }) - break - } - default: { - break - } - } - }, [onPress, loadNewQueue, item.Id, navigation, queueName]) - - const renderRunTime = useMemo( - () => item.Type === BaseItemKind.Audio && !hideRunTimes, - [item.Type, hideRunTimes], - ) - - const isAudio = useMemo(() => item.Type === 'Audio', [item.Type]) - - const playlistTrackCount = useMemo( - () => (item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined), - [item.Type, item.SongCount, item.ChildCount], - ) - - const leftSettings = useSwipeSettingsStore((s) => s.left) - const rightSettings = useSwipeSettingsStore((s) => s.right) - - const swipeHandlers = useCallback( - () => ({ - addToQueue: async () => - await addToQueue({ + const onPressCallback = async () => { + if (onPress) await onPress() + else + switch (item.Type) { + case 'Audio': { + loadNewQueue({ api, - deviceProfile, networkStatus, - tracks: [item], - queuingType: QueuingType.DirectlyQueued, - }), - toggleFavorite: () => - isFavorite ? removeFavorite({ item }) : addFavorite({ item }), - addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), - }), - [ - addToQueue, + deviceProfile, + track: item, + tracklist: [item], + index: 0, + queue: queueName ?? 'Search', + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + break + } + case 'MusicArtist': { + navigation?.navigate('Artist', { artist: item }) + break + } + + case 'MusicAlbum': { + navigation?.navigate('Album', { album: item }) + break + } + + case 'Playlist': { + navigation?.navigate('Playlist', { playlist: item, canEdit: true }) + break + } + default: { + break + } + } + } + + const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes + + const isAudio = item.Type === 'Audio' + + const playlistTrackCount = + item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined + + const leftSettings = useSwipeSettingsStore((s) => s.left) + const rightSettings = useSwipeSettingsStore((s) => s.right) + + const swipeHandlers = () => ({ + addToQueue: async () => + await addToQueue({ api, deviceProfile, networkStatus, - item, - addFavorite, - removeFavorite, - isFavorite, - ], - ) + tracks: [item], + queuingType: QueuingType.DirectlyQueued, + }), + toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })), + addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), + }) - const swipeConfig = useMemo( - () => - isAudio - ? buildSwipeConfig({ - left: leftSettings, - right: rightSettings, - handlers: swipeHandlers(), - }) - : {}, - [isAudio, leftSettings, rightSettings, swipeHandlers], - ) + const swipeConfig = isAudio + ? buildSwipeConfig({ + left: leftSettings, + right: rightSettings, + handlers: swipeHandlers(), + }) + : {} - const handleArtworkLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width } = event.nativeEvent.layout - artworkAreaWidth.value = width - }, - [artworkAreaWidth], - ) + const handleArtworkLayout = (event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout + artworkAreaWidth.value = width + } - const pressStyle = useMemo(() => ({ opacity: 0.5 }), []) + const pressStyle = { + opacity: 0.5, + } - return ( - + - - - - - - - - {renderRunTime ? ( - {item.RunTimeTicks} - ) : item.Type === 'Playlist' ? ( - - {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`} - - ) : null} - - - {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( - - ) : null} - - - - ) - }, - (prevProps, nextProps) => - prevProps.item.Id === nextProps.item.Id && - prevProps.circular === nextProps.circular && - prevProps.navigation === nextProps.navigation && - prevProps.queueName === nextProps.queueName && - !!prevProps.onPress === !!nextProps.onPress, -) - -const ItemRowDetails = memo( - function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { - const route = useRoute>() - - const shouldRenderArtistName = - item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist') - - const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist' - - const shouldRenderGenres = - item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist - - return ( - - - {item.Name ?? ''} - - - {shouldRenderArtistName && ( - - {item.AlbumArtist ?? 'Untitled Artist'} - - )} - - {shouldRenderProductionYear && ( - - - {item.ProductionYear?.toString() ?? 'Unknown Year'} - - - + + + + + + {renderRunTime ? ( {item.RunTimeTicks} - - )} + ) : item.Type === 'Playlist' ? ( + + {`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`} + + ) : null} + - {shouldRenderGenres && item.Genres && ( + {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? ( + + ) : null} + + + + ) +} + +function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element { + const route = useRoute>() + + const shouldRenderArtistName = + item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist') + + const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist' + + const shouldRenderGenres = item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist + + return ( + + + {item.Name ?? ''} + + + {shouldRenderArtistName && ( + + {item.AlbumArtist ?? 'Untitled Artist'} + + )} + + {shouldRenderProductionYear && ( + - {item.Genres?.join(', ') ?? ''} + {item.ProductionYear?.toString() ?? 'Unknown Year'} - )} - - ) - }, - (prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id, -) + + + + {item.RunTimeTicks} + + )} + + {shouldRenderGenres && item.Genres && ( + + {item.Genres?.join(', ') ?? ''} + + )} + + ) +} // Artwork wrapper that fades out when the quick-action menu is open -const HideableArtwork = memo( - function HideableArtwork({ - item, - circular, - onLayout, - }: { - item: BaseItemDto - circular?: boolean - onLayout?: (event: LayoutChangeEvent) => void - }): React.JSX.Element { - const { tx } = useSwipeableRowContext() - // Hide artwork as soon as swiping starts (any non-zero tx) - const style = useAnimatedStyle(() => ({ - opacity: tx.value === 0 ? withTiming(1) : 0, - })) - return ( - - - - - - ) - }, - (prevProps, nextProps) => - prevProps.item.Id === nextProps.item.Id && - prevProps.circular === nextProps.circular && - !!prevProps.onLayout === !!nextProps.onLayout, -) +function HideableArtwork({ + item, + circular, + onLayout, +}: { + item: BaseItemDto + circular?: boolean + onLayout?: (event: LayoutChangeEvent) => void +}): React.JSX.Element { + const { tx } = useSwipeableRowContext() + // Hide artwork as soon as swiping starts (any non-zero tx) + const style = useAnimatedStyle(() => ({ + opacity: tx.value === 0 ? withTiming(1) : 0, + })) + return ( + + + + + + ) +} -const SlidingTextArea = memo( - function SlidingTextArea({ - leftGapWidth, - children, - }: { - leftGapWidth: SharedValue - children: React.ReactNode - }): React.JSX.Element { - const { tx, rightWidth } = useSwipeableRowContext() - const tokenValue = getToken('$2', 'space') - const spacingValue = - typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) - const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 - const style = useAnimatedStyle(() => { - const t = tx.value - let offset = 0 - if (t > 0 && leftGapWidth.get() > 0) { - offset = -Math.min(t, leftGapWidth.get()) - } else if (t < 0) { - const rightSpace = Math.max(0, rightWidth) - const compensate = Math.min(-t, rightSpace) - const progress = rightSpace > 0 ? compensate / rightSpace : 1 - offset = compensate * 0.7 + quickActionBuffer * progress - } - return { transform: [{ translateX: offset }] } - }) - const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 - return ( - - {children} - - ) - }, - (prevProps, nextProps) => - prevProps.leftGapWidth === nextProps.leftGapWidth && - prevProps.children?.valueOf() === nextProps.children?.valueOf(), -) +function SlidingTextArea({ + leftGapWidth, + children, +}: { + leftGapWidth: SharedValue + children: React.ReactNode +}): React.JSX.Element { + const { tx, rightWidth } = useSwipeableRowContext() + const tokenValue = getToken('$2', 'space') + const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) + const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 + const style = useAnimatedStyle(() => { + const t = tx.value + let offset = 0 + if (t > 0 && leftGapWidth.get() > 0) { + offset = -Math.min(t, leftGapWidth.get()) + } else if (t < 0) { + const rightSpace = Math.max(0, rightWidth) + const compensate = Math.min(-t, rightSpace) + const progress = rightSpace > 0 ? compensate / rightSpace : 1 + offset = compensate * 0.7 + quickActionBuffer * progress + } + return { transform: [{ translateX: offset }] } + }) + const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 + return ( + + {children} + + ) +} export default ItemRow diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 980a1c90..27f9d44e 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, memo } from 'react' +import React, { useState } from 'react' import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui' import { Text } from '../helpers/text' import { RunTimeTicks } from '../helpers/time-codes' @@ -28,10 +28,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori import { StackActions } from '@react-navigation/native' import { useSwipeableRowContext } from './swipeable-row-context' import { useHideRunTimesSetting } from '../../../stores/settings/app' -import { queryClient, ONE_HOUR } from '../../../constants/query-client' -import { fetchMediaInfo } from '../../../api/queries/media/utils' -import MediaInfoQueryKey from '../../../api/queries/media/keys' -import JellifyTrack from '../../../types/JellifyTrack' +import useStreamedMediaInfo from '../../../api/queries/media' export interface TrackProps { track: BaseItemDto @@ -48,329 +45,243 @@ export interface TrackProps { editing?: boolean | undefined } -const queueItemsCache = new WeakMap() +export default function Track({ + track, + navigation, + tracklist, + index, + queue, + showArtwork, + onPress, + onLongPress, + testID, + isNested, + invertedColors, + editing, +}: TrackProps): React.JSX.Element { + const theme = useTheme() + const [artworkAreaWidth, setArtworkAreaWidth] = useState(0) -const getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => { - if (!queue?.length) return [] + const api = useApi() - const cached = queueItemsCache.get(queue) - if (cached) return cached + const deviceProfile = useStreamingDeviceProfile() - const mapped = queue.map((entry) => entry.item) - queueItemsCache.set(queue, mapped) - return mapped -} + const [hideRunTimes] = useHideRunTimesSetting() -const Track = memo( - function Track({ - track, - navigation, - tracklist, - index, - queue, - showArtwork, - onPress, - onLongPress, - testID, - isNested, - invertedColors, - editing, - }: TrackProps): React.JSX.Element { - const theme = useTheme() - const [artworkAreaWidth, setArtworkAreaWidth] = useState(0) + const nowPlaying = useCurrentTrack() + const playQueue = usePlayQueue() + const loadNewQueue = useLoadNewQueue() + const addToQueue = useAddToQueue() + const [networkStatus] = useNetworkStatus() - const api = useApi() + const { data: mediaInfo } = useStreamedMediaInfo(track.Id) - const deviceProfile = useStreamingDeviceProfile() + const offlineAudio = useDownloadedTrack(track.Id) - const [hideRunTimes] = useHideRunTimesSetting() + const { mutate: addFavorite } = useAddFavorite() + const { mutate: removeFavorite } = useRemoveFavorite() + const { data: isFavoriteTrack } = useIsFavorite(track) + const leftSettings = useSwipeSettingsStore((s) => s.left) + const rightSettings = useSwipeSettingsStore((s) => s.right) - const nowPlaying = useCurrentTrack() - const playQueue = usePlayQueue() - const loadNewQueue = useLoadNewQueue() - const addToQueue = useAddToQueue() - const [networkStatus] = useNetworkStatus() + // Memoize expensive computations + const isPlaying = nowPlaying?.item.Id === track.Id - const offlineAudio = useDownloadedTrack(track.Id) + const isOffline = networkStatus === networkStatusTypes.DISCONNECTED - const { mutate: addFavorite } = useAddFavorite() - const { mutate: removeFavorite } = useRemoveFavorite() - const { data: isFavoriteTrack } = useIsFavorite(track) - const leftSettings = useSwipeSettingsStore((s) => s.left) - const rightSettings = useSwipeSettingsStore((s) => s.right) + // Memoize tracklist for queue loading + const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? [] - // Memoize expensive computations - const isPlaying = useMemo( - () => nowPlaying?.item.Id === track.Id, - [nowPlaying?.item.Id, track.Id], - ) - - const isOffline = useMemo( - () => networkStatus === networkStatusTypes.DISCONNECTED, - [networkStatus], - ) - - // Memoize tracklist for queue loading - const memoizedTracklist = useMemo( - () => tracklist ?? getQueueItems(playQueue), - [tracklist, playQueue], - ) - - // Memoize handlers to prevent recreation - const handlePress = useCallback(async () => { - if (onPress) { - await onPress() - } else { - loadNewQueue({ - api, - deviceProfile, - networkStatus, - track, - index, - tracklist: memoizedTracklist, - queue, - queuingType: QueuingType.FromSelection, - startPlayback: true, - }) - } - }, [ - onPress, - api, - deviceProfile, - networkStatus, - track, - index, - memoizedTracklist, - queue, - loadNewQueue, - ]) - - const fetchStreamingMediaSourceInfo = useCallback(async () => { - if (!api || !deviceProfile || !track.Id) return undefined - - const queryKey = MediaInfoQueryKey({ api, deviceProfile, itemId: track.Id }) - - try { - const info = await queryClient.ensureQueryData({ - queryKey, - queryFn: () => fetchMediaInfo(api, deviceProfile, track.Id), - staleTime: ONE_HOUR, - gcTime: ONE_HOUR, - }) - - return info.MediaSources?.[0] - } catch (error) { - console.warn('Failed to fetch media info for context sheet', error) - return undefined - } - }, [api, deviceProfile, track.Id]) - - const openContextSheet = useCallback(async () => { - const streamingMediaSourceInfo = await fetchStreamingMediaSourceInfo() - - navigationRef.navigate('Context', { - item: track, - navigation, - streamingMediaSourceInfo, - downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, - }) - }, [fetchStreamingMediaSourceInfo, track, navigation, offlineAudio?.mediaSourceInfo]) - - const handleLongPress = useCallback(() => { - if (onLongPress) { - onLongPress() - return - } - - void openContextSheet() - }, [onLongPress, openContextSheet]) - - const handleIconPress = useCallback(() => { - void openContextSheet() - }, [openContextSheet]) - - // Memoize artists text - const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists]) - - // Memoize track name - const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name]) - - // Memoize index number - const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber]) - - // Memoize show artists condition - const shouldShowArtists = useMemo( - () => showArtwork || (track.Artists && track.Artists.length > 1), - [showArtwork, track.Artists], - ) - - const swipeHandlers = useMemo( - () => ({ - addToQueue: async () => { - console.info('Running add to queue swipe action') - await addToQueue({ - api, - deviceProfile, - networkStatus, - tracks: [track], - queuingType: QueuingType.DirectlyQueued, - }) - }, - toggleFavorite: () => { - console.info( - `Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`, - ) - if (isFavoriteTrack) removeFavorite({ item: track }) - else addFavorite({ item: track }) - }, - addToPlaylist: () => { - console.info('Running add to playlist swipe handler') - navigationRef.dispatch(StackActions.push('AddToPlaylist', { track })) - }, - }), - [ - addToQueue, + // Memoize handlers to prevent recreation + const handlePress = async () => { + if (onPress) { + await onPress() + } else { + loadNewQueue({ api, deviceProfile, networkStatus, track, - addFavorite, - removeFavorite, - isFavoriteTrack, - navigationRef, - ], - ) + index, + tracklist: memoizedTracklist, + queue, + queuingType: QueuingType.FromSelection, + startPlayback: true, + }) + } + } - const swipeConfig = useMemo( - () => - buildSwipeConfig({ - left: leftSettings, - right: rightSettings, - handlers: swipeHandlers, - }), - [leftSettings, rightSettings, swipeHandlers], - ) + const handleLongPress = () => { + if (onLongPress) { + onLongPress() + } else { + navigationRef.navigate('Context', { + item: track, + navigation, + streamingMediaSourceInfo: mediaInfo?.MediaSources + ? mediaInfo!.MediaSources![0] + : undefined, + downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, + }) + } + } - const textColor = useMemo( - () => (isPlaying ? theme.primary.val : theme.color.val), - [isPlaying], - ) + const handleIconPress = () => { + navigationRef.navigate('Context', { + item: track, + navigation, + streamingMediaSourceInfo: mediaInfo?.MediaSources + ? mediaInfo!.MediaSources![0] + : undefined, + downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo, + }) + } - const runtimeComponent = useMemo( - () => - hideRunTimes ? ( - <> - ) : ( - - {track.RunTimeTicks} - - ), - [hideRunTimes, track.RunTimeTicks], - ) + // Memoize text color to prevent recalculation + const textColor = isPlaying + ? theme.primary.val + : isOffline + ? offlineAudio + ? theme.color + : theme.neutral.val + : theme.color - return ( - - 1) + + const swipeHandlers = { + addToQueue: async () => { + console.info('Running add to queue swipe action') + await addToQueue({ + api, + deviceProfile, + networkStatus, + tracks: [track], + queuingType: QueuingType.DirectlyQueued, + }) + }, + toggleFavorite: () => { + console.info(`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`) + if (isFavoriteTrack) removeFavorite({ item: track }) + else addFavorite({ item: track }) + }, + addToPlaylist: () => { + console.info('Running add to playlist swipe handler') + navigationRef.dispatch(StackActions.push('AddToPlaylist', { track })) + }, + } + + const swipeConfig = buildSwipeConfig({ + left: leftSettings, + right: rightSettings, + handlers: swipeHandlers, + }) + + const runtimeComponent = hideRunTimes ? ( + <> + ) : ( + + {track.RunTimeTicks} + + ) + + return ( + + + setArtworkAreaWidth(e.nativeEvent.layout.width)} > - setArtworkAreaWidth(e.nativeEvent.layout.width)} - > - {showArtwork ? ( - - - - ) : ( - - {indexNumber} - - )} - + {showArtwork ? ( + + + + ) : ( + + {indexNumber} + + )} + - - + + + + {trackName} + + + {shouldShowArtists && ( - {trackName} + {artistsText} - - {shouldShowArtists && ( - - {artistsText} - - )} - - - - - - - {runtimeComponent} - {!editing && ( - )} - + + + + + + + {runtimeComponent} + {!editing && } - - - ) - }, - (prevProps, nextProps) => - prevProps.track.Id === nextProps.track.Id && - prevProps.index === nextProps.index && - prevProps.showArtwork === nextProps.showArtwork && - prevProps.isNested === nextProps.isNested && - prevProps.invertedColors === nextProps.invertedColors && - prevProps.testID === nextProps.testID && - prevProps.editing === nextProps.editing && - prevProps.queue === nextProps.queue && - prevProps.tracklist === nextProps.tracklist && - !!prevProps.onPress === !!nextProps.onPress && - !!prevProps.onLongPress === !!nextProps.onLongPress, -) + + + + ) +} function HideableArtwork({ children }: { children: React.ReactNode }) { const { tx } = useSwipeableRowContext() @@ -402,7 +313,5 @@ function SlidingTextArea({ } return { transform: [{ translateX: offset }] } }) - return {children} + return {children} } - -export default Track