mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-16 18:55:44 -06:00
@@ -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',
|
||||
],
|
||||
}
|
||||
|
||||
4
bun.lock
4
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ? (
|
||||
<ListItem
|
||||
@@ -326,10 +320,10 @@ interface MenuRowProps {
|
||||
}
|
||||
|
||||
function ViewAlbumMenuRow({ album: album, stackNavigation }: MenuRowProps): React.JSX.Element {
|
||||
const goToAlbum = useCallback(() => {
|
||||
const goToAlbum = () => {
|
||||
if (stackNavigation && album) stackNavigation.navigate('Album', { album })
|
||||
else goToAlbumFromContextSheet(album)
|
||||
}, [album, stackNavigation, navigationRef])
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -380,13 +374,10 @@ function ViewArtistMenuRow({
|
||||
enabled: !!artistId,
|
||||
})
|
||||
|
||||
const goToArtist = useCallback(
|
||||
(artist: BaseItemDto) => {
|
||||
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 ? (
|
||||
<ListItem
|
||||
|
||||
@@ -4,7 +4,7 @@ import IconButton from '../../../components/Global/helpers/icon-button'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { useTogglePlayback } from '../../../providers/Player/hooks/mutations'
|
||||
import { usePlaybackState } from '../../../providers/Player/hooks/queries'
|
||||
import React, { useMemo } from 'react'
|
||||
import React from 'react'
|
||||
import Icon from '../../Global/components/icon'
|
||||
|
||||
function PlayPauseButtonComponent({
|
||||
@@ -18,9 +18,9 @@ function PlayPauseButtonComponent({
|
||||
|
||||
const state = usePlaybackState()
|
||||
|
||||
const largeIcon = useMemo(() => 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 (
|
||||
<View justifyContent='center' alignItems='center' flex={flex}>
|
||||
@@ -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 <Icon name='pause' color='$primary' onPress={togglePlayback} />
|
||||
@@ -87,7 +87,7 @@ export function PlayPauseIcon(): React.JSX.Element {
|
||||
return <Icon name='play' color='$primary' onPress={togglePlayback} />
|
||||
}
|
||||
}
|
||||
}, [state, togglePlayback])
|
||||
})()
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<YStack flexGrow={1} justifyContent='flex-start'>
|
||||
@@ -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 (
|
||||
<YStack
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { Spacer, XStack, YStack } from 'tamagui'
|
||||
@@ -42,14 +42,9 @@ export default function Scrubber(): React.JSX.Element {
|
||||
|
||||
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
|
||||
|
||||
// Memoize expensive calculations
|
||||
const maxDuration = useMemo(() => {
|
||||
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 (
|
||||
<GestureDetector gesture={scrubGesture}>
|
||||
|
||||
@@ -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 (
|
||||
<GestureDetector gesture={gesture}>
|
||||
|
||||
@@ -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<typeof setTimeout>
|
||||
|
||||
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 (
|
||||
<FlatList
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from 'react'
|
||||
import ItemRow from '../Global/components/item-row'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { H3, Separator, YStack } from 'tamagui'
|
||||
@@ -17,9 +16,9 @@ export default function Suggestions({
|
||||
suggestions: BaseItemDto[] | undefined
|
||||
}): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<SearchParamList>>()
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
const handleScrollBeginDrag = () => {
|
||||
closeAllSwipeableRows()
|
||||
}, [])
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { RefObject, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import React, { RefObject, useRef, useEffect } from 'react'
|
||||
import Track from '../Global/components/track'
|
||||
import { Separator, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
@@ -35,29 +35,22 @@ export default function Tracks({
|
||||
|
||||
const pendingLetterRef = useRef<string | null>(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' ? (
|
||||
<FlashListStickyHeader text={track.toUpperCase()} />
|
||||
) : typeof track === 'number' ? null : typeof track === 'object' ? (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
showArtwork
|
||||
index={0}
|
||||
track={track}
|
||||
testID={`track-item-${index}`}
|
||||
tracklist={tracksToDisplay.slice(index, index + 50)}
|
||||
queue={queue}
|
||||
/>
|
||||
) : null,
|
||||
[tracksToDisplay, queue, navigation, queue],
|
||||
)
|
||||
const renderItem = ({
|
||||
item: track,
|
||||
index,
|
||||
}: {
|
||||
index: number
|
||||
item: string | number | BaseItemDto
|
||||
}) =>
|
||||
typeof track === 'string' ? (
|
||||
<FlashListStickyHeader text={track.toUpperCase()} />
|
||||
) : typeof track === 'number' ? null : typeof track === 'object' ? (
|
||||
<Track
|
||||
navigation={navigation}
|
||||
showArtwork
|
||||
index={0}
|
||||
track={track}
|
||||
testID={`track-item-${index}`}
|
||||
tracklist={tracksToDisplay.slice(index, index + 50)}
|
||||
queue={queue}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const ItemSeparatorComponent = useCallback(
|
||||
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
|
||||
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
|
||||
<Separator />
|
||||
),
|
||||
[],
|
||||
)
|
||||
const ItemSeparatorComponent = ({
|
||||
leadingItem,
|
||||
trailingItem,
|
||||
}: {
|
||||
leadingItem: unknown
|
||||
trailingItem: unknown
|
||||
}) =>
|
||||
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
|
||||
|
||||
// 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 (
|
||||
<XStack flex={1}>
|
||||
|
||||
@@ -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<Set<string>>(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(
|
||||
|
||||
@@ -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 <ArtistContext.Provider value={value}>{children}</ArtistContext.Provider>
|
||||
}
|
||||
|
||||
@@ -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 <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<StorageSummary | undefined>(() => {
|
||||
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<CleanupSuggestion[]>(() => {
|
||||
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<DeleteDownloadsResult | undefined> => {
|
||||
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<DeleteDownloadsResult | undefined> => {
|
||||
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<StorageContextValue>(
|
||||
() => ({
|
||||
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 <StorageContext.Provider value={value}>{children}</StorageContext.Provider>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user