From 972ec260c3b834452ec608043254a73fcd61c6bc Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:24:12 -0500 Subject: [PATCH] dehookify some things --- ios/Jellify/Info.plist | 1 - src/components/Album/header.tsx | 9 +- src/components/Artist/header.tsx | 6 +- src/components/Context/index.tsx | 7 +- .../Global/components/Track/index.tsx | 4 +- src/components/Global/components/item-row.tsx | 4 +- .../Home/helpers/frequent-tracks.tsx | 8 +- .../Home/helpers/recently-played.tsx | 5 +- src/components/Player/components/controls.tsx | 24 +-- .../Player/components/song-info.tsx | 51 +----- src/components/Player/index.tsx | 4 +- src/components/Player/mini-player.tsx | 7 +- src/components/Playlist/components/header.tsx | 12 +- src/components/Playlist/index.tsx | 5 +- src/components/Queue/index.tsx | 8 +- src/hooks/player/callbacks.ts | 168 +----------------- src/hooks/player/functions/controls.ts | 7 +- src/hooks/player/functions/queue.ts | 78 +++++++- src/hooks/player/functions/repeat-mode.ts | 24 +++ src/hooks/player/functions/shuffle.ts | 37 ++-- src/hooks/use-item-context.ts | 54 +++--- 21 files changed, 191 insertions(+), 332 deletions(-) create mode 100644 src/hooks/player/functions/repeat-mode.ts diff --git a/ios/Jellify/Info.plist b/ios/Jellify/Info.plist index 29db5a1f..23ba206b 100644 --- a/ios/Jellify/Info.plist +++ b/ios/Jellify/Info.plist @@ -94,7 +94,6 @@ audio fetch - processing UILaunchStoryboardName LaunchScreen diff --git a/src/components/Album/header.tsx b/src/components/Album/header.tsx index a6e77852..ed3bcd4f 100644 --- a/src/components/Album/header.tsx +++ b/src/components/Album/header.tsx @@ -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>() diff --git a/src/components/Artist/header.tsx b/src/components/Artist/header.tsx index 9a221484..cb4c7005 100644 --- a/src/components/Artist/header.tsx +++ b/src/components/Artist/header.tsx @@ -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>() 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, diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx index 3f3cd463..fad7aeeb 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, 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, 'navigate' | 'dispatch'> @@ -188,8 +187,6 @@ function AddToPlaylistRow({ } function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Element { - const addToQueue = useAddToQueue() - const mutation: AddToQueueMutation = { tracks, } diff --git a/src/components/Global/components/Track/index.tsx b/src/components/Global/components/Track/index.tsx index 2c687f4c..3ebc91f8 100644 --- a/src/components/Global/components/Track/index.tsx +++ b/src/components/Global/components/Track/index.tsx @@ -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!]) diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx index aaa90743..b0d99e9f 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/item-row.tsx @@ -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() diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx index 870fc6a5..4f94ca3b 100644 --- a/src/components/Home/helpers/frequent-tracks.tsx +++ b/src/components/Home/helpers/frequent-tracks.tsx @@ -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>() - 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], diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx index ba321d49..52d35a8d 100644 --- a/src/components/Home/helpers/recently-played.tsx +++ b/src/components/Home/helpers/recently-played.tsx @@ -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>() const rootNavigation = useNavigation>() - const loadNewQueue = useLoadNewQueue() - const tracksInfiniteQuery = useRecentlyPlayedTracks() const { horizontalItems } = useDisplayContext() diff --git a/src/components/Player/components/controls.tsx b/src/components/Player/components/controls.tsx index f6b567d5..ae4b70f6 100644 --- a/src/components/Player/components/controls.tsx +++ b/src/components/Player/components/controls.tsx @@ -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 ( {!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({ await previous()} large testID='previous-button-test-id' /> @@ -53,7 +43,7 @@ export default function Controls({ 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} /> )} diff --git a/src/components/Player/components/song-info.tsx b/src/components/Player/components/song-info.tsx index da3ca434..4ab997c7 100644 --- a/src/components/Player/components/song-info.tsx +++ b/src/components/Player/components/song-info.tsx @@ -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 } -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() diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index 3beb7b62..a970071e 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -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() diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index 1df01d29..51aee18e 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -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>() diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx index b42140e2..17b4f3ec 100644 --- a/src/components/Playlist/components/header.tsx +++ b/src/components/Playlist/components/header.tsx @@ -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>() const playPlaylist = async (shuffled: boolean = false) => { diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx index 907a811e..be5d201f 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -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 ?? []) diff --git a/src/components/Queue/index.tsx b/src/components/Queue/index.tsx index 143377bb..a58fb266 100644 --- a/src/components/Queue/index.tsx +++ b/src/components/Queue/index.tsx @@ -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() @@ -81,7 +79,7 @@ export default function Queue({ { - await removeFromQueue(index) + await removeItemFromQueue(index) }} > diff --git a/src/hooks/player/callbacks.ts b/src/hooks/player/callbacks.ts index dad55411..d9692e01 100644 --- a/src/hooks/player/callbacks.ts +++ b/src/hooks/player/callbacks.ts @@ -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, - })) - } -} diff --git a/src/hooks/player/functions/controls.ts b/src/hooks/player/functions/controls.ts index da5fc038..48687cd1 100644 --- a/src/hooks/player/functions/controls.ts +++ b/src/hooks/player/functions/controls.ts @@ -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 { + 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 { * @param index The track index to skip to, to skip multiple tracks */ export async function skip(index: number | undefined): Promise { + triggerHaptic('impactMedium') + const { currentIndex } = await TrackPlayer.getState() if (!isUndefined(index)) { diff --git a/src/hooks/player/functions/queue.ts b/src/hooks/player/functions/queue.ts index 4072fd92..09d2c890 100644 --- a/src/hooks/player/functions/queue.ts +++ b/src/hooks/player/functions/queue.ts @@ -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, + })) +} diff --git a/src/hooks/player/functions/repeat-mode.ts b/src/hooks/player/functions/repeat-mode.ts new file mode 100644 index 00000000..02a3dd21 --- /dev/null +++ b/src/hooks/player/functions/repeat-mode.ts @@ -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) +} diff --git a/src/hooks/player/functions/shuffle.ts b/src/hooks/player/functions/shuffle.ts index eaf03758..0eb38a5b 100644 --- a/src/hooks/player/functions/shuffle.ts +++ b/src/hooks/player/functions/shuffle.ts @@ -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). diff --git a/src/hooks/use-item-context.ts b/src/hooks/use-item-context.ts index 67674094..5f040687 100644 --- a/src/hooks/use-item-context.ts +++ b/src/hooks/use-item-context.ts @@ -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() export default function useItemContext(): (item: BaseItemDto) => void { - const api = getApi() - const user = getUser() - - const prefetchedContext = useRef>(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, })