dehookify some things

This commit is contained in:
Violet Caulfield
2026-03-12 08:24:12 -05:00
parent 551255d212
commit 972ec260c3
21 changed files with 191 additions and 332 deletions

View File

@@ -94,7 +94,6 @@
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View File

@@ -1,10 +1,8 @@
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { BaseStackParamList } from '../../screens/types'
import { useApi } from '../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { YStack, H5, XStack, Separator, Text, Paragraph } from 'tamagui'
import { YStack, H5, XStack, Separator, Paragraph } from 'tamagui'
import Icon from '../Global/components/icon'
import ItemImage from '../Global/components/image'
import { RunTimeTicks } from '../Global/helpers/time-codes'
@@ -13,6 +11,7 @@ import { InstantMixButton } from '../Global/components/instant-mix-button'
import { useAlbumDiscs } from '../../api/queries/album'
import { formatArtistName } from '../../utils/formatting/artist-names'
import { BUTTON_PRESS_STYLES, ICON_PRESS_STYLES } from '../../configs/style.config'
import { loadNewQueue } from '../../hooks/player/functions/queue'
/**
* Renders a header for an Album's track list
@@ -22,10 +21,6 @@ import { BUTTON_PRESS_STYLES, ICON_PRESS_STYLES } from '../../configs/style.conf
* @returns A React component
*/
export default function AlbumTrackListHeader({ album }: { album: BaseItemDto }): React.JSX.Element {
const api = useApi()
const loadNewQueue = useLoadNewQueue()
const { data: discs, isPending } = useAlbumDiscs(album)
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()

View File

@@ -11,11 +11,11 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import IconButton from '../Global/helpers/icon-button'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { getApi } from '../../stores'
import Icon from '../Global/components/icon'
import { useArtistTracks } from '../../api/queries/track'
import { ICON_PRESS_STYLES } from '../../configs/style.config'
import { loadNewQueue } from '../../hooks/player/functions/queue'
export default function ArtistHeader(): React.JSX.Element {
const { width } = useSafeAreaFrame()
@@ -24,8 +24,6 @@ export default function ArtistHeader(): React.JSX.Element {
const { artist, albums } = useArtistContext()
const loadNewQueue = useLoadNewQueue()
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const playArtist = async (shuffled: boolean = false) => {
@@ -41,7 +39,7 @@ export default function ArtistHeader(): React.JSX.Element {
if (allTracks.length === 0) return
loadNewQueue({
await loadNewQueue({
track: allTracks[0],
index: 0,
tracklist: allTracks,

View File

@@ -3,7 +3,7 @@ import {
BaseItemKind,
MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models'
import { ListItem, Spinner, View, YGroup } from 'tamagui'
import { ListItem, 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'
@@ -23,9 +23,7 @@ import ItemImage from '../Global/components/image'
import { StackActions } from '@react-navigation/native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../hooks/player/callbacks'
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native'
import { useApi } from '../../stores'
import DeletePlaylistRow from './components/delete-playlist-row'
import RemoveFromPlaylistRow from './components/remove-from-playlist-row'
@@ -34,6 +32,7 @@ import { useIsDownloaded } from '../../hooks/downloads'
import { useDownloadProgress } from 'react-native-nitro-player'
import CircularProgressIndicator from '../Global/components/circular-progress-indicator'
import { useArtist } from '../../api/queries/artist'
import { addToQueue } from '../../hooks/player/functions/queue'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -188,8 +187,6 @@ function AddToPlaylistRow({
}
function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Element {
const addToQueue = useAddToQueue()
const mutation: AddToQueueMutation = {
tracks,
}

View File

@@ -9,7 +9,6 @@ import { useNetworkStatus } from '../../../../stores/network'
import navigationRef from '../../../../screens/navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../../screens/types'
import { useAddToQueue, useLoadNewQueue } from '../../../../hooks/player/callbacks'
import SwipeableRow from '../SwipeableRow'
import { useSwipeSettingsStore } from '../../../../stores/settings/swipe'
import { buildSwipeConfig } from '../../helpers/swipe-actions'
@@ -20,6 +19,7 @@ import { StackActions } from '@react-navigation/native'
import { useHideRunTimesSetting } from '../../../../stores/settings/app'
import TrackRowContent from './content'
import { useIsDownloaded } from '../../../../hooks/downloads'
import { addToQueue, loadNewQueue } from '../../../../hooks/player/functions/queue'
export interface TrackProps {
track: BaseItemDto
@@ -63,8 +63,6 @@ export default function Track({
const [hideRunTimes] = useHideRunTimesSetting()
const currentTrackId = useCurrentTrackId()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const isDownloaded = useIsDownloaded([track.Id!])

View File

@@ -9,7 +9,6 @@ import FavoriteIcon from './favorite-icon'
import navigationRef from '../../../screens/navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types'
import { useAddToQueue, useLoadNewQueue } from '../../../hooks/player/callbacks'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
import React from 'react'
@@ -30,6 +29,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori
import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { Queue } from '../../../services/types/queue-item'
import { formatArtistName } from '../../../utils/formatting/artist-names'
import { addToQueue, loadNewQueue } from '../../../hooks/player/functions/queue'
interface ItemRowProps {
item: BaseItemDto
@@ -63,8 +63,6 @@ function ItemRow({
}: ItemRowProps): React.JSX.Element {
const artworkAreaWidth = useSharedValue(0)
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting()

View File

@@ -2,15 +2,14 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
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'
import Icon from '../../Global/components/icon'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import HomeStackParamList from '../../../screens/Home/types'
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents'
import AnimatedRow from '../../Global/helpers/animated-row'
import { loadNewQueue } from '../../../hooks/player/functions/queue'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const tracksInfiniteQuery = useFrequentlyPlayedTracks()
@@ -19,7 +18,6 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const loadNewQueue = useLoadNewQueue()
const { horizontalItems } = useDisplayContext()
return tracksInfiniteQuery.data ? (
@@ -47,8 +45,8 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
caption={track.Name}
subCaption={`${track.Artists?.join(', ')}`}
squared
onPress={() => {
loadNewQueue({
onPress={async () => {
await loadNewQueue({
track,
index,
tracklist: tracksInfiniteQuery.data ?? [track],

View File

@@ -3,23 +3,20 @@ import { H5, XStack } from 'tamagui'
import ItemCard from '../../Global/components/item-card'
import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueuingType } from '../../../enums/queuing-type'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import Icon from '../../Global/components/icon'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../../screens/Home/types'
import { useRecentlyPlayedTracks } from '../../../api/queries/recents'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client'
import AnimatedRow from '../../Global/helpers/animated-row'
import { loadNewQueue } from '../../../hooks/player/functions/queue'
export default function RecentlyPlayed(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const loadNewQueue = useLoadNewQueue()
const tracksInfiniteQuery = useRecentlyPlayedTracks()
const { horizontalItems } = useDisplayContext()

View File

@@ -2,30 +2,20 @@ import React from 'react'
import { Spacer, XStack, getToken } from 'tamagui'
import PlayPauseButton from './buttons'
import Icon from '../../Global/components/icon'
import {
usePrevious,
useSkip,
useToggleRepeatMode,
useToggleShuffle,
} from '../../../hooks/player/callbacks'
import { useRepeatMode, useShuffle } from '../../../stores/player/queue'
import { RepeatMode } from '@jellyfin/sdk/lib/generated-client/models/repeat-mode'
import { toggleRepeatMode } from '../../../hooks/player/functions/repeat-mode'
import { toggleShuffle } from '../../../hooks/player/functions/shuffle'
import { previous, skip } from '../../../hooks/player/functions/controls'
export default function Controls({
onLyricsScreen,
}: {
onLyricsScreen?: boolean
}): React.JSX.Element {
const previous = usePrevious()
const skip = useSkip()
const repeatMode = useRepeatMode()
const toggleRepeatMode = useToggleRepeatMode()
const shuffled = useShuffle()
const toggleShuffle = useToggleShuffle()
return (
<XStack alignItems='center' justifyContent='space-between'>
{!onLyricsScreen && (
@@ -33,7 +23,7 @@ export default function Controls({
small
color={shuffled ? '$primary' : '$color'}
name='shuffle'
onPress={() => toggleShuffle(shuffled)}
onPress={async () => await toggleShuffle(shuffled)}
/>
)}
@@ -42,7 +32,7 @@ export default function Controls({
<Icon
name='skip-previous'
color='$primary'
onPress={previous}
onPress={async () => await previous()}
large
testID='previous-button-test-id'
/>
@@ -53,7 +43,7 @@ export default function Controls({
<Icon
name='skip-next'
color='$primary'
onPress={() => skip(undefined)}
onPress={async () => await skip(undefined)}
large
testID='skip-button-test-id'
/>
@@ -65,7 +55,7 @@ export default function Controls({
small
color={repeatMode === 'off' ? '$color' : '$primary'}
name={repeatMode === 'track' ? 'repeat-once' : 'repeat'}
onPress={async () => toggleRepeatMode()}
onPress={toggleRepeatMode}
/>
)}
</XStack>

View File

@@ -9,23 +9,11 @@ import { QueryKeys } from '../../../enums/query-keys'
import navigationRef from '../../../screens/navigation'
import Icon from '../../Global/components/icon'
import { CommonActions } from '@react-navigation/native'
import { Gesture } from 'react-native-gesture-handler'
import Animated, {
Easing,
FadeIn,
FadeOut,
LinearTransition,
useSharedValue,
withDelay,
withSpring,
} from 'react-native-reanimated'
import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated'
import type { SharedValue } from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../../hooks/player/callbacks'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
import { isExplicit } from '../../../utils/trackDetails'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
import { MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
import { ICON_PRESS_STYLES } from '../../../configs/style.config'
@@ -35,43 +23,8 @@ type SongInfoProps = {
swipeX?: SharedValue<number>
}
export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Element {
export default function SongInfo(): React.JSX.Element {
const api = useApi()
const skip = useSkip()
const previous = usePrevious()
// local fallback if no shared value was provided
const localX = useSharedValue(0)
const x = swipeX ?? localX
const albumGesture = Gesture.Pan()
.activeOffsetX([-12, 12])
.onUpdate((e) => {
if (Math.abs(e.translationY) < 40) {
x.value = Math.max(-160, Math.min(160, e.translationX))
}
})
.onEnd((e) => {
const threshold = 120
const minVelocity = 600
const isHorizontal = Math.abs(e.translationY) < 40
if (
isHorizontal &&
(Math.abs(e.translationX) > threshold || Math.abs(e.velocityX) > minVelocity)
) {
if (e.translationX > 0) {
x.value = withSpring(220)
runOnJS(triggerHaptic)('notificationSuccess')
runOnJS(skip)(undefined)
} else {
x.value = withSpring(-220)
runOnJS(triggerHaptic)('notificationSuccess')
runOnJS(previous)()
}
x.value = withDelay(160, withSpring(0))
} else {
x.value = withSpring(0)
}
})
const currentTrack = useCurrentTrack()

View File

@@ -11,15 +11,13 @@ import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../hooks/player/callbacks'
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../stores/player/queue'
import { previous, skip } from '../../hooks/player/functions/controls'
export default function PlayerScreen(): React.JSX.Element {
usePerformanceMonitor('PlayerScreen', 5)
const skip = useSkip()
const previous = usePrevious()
const nowPlaying = useCurrentTrack()
const { width, height } = useWindowDimensions()

View File

@@ -11,15 +11,12 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
Easing,
FadeIn,
FadeInDown,
FadeOut,
FadeOutDown,
useSharedValue,
useAnimatedStyle,
withTiming,
useAnimatedReaction,
ReduceMotion,
SlideInUp,
SlideInDown,
SlideOutDown,
interpolate,
@@ -28,17 +25,15 @@ 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 '../../hooks/player/callbacks'
import { useCurrentTrack } from '../../stores/player/queue'
import getTrackDto from '../../utils/mapping/track-extra-payload'
import { ICON_PRESS_STYLES } from '../../configs/style.config'
import { previous, skip } from '../../hooks/player/functions/controls'
export default function Miniplayer(): React.JSX.Element | null {
const nowPlaying = useCurrentTrack()
const item = getTrackDto(nowPlaying)
const skip = useSkip()
const previous = usePrevious()
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()

View File

@@ -3,14 +3,9 @@ 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 { useNetworkStatus } from '../../../stores/network'
import { QueuingType } from '../../../enums/queuing-type'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../hooks/player/callbacks'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import ItemImage from '../../Global/components/image'
import { useApi } from '../../../stores'
import Input from '../../Global/helpers/input'
import Animated, { Easing, FadeInDown, FadeOutDown } from 'react-native-reanimated'
import { Dispatch, SetStateAction } from 'react'
@@ -18,6 +13,7 @@ import Button from '../../Global/helpers/button'
import { Text } from '../../Global/helpers/text'
import { RunTimeTicks } from '../../Global/helpers/time-codes'
import { BUTTON_PRESS_STYLES } from '../../../configs/style.config'
import { loadNewQueue } from '../../../hooks/player/functions/queue'
export default function PlaylistTracklistHeader({
playlist,
@@ -97,12 +93,6 @@ function PlaylistHeaderControls({
playlist: BaseItemDto
playlistTracks: BaseItemDto[]
}): React.JSX.Element {
const streamingDeviceProfile = useStreamingDeviceProfile()
const loadNewQueue = useLoadNewQueue()
const api = useApi()
const [networkStatus] = useNetworkStatus()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const playPlaylist = async (shuffled: boolean = false) => {

View File

@@ -11,8 +11,6 @@ import { RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import PlaylistTracklistHeader from './components/header'
import navigationRef from '../../screens/navigation'
import { useLoadNewQueue } from '../../hooks/player/callbacks'
import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useEffect, useLayoutEffect, useState } from 'react'
@@ -36,6 +34,7 @@ import { queryClient } from '../../constants/query-client'
import { PlaylistTracksQueryKey } from '../../api/queries/playlist/keys'
import { useIsDownloaded } from '../../hooks/downloads'
import { useDeleteDownloads } from '../../hooks/downloads/mutations'
import { loadNewQueue } from '../../hooks/player/functions/queue'
export default function Playlist({
playlist,
@@ -145,8 +144,6 @@ export default function Playlist({
if (!editing) refetch()
}, [editing])
const loadNewQueue = useLoadNewQueue()
const isDownloaded = useIsDownloaded(playlistTracks?.map(({ Id }) => Id) ?? [])
const playlistDownloadPending = useIsDownloading(playlistTracks ?? [])

View File

@@ -4,7 +4,6 @@ import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text, XStack } from 'tamagui'
import { useLayoutEffect, useRef } from 'react'
import { useRemoveFromQueue, useReorderQueue, useSkip } from '../../hooks/player/callbacks'
import { useCurrentIndex, usePlayQueue, useQueueRef } from '../../stores/player/queue'
import Sortable from 'react-native-sortables'
import { OrderChangeParams, RenderItemInfo } from 'react-native-sortables/dist/typescript/types'
@@ -13,6 +12,8 @@ import Animated, { useAnimatedRef } from 'react-native-reanimated'
import { TrackItem } from 'react-native-nitro-player'
import getTrackDto from '../../utils/mapping/track-extra-payload'
import { View } from 'react-native'
import { skip } from '../../hooks/player/functions/controls'
import { removeItemFromQueue, reorderQueue } from '../../hooks/player/functions/queue'
export default function Queue({
navigation,
@@ -24,9 +25,6 @@ export default function Queue({
const currentIndex = useCurrentIndex()
const queueRef = useQueueRef()
const removeFromQueue = useRemoveFromQueue()
const reorderQueue = useReorderQueue()
const skip = useSkip()
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
@@ -81,7 +79,7 @@ export default function Queue({
<Sortable.Touchable
onTap={async () => {
await removeFromQueue(index)
await removeItemFromQueue(index)
}}
>
<Icon name='close' color='$warning' />

View File

@@ -1,27 +1,13 @@
import {
loadQueue,
playLaterInQueue,
playNextInQueue,
removeItemFromQueue,
} from './functions/queue'
import { addToQueue, loadNewQueue, removeItemFromQueue } from './functions/queue'
import { previous, skip } from './functions/controls'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces'
import { QueuingType } from '../../enums/queuing-type'
import Toast from 'react-native-toast-message'
import { QueueOrderMutation } from './interfaces'
import { handleDeshuffle, handleShuffle } from './functions/shuffle'
import usePlayerEngineStore, { PlayerEngine } from '../../stores/player/engine'
import { useRemoteMediaClient } from 'react-native-google-cast'
import { triggerHaptic } from '../use-haptic-feedback'
import { usePlayerQueueStore } from '../../stores/player/queue'
import {
PlayerQueue,
RepeatMode,
TrackItem,
TrackPlayer,
TrackPlayerState,
} from 'react-native-nitro-player'
import reportPlaybackStarted from '../../api/mutations/playback/functions/playback-started'
import { updateTrackMediaInfo } from '../../providers/Player/utils/event-handlers'
import { PlayerQueue, TrackItem, TrackPlayer, TrackPlayerState } from 'react-native-nitro-player'
import { toggleRepeatMode } from './functions/repeat-mode'
/**
* A mutation to handle toggling the playback state
@@ -48,27 +34,12 @@ export const useTogglePlayback = () => {
}
}
/**
* @deprecated Let's just use the function this returns directly instead
* of subscribing to a hook
*/
export const useToggleRepeatMode = () => {
return () => {
const currentMode = usePlayerQueueStore.getState().repeatMode
triggerHaptic('impactLight')
let nextMode: RepeatMode
switch (currentMode) {
case 'off':
nextMode = 'Playlist'
break
case 'Playlist':
nextMode = 'track'
break
default:
nextMode = 'off'
}
TrackPlayer.setRepeatMode(nextMode)
usePlayerQueueStore.getState().setRepeatMode(nextMode)
}
return toggleRepeatMode
}
/**
@@ -104,109 +75,6 @@ const useSeekBy = () => {
}
}
export const useAddToQueue = () => {
return async (variables: AddToQueueMutation) => {
try {
if (variables.queuingType === QueuingType.PlayNext)
await playNextInQueue({ ...variables })
else await playLaterInQueue({ ...variables })
triggerHaptic('notificationSuccess')
Toast.show({
text1:
variables.queuingType === QueuingType.PlayNext
? 'Playing next'
: 'Added to queue',
type: 'success',
})
} catch (error) {
triggerHaptic('notificationError')
console.error(
`Failed to ${variables.queuingType === QueuingType.PlayNext ? 'play next' : 'add to queue'}`,
error,
)
Toast.show({
text1:
variables.queuingType === QueuingType.PlayNext
? 'Failed to play next'
: 'Failed to add to queue',
type: 'error',
})
} finally {
const queue = await TrackPlayer.getActualQueue()
usePlayerQueueStore.getState().setQueue(queue)
}
}
}
export const useLoadNewQueue = () => {
return async (variables: QueueMutation) => {
triggerHaptic('impactLight')
usePlayerQueueStore.getState().setIsQueuing(true)
const { tracks, finalStartIndex } = await loadQueue({ ...variables })
// skipToIndex is now settled. Drive a single, authoritative URL-resolution
// pass while isQueuing=true so any concurrent native callbacks are still
// silenced. resolveTrackUrls bypasses the isQueuing guard intentionally.
const tracksNeedingUrls = await TrackPlayer.getTracksNeedingUrls()
if (tracksNeedingUrls.length > 0) {
await updateTrackMediaInfo(tracksNeedingUrls)
}
usePlayerQueueStore.getState().setIsQueuing(false)
if (variables.startPlayback) {
TrackPlayer.play()
reportPlaybackStarted(tracks[finalStartIndex], 0)
}
}
}
export const usePrevious = () => {
return async () => {
triggerHaptic('impactMedium')
await previous()
}
}
export const useSkip = () => {
return async (index?: number | undefined) => {
triggerHaptic('impactMedium')
await skip(index)
}
}
export const useRemoveFromQueue = () => {
return async (index: number) => {
await removeItemFromQueue(index)
}
}
export const useReorderQueue = () => {
return async ({ fromIndex, toIndex }: QueueOrderMutation) => {
const playlistId = PlayerQueue.getCurrentPlaylistId()
if (!playlistId) return
const { tracks } = PlayerQueue.getPlaylist(playlistId)!
PlayerQueue.reorderTrackInPlaylist(playlistId, tracks[fromIndex].id, toIndex)
const { currentIndex } = await TrackPlayer.getState()
const queue = await TrackPlayer.getActualQueue()
usePlayerQueueStore.setState((state) => ({
...state,
queue,
currentIndex,
}))
}
}
export const useResetQueue = () => () => {
usePlayerQueueStore.getState().setUnshuffledQueue([])
usePlayerQueueStore.getState().setShuffled(false)
@@ -214,21 +82,3 @@ export const useResetQueue = () => () => {
usePlayerQueueStore.getState().setQueue([])
usePlayerQueueStore.getState().setCurrentIndex(undefined)
}
export const useToggleShuffle = () => {
return async (shuffled: boolean) => {
triggerHaptic('impactMedium')
let result: { currentIndex: number; queue: TrackItem[] } | undefined
if (shuffled) result = await handleDeshuffle()
else result = await handleShuffle()
usePlayerQueueStore.setState((state) => ({
...state,
queue: result.queue,
currentIndex: result.currentIndex,
shuffled: !shuffled,
}))
}
}

View File

@@ -1,6 +1,7 @@
import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../../configs/player.config'
import { isUndefined } from 'lodash'
import { TrackPlayer } from 'react-native-nitro-player'
import { triggerHaptic } from '../../use-haptic-feedback'
/**
* A function that will skip to the previous track if
@@ -15,11 +16,13 @@ import { TrackPlayer } from 'react-native-nitro-player'
* Does not resume playback if the player was paused
*/
export async function previous(): Promise<void> {
triggerHaptic('impactMedium')
const { currentState, currentIndex, currentPosition } = await TrackPlayer.getState()
if (isUndefined(currentIndex)) return
if (Math.floor(currentPosition) < SKIP_TO_PREVIOUS_THRESHOLD) {
if (Math.floor(currentPosition) <= SKIP_TO_PREVIOUS_THRESHOLD) {
TrackPlayer.skipToPrevious()
} else {
TrackPlayer.seek(0)
@@ -39,6 +42,8 @@ export async function previous(): Promise<void> {
* @param index The track index to skip to, to skip multiple tracks
*/
export async function skip(index: number | undefined): Promise<void> {
triggerHaptic('impactMedium')
const { currentIndex } = await TrackPlayer.getState()
if (!isUndefined(index)) {

View File

@@ -1,7 +1,7 @@
import { mapDtoToTrack } from '../../../utils/mapping/item-to-track'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { clearPlaylists, filterTracksOnNetworkStatus } from './utils/queue'
import { AddToQueueMutation, QueueMutation } from '../interfaces'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from '../interfaces'
import { shuffleJellifyTracks } from './utils/shuffle'
import { setNewQueue, usePlayerQueueStore } from '../../../stores/player/queue'
@@ -10,12 +10,37 @@ import { useNetworkStore } from '../../../stores/network'
import { DownloadManager, PlayerQueue, TrackItem, TrackPlayer } from 'react-native-nitro-player'
import uuid from 'react-native-uuid'
import { triggerHaptic } from '../../use-haptic-feedback'
import Toast from 'react-native-toast-message'
import { QueuingType } from '../../../enums/queuing-type'
import { updateTrackMediaInfo } from '../../../providers/Player/utils/event-handlers'
import reportPlaybackStarted from '../../../api/mutations/playback/functions/playback-started'
type LoadQueueResult = {
finalStartIndex: number
tracks: TrackItem[]
}
export const loadNewQueue = async (variables: QueueMutation) => {
triggerHaptic('impactLight')
usePlayerQueueStore.getState().setIsQueuing(true)
const { tracks, finalStartIndex } = await loadQueue({ ...variables })
// skipToIndex is now settled. Drive a single, authoritative URL-resolution
// pass while isQueuing=true so any concurrent native callbacks are still
// silenced. resolveTrackUrls bypasses the isQueuing guard intentionally.
const tracksNeedingUrls = await TrackPlayer.getTracksNeedingUrls()
if (tracksNeedingUrls.length > 0) {
await updateTrackMediaInfo(tracksNeedingUrls)
}
usePlayerQueueStore.getState().setIsQueuing(false)
if (variables.startPlayback) {
TrackPlayer.play()
reportPlaybackStarted(tracks[finalStartIndex], 0)
}
}
export async function loadQueue({
index = 0,
tracklist,
@@ -120,6 +145,37 @@ export const playLaterInQueue = async ({ tracks }: AddToQueueMutation) => {
.setUnshuffledQueue([...usePlayerQueueStore.getState().unShuffledQueue, ...newTracks])
}
export const addToQueue = async (variables: AddToQueueMutation) => {
try {
if (variables.queuingType === QueuingType.PlayNext) await playNextInQueue({ ...variables })
else await playLaterInQueue({ ...variables })
triggerHaptic('notificationSuccess')
Toast.show({
text1:
variables.queuingType === QueuingType.PlayNext ? 'Playing next' : 'Added to queue',
type: 'success',
})
} catch (error) {
triggerHaptic('notificationError')
console.error(
`Failed to ${variables.queuingType === QueuingType.PlayNext ? 'play next' : 'add to queue'}`,
error,
)
Toast.show({
text1:
variables.queuingType === QueuingType.PlayNext
? 'Failed to play next'
: 'Failed to add to queue',
type: 'error',
})
} finally {
const queue = await TrackPlayer.getActualQueue()
usePlayerQueueStore.getState().setQueue(queue)
}
}
export const removeItemFromQueue = async (index: number) => {
triggerHaptic('impactMedium')
@@ -171,3 +227,23 @@ export const removeItemFromQueue = async (index: number) => {
currentIndex: newCurrentIndex,
}))
}
export const reorderQueue = async ({ fromIndex, toIndex }: QueueOrderMutation) => {
const playlistId = PlayerQueue.getCurrentPlaylistId()
if (!playlistId) return
const { tracks } = PlayerQueue.getPlaylist(playlistId)!
PlayerQueue.reorderTrackInPlaylist(playlistId, tracks[fromIndex].id, toIndex)
const { currentIndex } = await TrackPlayer.getState()
const queue = await TrackPlayer.getActualQueue()
usePlayerQueueStore.setState((state) => ({
...state,
queue,
currentIndex,
}))
}

View File

@@ -0,0 +1,24 @@
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { triggerHaptic } from '../../use-haptic-feedback'
import { RepeatMode, TrackPlayer } from 'react-native-nitro-player'
export const toggleRepeatMode = () => {
const currentMode = usePlayerQueueStore.getState().repeatMode
triggerHaptic('impactLight')
let nextMode: RepeatMode
switch (currentMode) {
case 'off':
nextMode = 'Playlist'
break
case 'Playlist':
nextMode = 'track'
break
default:
nextMode = 'off'
}
TrackPlayer.setRepeatMode(nextMode)
usePlayerQueueStore.getState().setRepeatMode(nextMode)
}

View File

@@ -19,6 +19,23 @@ import { ApiLimits } from '../../../configs/query.config'
import { mapDtoToTrack } from '../../../utils/mapping/item-to-track'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { triggerHaptic } from '../../use-haptic-feedback'
export const toggleShuffle = async (shuffled: boolean) => {
triggerHaptic('impactMedium')
let result: { currentIndex: number; queue: TrackItem[] } | undefined
if (shuffled) result = await handleDeshuffle()
else result = await handleShuffle()
usePlayerQueueStore.setState((state) => ({
...state,
queue: result.queue,
currentIndex: result.currentIndex,
shuffled: !shuffled,
}))
}
export async function handleShuffle(
keepCurrentTrack: boolean = true,
@@ -259,25 +276,21 @@ export async function handleShuffle(
// Save off unshuffledQueue
usePlayerQueueStore.getState().setUnshuffledQueue([...playQueue])
// Tracks that come AFTER the currently playing track — these are what we shuffle.
const tracksAfterCurrent = playQueue.filter((_, index) => index > currentIndex)
// Only shuffle tracks AFTER the current position. Tracks before it have already
// played and must stay in the native playlist prefix so ExoPlayer's window index
// keeps pointing at the right item — removing them would cause the next skip to
// jump to the wrong track.
const tracksAfterCurrent = playQueue.filter((_, i) => i > currentIndex)
const { shuffled: newShuffledQueue } = shuffleJellifyTracks(tracksAfterCurrent)
if (keepCurrentTrack) {
// KEY: only touch tracks that are AFTER the current track.
//
// Android's ExoPlayer rebuilds via setMediaItems(list, resetPosition=false), which
// preserves the current window INDEX — not the track identity. If we remove tracks
// before the current track, the playlist shrinks and ExoPlayer's saved index can
// become out-of-bounds, causing it to jump to the last item.
//
// By leaving all tracks before (and including) currentIndex in place, ExoPlayer's
// current window index still points at the right track after the rebuild.
tracksAfterCurrent.forEach((track) =>
PlayerQueue.removeTrackFromPlaylist(playlistId!, track.id),
)
// Insert the shuffled upcoming tracks right after currentIndex.
// Insert the shuffled upcoming tracks right after the current track.
// Must use currentIndex + 1 (not a hardcoded 1) so the insert position is
// correct regardless of how many tracks precede the current one.
PlayerQueue.addTracksToPlaylist(playlistId!, newShuffledQueue, currentIndex + 1)
// Present a clean queue to the JS store (current track first, then shuffled upcoming).

View File

@@ -6,24 +6,26 @@ import { QueryKeys } from '../enums/query-keys'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import fetchUserData from '../api/queries/user-data/utils'
import { useRef } from 'react'
import UserDataQueryKey from '../api/queries/user-data/keys'
import { getApi, getUser } from '../stores'
import { ArtistQueryKey } from '../api/queries/artist/keys'
// Module-level dedup guard — no hook needed, this is just a long-lived Set
const prefetchedContext = new Set<string>()
export default function useItemContext(): (item: BaseItemDto) => void {
const api = getApi()
const user = getUser()
const prefetchedContext = useRef<Set<string>>(new Set())
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 (prefetchedContext.has(effectSig)) return
// Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig)
prefetchedContext.add(effectSig)
// Read api/user inside the callback so they're only resolved when actually needed
const api = getApi()
const user = getUser()
warmItemContext(api, user, item)
}
@@ -41,8 +43,7 @@ function warmItemContext(
if (Type === BaseItemKind.Audio) warmTrackContext(api, item)
if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
if (Type === BaseItemKind.MusicArtist) queryClient.setQueryData(ArtistQueryKey(Id), item)
if (Type === BaseItemKind.MusicAlbum) warmAlbumContext(api, item)
@@ -64,13 +65,11 @@ function warmItemContext(
staleTime: ONE_HOUR,
})
if (queryClient.getQueryState(UserDataQueryKey(user, item.Id!))?.status !== 'success') {
queryClient.ensureQueryData({
queryKey: UserDataQueryKey(user, item.Id!),
queryFn: () => fetchUserData(Id),
staleTime: ONE_MINUTE * 15,
})
}
queryClient.ensureQueryData({
queryKey: UserDataQueryKey(user, item.Id!),
queryFn: () => fetchUserData(Id),
staleTime: ONE_MINUTE * 15,
})
}
function warmAlbumContext(api: Api | undefined, album: BaseItemDto): void {
@@ -92,17 +91,10 @@ function warmArtistContext(api: Api | undefined, artistId: string): void {
// Fail fast if we don't have an artist ID to work with
if (!artistId) return
const queryKey = [QueryKeys.ArtistById, artistId]
// Bail out if we have data
if (queryClient.getQueryState(queryKey)?.status === 'success') return
/**
* Store queryable of artist item
*/
// ensureQueryData respects staleTime internally — no need to check getQueryState first
queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchItem(api, artistId!),
queryKey: ArtistQueryKey(artistId),
queryFn: () => fetchItem(api, artistId),
staleTime: ONE_DAY,
})
}
@@ -110,12 +102,10 @@ function warmArtistContext(api: Api | undefined, artistId: string): void {
function warmTrackContext(api: Api | undefined, track: BaseItemDto): void {
const { AlbumId, ArtistItems } = track
const albumQueryKey = [QueryKeys.Album, AlbumId]
if (AlbumId && queryClient.getQueryState(albumQueryKey)?.status !== 'success')
if (AlbumId)
queryClient.ensureQueryData({
queryKey: albumQueryKey,
queryFn: () => fetchItem(api, AlbumId!),
queryKey: [QueryKeys.Album, AlbumId],
queryFn: () => fetchItem(api, AlbumId),
staleTime: ONE_DAY,
})