From 87393f1f08b45dc95dce4c70361b7827b007cb42 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Sat, 26 Apr 2025 13:49:04 -0500 Subject: [PATCH] Player Backend Improvements (#291) Separating Queuing and Player logic, please report bugs if you experience playback issues or queue irregularities Fetching additional track metadata for use in later features, utilizing transcoding URLs reported by Jellyfin Disable NowPlaying in CarPlay on startup - this should be navigable yet in the CarPlay interface --- api/queries/functions/media.ts | 2 +- components/Global/components/item.tsx | 27 +- components/Global/components/track.tsx | 28 +- components/Home/helpers/frequent-tracks.tsx | 27 +- components/Home/helpers/recently-played.tsx | 26 +- components/Home/stack.tsx | 2 - .../ItemDetail/helpers/TrackOptions.tsx | 4 +- components/Player/helpers/controls.tsx | 13 +- components/Player/helpers/scrubber.tsx | 6 +- components/Player/mini-player.tsx | 4 +- components/Player/screens/index.tsx | 13 +- components/Player/screens/queue.tsx | 105 ++-- components/Settings/helpers/sign-out.tsx | 3 +- components/jellify.tsx | 9 +- components/navigation.tsx | 10 - components/offlineList/index.tsx | 68 --- components/provider.tsx | 1 - ios/Jellify.xcodeproj/project.pbxproj | 22 +- ios/dummy-rntp.swift | 8 - package.json | 1 - player/helpers/queue.ts | 2 +- player/interfaces.ts | 1 - player/provider.tsx | 468 +----------------- player/queue-provider.tsx | 444 ++++++++++++++++- yarn.lock | 48 +- 25 files changed, 636 insertions(+), 706 deletions(-) delete mode 100644 components/offlineList/index.tsx delete mode 100644 ios/dummy-rntp.swift diff --git a/api/queries/functions/media.ts b/api/queries/functions/media.ts index 137cf675..5d153b74 100644 --- a/api/queries/functions/media.ts +++ b/api/queries/functions/media.ts @@ -10,7 +10,7 @@ export async function fetchMediaInfo(itemId: string): Promise { - console.debug(data) + console.debug('Received media info response') resolve(data) }) .catch((error) => { diff --git a/components/Global/components/item.tsx b/components/Global/components/item.tsx index 1abdb373..3d85615b 100644 --- a/components/Global/components/item.tsx +++ b/components/Global/components/item.tsx @@ -1,4 +1,3 @@ -import { usePlayerContext } from '../../../player/provider' import { StackParamList } from '../../../components/types' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackNavigationProp } from '@react-navigation/native-stack' @@ -9,6 +8,9 @@ import BlurhashedImage from './blurhashed-image' import Icon from '../helpers/icon' import { QueuingType } from '../../../enums/queuing-type' import { RunTimeTicks } from '../helpers/time-codes' +import { useQueueContext } from '../../../player/queue-provider' +import { usePlayerContext } from '../../../player/provider' +import { State } from 'react-native-track-player' export default function Item({ item, @@ -19,7 +21,8 @@ export default function Item({ queueName: string navigation: NativeStackNavigationProp }): React.JSX.Element { - const { usePlayNewQueue } = usePlayerContext() + const { useStartPlayback } = usePlayerContext() + const { useLoadNewQueue } = useQueueContext() const { width } = useSafeAreaFrame() @@ -48,12 +51,20 @@ export default function Item({ } case 'Audio': { - usePlayNewQueue.mutate({ - track: item, - tracklist: [item], - queue: 'Search', - queuingType: QueuingType.FromSelection, - }) + useLoadNewQueue.mutate( + { + track: item, + tracklist: [item], + index: 0, + queue: 'Search', + queuingType: QueuingType.FromSelection, + }, + { + onSuccess: () => { + useStartPlayback.mutate() + }, + }, + ) break } } diff --git a/components/Global/components/track.tsx b/components/Global/components/track.tsx index 2f58b335..f300edfd 100644 --- a/components/Global/components/track.tsx +++ b/components/Global/components/track.tsx @@ -18,12 +18,14 @@ import { useNetworkContext } from '../../../components/Network/provider' import { useQuery } from '@tanstack/react-query' import { QueryKeys } from '../../../enums/query-keys' import { fetchMediaInfo } from '../../../api/queries/functions/media' +import { useQueueContext } from '../../../player/queue-provider' +import { State } from 'react-native-track-player' interface TrackProps { track: BaseItemDto navigation: NativeStackNavigationProp tracklist?: BaseItemDto[] | undefined - index?: number | undefined + index: number queue: Queue showArtwork?: boolean | undefined onPress?: () => void | undefined @@ -51,7 +53,8 @@ export default function Track({ onRemove, }: TrackProps): React.JSX.Element { const theme = useTheme() - const { nowPlaying, playQueue, usePlayNewQueue } = usePlayerContext() + const { nowPlaying, useStartPlayback } = usePlayerContext() + const { playQueue, useLoadNewQueue } = useQueueContext() const { downloadedTracks, networkStatus } = useNetworkContext() const isPlaying = nowPlaying?.item.Id === track.Id @@ -76,13 +79,20 @@ export default function Track({ if (onPress) { onPress() } else { - usePlayNewQueue.mutate({ - track, - index, - tracklist: tracklist ?? playQueue.map((track) => track.item), - queue, - queuingType: QueuingType.FromSelection, - }) + useLoadNewQueue.mutate( + { + track, + index, + tracklist: tracklist ?? playQueue.map((track) => track.item), + queue, + queuingType: QueuingType.FromSelection, + }, + { + onSuccess: () => { + useStartPlayback.mutate() + }, + }, + ) } }} onLongPress={ diff --git a/components/Home/helpers/frequent-tracks.tsx b/components/Home/helpers/frequent-tracks.tsx index 32680de5..1414fa9d 100644 --- a/components/Home/helpers/frequent-tracks.tsx +++ b/components/Home/helpers/frequent-tracks.tsx @@ -6,9 +6,10 @@ import HorizontalCardList from '../../../components/Global/components/horizontal import { ItemCard } from '../../../components/Global/components/item-card' import { QueuingType } from '../../../enums/queuing-type' import { trigger } from 'react-native-haptic-feedback' -import { usePlayerContext } from '../../../player/provider' import { H2 } from '../../../components/Global/helpers/text' import Icon from '../../../components/Global/helpers/icon' +import { useQueueContext } from '../../../player/queue-provider' +import { usePlayerContext } from '../../../player/provider' export default function FrequentlyPlayedTracks({ navigation, @@ -17,7 +18,8 @@ export default function FrequentlyPlayedTracks({ }): React.JSX.Element { const { frequentlyPlayed } = useHomeContext() - const { usePlayNewQueue } = usePlayerContext() + const { useStartPlayback } = usePlayerContext() + const { useLoadNewQueue } = useQueueContext() return ( @@ -48,13 +50,20 @@ export default function FrequentlyPlayedTracks({ subCaption={`${track.Artists?.join(', ')}`} squared onPress={() => { - usePlayNewQueue.mutate({ - track, - index, - tracklist: frequentlyPlayed ?? [track], - queue: 'On Repeat', - queuingType: QueuingType.FromSelection, - }) + useLoadNewQueue.mutate( + { + track, + index, + tracklist: frequentlyPlayed ?? [track], + queue: 'On Repeat', + queuingType: QueuingType.FromSelection, + }, + { + onSuccess: () => { + useStartPlayback.mutate() + }, + }, + ) }} onLongPress={() => { trigger('impactMedium') diff --git a/components/Home/helpers/recently-played.tsx b/components/Home/helpers/recently-played.tsx index 5f984371..74d18b87 100644 --- a/components/Home/helpers/recently-played.tsx +++ b/components/Home/helpers/recently-played.tsx @@ -9,15 +9,16 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { trigger } from 'react-native-haptic-feedback' import { QueuingType } from '../../../enums/queuing-type' import HorizontalCardList from '../../../components/Global/components/horizontal-list' -import { QueryKeys } from '../../../enums/query-keys' import Icon from '../../../components/Global/helpers/icon' +import { useQueueContext } from '../../../player/queue-provider' export default function RecentlyPlayed({ navigation, }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { nowPlaying, usePlayNewQueue } = usePlayerContext() + const { nowPlaying, useStartPlayback } = usePlayerContext() + const { useLoadNewQueue } = useQueueContext() const { recentTracks } = useHomeContext() return useMemo(() => { @@ -49,13 +50,20 @@ export default function RecentlyPlayed({ squared item={recentlyPlayedTrack} onPress={() => { - usePlayNewQueue.mutate({ - track: recentlyPlayedTrack, - index: index, - tracklist: recentTracks ?? [recentlyPlayedTrack], - queue: 'Recently Played', - queuingType: QueuingType.FromSelection, - }) + useLoadNewQueue.mutate( + { + track: recentlyPlayedTrack, + index: index, + tracklist: recentTracks ?? [recentlyPlayedTrack], + queue: 'Recently Played', + queuingType: QueuingType.FromSelection, + }, + { + onSuccess: () => { + useStartPlayback.mutate() + }, + }, + ) }} onLongPress={() => { trigger('impactMedium') diff --git a/components/Home/stack.tsx b/components/Home/stack.tsx index b7ec58a7..fe6e1e93 100644 --- a/components/Home/stack.tsx +++ b/components/Home/stack.tsx @@ -6,11 +6,9 @@ import { AlbumScreen } from '../Album' import { PlaylistScreen } from '../Playlist/screens' import { ProvidedHome } from './component' import DetailsScreen from '../ItemDetail/screen' -import AddPlaylist from '../Library/components/add-playlist' import ArtistsScreen from '../Artists/screen' import TracksScreen from '../Tracks/screen' import { ArtistScreen } from '../Artist' -import { OfflineList } from '../offlineList' const Stack = createNativeStackNavigator() diff --git a/components/ItemDetail/helpers/TrackOptions.tsx b/components/ItemDetail/helpers/TrackOptions.tsx index 8c6ea707..d9076ad9 100644 --- a/components/ItemDetail/helpers/TrackOptions.tsx +++ b/components/ItemDetail/helpers/TrackOptions.tsx @@ -1,4 +1,3 @@ -import { usePlayerContext } from '../../../player/provider' import { StackParamList } from '../../../components/types' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackNavigationProp } from '@react-navigation/native-stack' @@ -33,6 +32,7 @@ import { Image } from 'expo-image' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import Client from '../../../api/client' import { useNetworkContext } from '../../../components/Network/provider' +import { useQueueContext } from '../../../player/queue-provider' interface TrackOptionsProps { track: BaseItemDto @@ -67,7 +67,7 @@ export default function TrackOptions({ queryFn: () => fetchUserPlaylists(), }) - const { useAddToQueue } = usePlayerContext() + const { useAddToQueue } = useQueueContext() const { width } = useSafeAreaFrame() diff --git a/components/Player/helpers/controls.tsx b/components/Player/helpers/controls.tsx index f44b5071..e89785a7 100644 --- a/components/Player/helpers/controls.tsx +++ b/components/Player/helpers/controls.tsx @@ -5,11 +5,14 @@ import Icon from '../../../components/Global/helpers/icon' import { getProgress, seekBy, skipToNext } from 'react-native-track-player/lib/src/trackPlayer' import { usePlayerContext } from '../../../player/provider' import { useSafeAreaFrame } from 'react-native-safe-area-context' +import { useQueueContext } from '../../../player/queue-provider' export default function Controls(): React.JSX.Element { const { width } = useSafeAreaFrame() - const { usePrevious, useSeekTo } = usePlayerContext() + const { useSeekTo } = usePlayerContext() + + const { usePrevious } = useQueueContext() return ( @@ -22,13 +25,7 @@ export default function Controls(): React.JSX.Element { { - const progress = await getProgress() - if (progress.position < 3) usePrevious.mutate() - else { - useSeekTo.mutate(0) - } - }} + onPress={() => usePrevious.mutate()} large /> diff --git a/components/Player/helpers/scrubber.tsx b/components/Player/helpers/scrubber.tsx index 20938102..9367820f 100644 --- a/components/Player/helpers/scrubber.tsx +++ b/components/Player/helpers/scrubber.tsx @@ -9,12 +9,14 @@ import { usePlayerContext } from '../../../player/provider' import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes' import { UPDATE_INTERVAL } from '../../../player/config' import { ProgressMultiplier } from '../component.config' -import { useSharedValue } from 'react-native-reanimated' +import { useQueueContext } from '../../../player/queue-provider' const scrubGesture = Gesture.Pan() export default function Scrubber(): React.JSX.Element { - const { useSeekTo, useSkip, usePrevious } = usePlayerContext() + const { useSeekTo } = usePlayerContext() + + const { useSkip, usePrevious } = useQueueContext() const { width } = useSafeAreaFrame() diff --git a/components/Player/mini-player.tsx b/components/Player/mini-player.tsx index b6524cfa..4db3b388 100644 --- a/components/Player/mini-player.tsx +++ b/components/Player/mini-player.tsx @@ -11,6 +11,7 @@ import { TextTickerConfig } from './component.config' import { Image } from 'expo-image' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import Client from '../../api/client' +import { useQueueContext } from '../../player/queue-provider' export function Miniplayer({ navigation, @@ -19,7 +20,8 @@ export function Miniplayer({ }): React.JSX.Element { const theme = useTheme() - const { nowPlaying, useSkip } = usePlayerContext() + const { nowPlaying } = usePlayerContext() + const { useSkip } = useQueueContext() return ( }): React.JSX.Element { - const { nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, queue } = usePlayerContext() + const { nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying } = usePlayerContext() + + const { queueRef } = useQueueContext() const { width } = useSafeAreaFrame() @@ -57,9 +60,9 @@ export default function PlayerScreen({ > { // If the Queue is a BaseItemDto, display the name of it - typeof queue === 'object' - ? (queue as BaseItemDto).Name ?? 'Untitled' - : queue + typeof queueRef === 'object' + ? queueRef.Name ?? 'Untitled' + : queueRef } @@ -91,7 +94,7 @@ export default function PlayerScreen({ ) - }, [nowPlaying, queue])} + }, [nowPlaying, queueRef])} {/** Memoize TextTickers otherwise they won't animate due to the progress being updated in the PlayerContext */} diff --git a/components/Player/screens/queue.tsx b/components/Player/screens/queue.tsx index 392c8f63..7ea7a9ee 100644 --- a/components/Player/screens/queue.tsx +++ b/components/Player/screens/queue.tsx @@ -7,6 +7,8 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context' import DraggableFlatList from 'react-native-draggable-flatlist' import { trigger } from 'react-native-haptic-feedback' import { Separator } from 'tamagui' +import { useQueueContext } from '../../../player/queue-provider' +import Animated from 'react-native-reanimated' export default function Queue({ navigation, @@ -14,15 +16,16 @@ export default function Queue({ navigation: NativeStackNavigationProp }): React.JSX.Element { const { width } = useSafeAreaFrame() + const { nowPlaying } = usePlayerContext() + const { playQueue, - queue, - useClearQueue, + queueRef, + useRemoveUpcomingTracks, useRemoveFromQueue, useReorderQueue, useSkip, - nowPlaying, - } = usePlayerContext() + } = useQueueContext() navigation.setOptions({ headerRight: () => { @@ -30,7 +33,7 @@ export default function Queue({ { - useClearQueue.mutate() + useRemoveUpcomingTracks.mutate() }} /> ) @@ -42,50 +45,52 @@ export default function Queue({ ) return ( - ({ - length: width / 9, - offset: (width / 9) * index, - index, - })} - initialScrollIndex={scrollIndex !== -1 ? scrollIndex : 0} - ItemSeparatorComponent={() => } - // itemEnteringAnimation={FadeIn} - // itemExitingAnimation={FadeOut} - // itemLayoutAnimation={SequencedTransition} - keyExtractor={({ item }, index) => { - return `${index}-${item.Id}` - }} - numColumns={1} - onDragEnd={({ data, from, to }) => { - useReorderQueue.mutate({ newOrder: data, from, to }) - }} - renderItem={({ item: queueItem, getIndex, drag, isActive }) => ( - { - useSkip.mutate(getIndex()) - }} - onLongPress={() => { - trigger('impactLight') - drag() - }} - isNested - showRemove - onRemove={() => { - if (getIndex()) useRemoveFromQueue.mutate(getIndex()!) - }} - /> - )} - /> + + ({ + length: width / 9, + offset: (width / 9) * index, + index, + })} + initialScrollIndex={scrollIndex !== -1 ? scrollIndex : 0} + ItemSeparatorComponent={() => } + // itemEnteringAnimation={FadeIn} + // itemExitingAnimation={FadeOut} + // itemLayoutAnimation={SequencedTransition} + keyExtractor={({ item }, index) => { + return `${index}-${item.Id}` + }} + numColumns={1} + onDragEnd={({ data, from, to }) => { + useReorderQueue.mutate({ newOrder: data, from, to }) + }} + renderItem={({ item: queueItem, getIndex, drag, isActive }) => ( + { + useSkip.mutate(getIndex() ?? 0) + }} + onLongPress={() => { + trigger('impactLight') + drag() + }} + isNested + showRemove + onRemove={() => { + if (getIndex()) useRemoveFromQueue.mutate(getIndex()!) + }} + /> + )} + /> + ) } diff --git a/components/Settings/helpers/sign-out.tsx b/components/Settings/helpers/sign-out.tsx index d0862dab..fedba2b5 100644 --- a/components/Settings/helpers/sign-out.tsx +++ b/components/Settings/helpers/sign-out.tsx @@ -3,7 +3,7 @@ import Button from '../../Global/helpers/button' import Client from '../../../api/client' import { useJellifyContext } from '../../../components/provider' import TrackPlayer from 'react-native-track-player' -import { StackParamList } from '@/components/types' +import { StackParamList } from '../../../components/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' @@ -17,7 +17,6 @@ export default function SignOut(): React.JSX.Element { setLoggedIn(false) Client.signOut() TrackPlayer.reset() - // navigation.navigate('Offline') }} > Sign Out diff --git a/components/jellify.tsx b/components/jellify.tsx index 2d350624..98c3a859 100644 --- a/components/jellify.tsx +++ b/components/jellify.tsx @@ -10,6 +10,7 @@ import { JellifyProvider, useJellifyContext } from './provider' import { ToastProvider } from '@tamagui/toast' import { JellifyUserDataProvider } from './user-data-provider' import { NetworkContextProvider } from './Network/provider' +import { QueueProvider } from '../player/queue-provider' export default function Jellify(): React.JSX.Element { return ( @@ -30,9 +31,11 @@ function App(): React.JSX.Element { return loggedIn ? ( - - - + + + + + ) : ( diff --git a/components/navigation.tsx b/components/navigation.tsx index 5550ed20..a64689c1 100644 --- a/components/navigation.tsx +++ b/components/navigation.tsx @@ -3,8 +3,6 @@ import Player from './Player/stack' import { Tabs } from './tabs' import { StackParamList } from './types' import { useTheme } from 'tamagui' -import DetailsScreen from './ItemDetail/screen' -import { OfflineList } from './offlineList' export default function Navigation(): React.JSX.Element { const RootStack = createNativeStackNavigator() @@ -29,14 +27,6 @@ export default function Navigation(): React.JSX.Element { presentation: 'modal', }} /> - ) } diff --git a/components/offlineList/index.tsx b/components/offlineList/index.tsx deleted file mode 100644 index c48b22c0..00000000 --- a/components/offlineList/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { JellifyTrack } from '@/types/JellifyTrack' -import React from 'react' -import { FlatList, Pressable } from 'react-native' -import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated' -import { Image, Text, View, XStack, YStack } from 'tamagui' -import { getAudioCache } from '../Network/offlineModeUtils' -import { usePlayerContext } from '../../player/provider' -import { useNavigation } from '@react-navigation/native' - -interface Props { - tracks: JellifyTrack[] - onPress: (track: JellifyTrack) => void -} - -export function OfflineList() { - const tracks = getAudioCache() - const navigation = useNavigation() - const { usePlayNewQueueOffline } = usePlayerContext() - const onPress = (track: JellifyTrack) => { - console.log('onPress', track) - usePlayNewQueueOffline.mutate({ trackListOffline: track }) - navigation.navigate('Player') - } - - const renderItem = ({ item }: { item: JellifyTrack }) => ( - - onPress(item)}> - - - - - {item.title || 'Unknown Title'} - - - {item.artist || 'Unknown Artist'} - - - - - - ) - - return ( - item.item.Id as string} - renderItem={renderItem} - contentContainerStyle={{ - padding: 16, - paddingBottom: 100, - }} - showsVerticalScrollIndicator={false} - /> - ) -} diff --git a/components/provider.tsx b/components/provider.tsx index dc4049c3..550ba0d6 100644 --- a/components/provider.tsx +++ b/components/provider.tsx @@ -29,7 +29,6 @@ const JellifyContextInitializer = () => { if (loggedIn) { CarPlay.setRootTemplate(CarPlayNavigation) - CarPlay.pushTemplate(CarPlayNowPlaying) if (Platform.OS === 'ios') { CarPlay.enableNowPlaying(true) // https://github.com/birkir/react-native-carplay/issues/185 diff --git a/ios/Jellify.xcodeproj/project.pbxproj b/ios/Jellify.xcodeproj/project.pbxproj index 77cf0907..a08a90c2 100644 --- a/ios/Jellify.xcodeproj/project.pbxproj +++ b/ios/Jellify.xcodeproj/project.pbxproj @@ -57,7 +57,6 @@ CF6B19F92D55121E002464CB /* icon_tinted_20pt_3x.png in Resources */ = {isa = PBXBuildFile; fileRef = CF6B19EB2D55121E002464CB /* icon_tinted_20pt_3x.png */; }; CF6B19FA2D55121E002464CB /* icon_tinted_60pt_3x.png in Resources */ = {isa = PBXBuildFile; fileRef = CF6B19F12D55121E002464CB /* icon_tinted_60pt_3x.png */; }; CF6B19FB2D55121E002464CB /* icon_tinted_40pt_2x.png in Resources */ = {isa = PBXBuildFile; fileRef = CF6B19EE2D55121E002464CB /* icon_tinted_40pt_2x.png */; }; - CF71790B2CBC486C0021BCA3 /* dummy-rntp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */; }; CF98CA472D3E99E0003D88B7 /* CarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA452D3E99DF003D88B7 /* CarScene.swift */; }; CF98CA482D3E99E0003D88B7 /* PhoneScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA462D3E99DF003D88B7 /* PhoneScene.swift */; }; CF98CA492D3E99E0003D88B7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA442D3E99DF003D88B7 /* AppDelegate.swift */; }; @@ -148,7 +147,6 @@ CF6B19F12D55121E002464CB /* icon_tinted_60pt_3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon_tinted_60pt_3x.png; sourceTree = ""; }; CF6B19F22D55121E002464CB /* icon_tinted_original.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon_tinted_original.png; sourceTree = ""; }; CF7179092CBC486C0021BCA3 /* Jellify-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Jellify-Bridging-Header.h"; sourceTree = ""; }; - CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "dummy-rntp.swift"; sourceTree = ""; }; CF98CA442D3E99DF003D88B7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; CF98CA452D3E99DF003D88B7 /* CarScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarScene.swift; sourceTree = ""; }; CF98CA462D3E99DF003D88B7 /* PhoneScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneScene.swift; sourceTree = ""; }; @@ -204,7 +202,6 @@ 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, F757EB73303E0AC21EF34F64 /* PrivacyInfo.xcprivacy */, - CF71790A2CBC486C0021BCA3 /* dummy-rntp.swift */, CF7179092CBC486C0021BCA3 /* Jellify-Bridging-Header.h */, ); name = Jellify; @@ -522,10 +519,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n"; @@ -539,10 +540,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks.sh\"\n"; @@ -607,7 +612,6 @@ CF98CA472D3E99E0003D88B7 /* CarScene.swift in Sources */, CF98CA482D3E99E0003D88B7 /* PhoneScene.swift in Sources */, CF98CA492D3E99E0003D88B7 /* AppDelegate.swift in Sources */, - CF71790B2CBC486C0021BCA3 /* dummy-rntp.swift in Sources */, 66BC9C5D1B536CD0799EEC89 /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -843,10 +847,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -932,10 +933,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/ios/dummy-rntp.swift b/ios/dummy-rntp.swift deleted file mode 100644 index 2d7d87db..00000000 --- a/ios/dummy-rntp.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// dummy-rntp.swift -// Jellify -// -// Created by Violet Caulfield on 10/13/24. -// - -import Foundation diff --git a/package.json b/package.json index dfa0a5ea..23171814 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "invert-color": "^2.0.0", "jest-expo": "^52.0.6", "lodash": "^4.17.21", - "npm-bundle": "^3.0.3", "patch-package": "^8.0.0", "react": "18.3.1", "react-freeze": "^1.0.4", diff --git a/player/helpers/queue.ts b/player/helpers/queue.ts index 34400653..48733628 100644 --- a/player/helpers/queue.ts +++ b/player/helpers/queue.ts @@ -23,7 +23,7 @@ export function buildNewQueue( return newQueue } -export function networkStatusCheck( +export function filterTracksOnNetworkStatus( networkStatus: networkStatusTypes | undefined, queuedItems: BaseItemDto[], downloadedTracks: JellifyDownload[], diff --git a/player/interfaces.ts b/player/interfaces.ts index 032f8177..1c471709 100644 --- a/player/interfaces.ts +++ b/player/interfaces.ts @@ -9,7 +9,6 @@ export interface QueueMutation { tracklist: BaseItemDto[] queue: Queue queuingType?: QueuingType | undefined - trackListOffline?: JellifyTrack } export interface AddToQueueMutation { diff --git a/player/provider.tsx b/player/provider.tsx index a14cd6b1..ad25873d 100644 --- a/player/provider.tsx +++ b/player/provider.tsx @@ -2,7 +2,6 @@ import { createContext, ReactNode, SetStateAction, useContext, useEffect, useSta import { JellifyTrack } from '../types/JellifyTrack' import { storage } from '../constants/storage' import { MMKVStorageKeys } from '../enums/mmkv-storage-keys' -import { findPlayNextIndexStart, findPlayQueueIndexStart } from './helpers/index' import TrackPlayer, { Event, State, @@ -13,58 +12,27 @@ import { isEqual, isUndefined } from 'lodash' import { handlePlaybackProgressUpdated, handlePlaybackState } from './handlers' import { useUpdateOptions } from '../player/hooks' import { useMutation, UseMutationResult } from '@tanstack/react-query' -import { mapDtoToTrack } from '../helpers/mappings' -import { QueuingType } from '../enums/queuing-type' import { trigger } from 'react-native-haptic-feedback' -import { - getActiveTrackIndex, - getQueue, - pause, - seekTo, - skip, - skipToNext, - skipToPrevious, -} from 'react-native-track-player/lib/src/trackPlayer' +import { pause, seekTo } from 'react-native-track-player/lib/src/trackPlayer' import { convertRunTimeTicksToSeconds } from '../helpers/runtimeticks' import Client from '../api/client' -import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces' -import { Section } from '../components/Player/types' -import { Queue } from './types/queue-item' -import * as Burnt from 'burnt' -import { markItemPlayed } from '../api/mutations/functions/item' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api' -import { SKIP_TO_PREVIOUS_THRESHOLD } from './config' import { useNetworkContext } from '../components/Network/provider' -import { networkStatusCheck } from './helpers/queue' -import { QueueProvider } from './queue-provider' +import { useQueueContext } from './queue-provider' interface PlayerContext { initialized: boolean nowPlayingIsFavorite: boolean setNowPlayingIsFavorite: React.Dispatch> nowPlaying: JellifyTrack | undefined - playQueue: JellifyTrack[] - queue: Queue - getQueueSectionData: () => Section[] - useAddToQueue: UseMutationResult - useClearQueue: UseMutationResult - useRemoveFromQueue: UseMutationResult - useReorderQueue: UseMutationResult - useTogglePlayback: UseMutationResult + useStartPlayback: UseMutationResult + useTogglePlayback: UseMutationResult useSeekTo: UseMutationResult - useSkip: UseMutationResult - usePrevious: UseMutationResult - usePlayNewQueue: UseMutationResult - playbackState: State | undefined - setNowPlaying: (track: JellifyTrack) => void } const PlayerContextInitializer = () => { const nowPlayingJson = storage.getString(MMKVStorageKeys.NowPlaying) - const playQueueJson = storage.getString(MMKVStorageKeys.PlayQueue) - const queueJson = storage.getString(MMKVStorageKeys.Queue) const playStateApi = getPlaystateApi(Client.api!) @@ -76,146 +44,27 @@ const PlayerContextInitializer = () => { nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined, ) - const [isSkipping, setIsSkipping] = useState(false) + const { playQueue, currentIndex } = useQueueContext() - const [playQueue, setPlayQueue] = useState( - playQueueJson ? JSON.parse(playQueueJson) : [], - ) - - const [queue, setQueue] = useState(queueJson ? JSON.parse(queueJson) : 'Queue') //#endregion State //#region Functions - const play = async (index?: number | undefined) => { - if (index && index > 0) { - TrackPlayer.skip(index) - } - - TrackPlayer.play() + const play = async () => { + await TrackPlayer.play() } - const getQueueSectionData: () => Section[] = () => { - return Object.keys(QueuingType).map((type) => { - return { - title: type, - data: playQueue.filter((track) => track.QueuingType === type), - } as Section - }) - } - - /** - * Takes a {@link BaseItemDto} of a track on Jellyfin, and updates it's - * position in the {@link queue} - * - * - * @param track The Jellyfin track object to update and replace in the queue - */ - const replaceQueueItem: (track: BaseItemDto) => Promise = async (track: BaseItemDto) => { - const queue = (await TrackPlayer.getQueue()) as JellifyTrack[] - - const queueItemIndex = queue.findIndex((queuedTrack) => queuedTrack.item.Id === track.Id!) - - // Update queued item at index if found, else silently do nothing - if (queueItemIndex !== -1) { - const queueItem = queue[queueItemIndex] - - TrackPlayer.remove([queueItemIndex]).then(() => { - TrackPlayer.add( - mapDtoToTrack(track, downloadedTracks ?? [], queueItem.QueuingType), - queueItemIndex, - ) - }) - } - } - - const resetQueue = async (hideMiniplayer?: boolean | undefined) => { - console.debug('Clearing queue') - await TrackPlayer.setQueue([]) - setPlayQueue([]) - } - - const addToQueue = async (tracks: JellifyTrack[]) => { - const insertIndex = await findPlayQueueIndexStart(playQueue) - console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`) - - await TrackPlayer.add(tracks, insertIndex) - - setPlayQueue((await getQueue()) as JellifyTrack[]) - - console.debug(`Queue has ${playQueue.length} tracks`) - } - - const addToNext = async (tracks: JellifyTrack[]) => { - const insertIndex = await findPlayNextIndexStart(playQueue) - - console.debug(`Adding ${tracks.length} to queue at index ${insertIndex}`) - - await TrackPlayer.add(tracks, insertIndex) - - setPlayQueue((await getQueue()) as JellifyTrack[]) - } //#endregion Functions //#region Hooks - const useAddToQueue = useMutation({ - mutationFn: async (mutation: AddToQueueMutation) => { - trigger('impactLight') - - if (mutation.queuingType === QueuingType.PlayingNext) - return addToNext([ - mapDtoToTrack(mutation.track, downloadedTracks ?? [], mutation.queuingType), - ]) - else - return addToQueue([ - mapDtoToTrack(mutation.track, downloadedTracks ?? [], mutation.queuingType), - ]) - }, - onSuccess: (data, { queuingType }) => { - trigger('notificationSuccess') - - Burnt.alert({ - title: queuingType === QueuingType.PlayingNext ? 'Playing next' : 'Added to queue', - duration: 1, - preset: 'done', - }) - }, - onError: () => { - trigger('notificationError') - }, - }) - - const useRemoveFromQueue = useMutation({ - mutationFn: async (index: number) => { - trigger('impactMedium') - - await TrackPlayer.remove([index]) - - setPlayQueue((await TrackPlayer.getQueue()) as JellifyTrack[]) - }, - }) - - const useClearQueue = useMutation({ - mutationFn: async () => { - trigger('effectDoubleClick') - - await TrackPlayer.removeUpcomingTracks() - - setPlayQueue((await getQueue()) as JellifyTrack[]) - }, - }) - - const useReorderQueue = useMutation({ - mutationFn: async (mutation: QueueOrderMutation) => { - setPlayQueue(mutation.newOrder) - await TrackPlayer.move(mutation.from, mutation.to) - }, + const useStartPlayback = useMutation({ + mutationFn: play, }) const useTogglePlayback = useMutation({ - mutationFn: (index?: number | undefined) => { + mutationFn: () => { trigger('impactMedium') if (playbackState === State.Playing) return pause() - else return play(index) + else return play() }, }) @@ -231,102 +80,6 @@ const PlayerContextInitializer = () => { }) }, }) - - const useSkip = useMutation({ - mutationFn: async (index?: number | undefined) => { - trigger('impactMedium') - - // Handle if this is the last track in the queue - if (playQueue.length - 1 === (await getActiveTrackIndex())) return - else { - if (!isUndefined(index)) { - setIsSkipping(true) - setNowPlaying(playQueue[index]) - await skip(index) - setIsSkipping(false) - } else { - const nowPlayingIndex = playQueue.findIndex( - (track) => track.item.Id === nowPlaying!.item.Id, - ) - setNowPlaying(playQueue[nowPlayingIndex + 1]) - await skipToNext() - } - } - }, - }) - - const usePrevious = useMutation({ - mutationFn: async () => { - trigger('impactMedium') - - const nowPlayingIndex = playQueue.findIndex( - (track) => track.item.Id === nowPlaying!.item.Id, - ) - - const { position } = await TrackPlayer.getProgress() - - if (nowPlayingIndex > 0 && position < SKIP_TO_PREVIOUS_THRESHOLD) { - setNowPlaying(playQueue[nowPlayingIndex - 1]) - await skipToPrevious() - } else await seekTo(0) - }, - }) - - /** - * A mutation hook that adds tracks to the play queue - * - * Respects the network status of the app - if we are - * online, tracks are always added to the queue for streaming - * - if we are *offline* then only tracks that are downloaded - * will be added to the queue - */ - const usePlayNewQueue = useMutation({ - mutationFn: async (mutation: QueueMutation) => { - trigger('effectDoubleClick') - - setIsSkipping(true) - - // Optimistically set now playing - - setNowPlaying( - mapDtoToTrack( - mutation.tracklist[mutation.index ?? 0], - downloadedTracks ?? [], - QueuingType.FromSelection, - ), - ) - - await resetQueue(false) - - const queueItems = networkStatusCheck( - networkStatus, - mutation.tracklist, - downloadedTracks ?? [], - ) - - console.debug(`Adding ${queueItems.length} to the queue`) - - const queueTracks = queueItems.map((queueItem) => { - return mapDtoToTrack(queueItem, downloadedTracks ?? [], QueuingType.FromSelection) - }) - - console.debug(`Slotting ${queueTracks.length} track(s)`) - - await addToQueue(queueTracks) - - setQueue(mutation.queue) - }, - onSuccess: async (data, mutation: QueueMutation) => { - setIsSkipping(false) - await play(mutation.index) - - if (typeof mutation.queue === 'object') await markItemPlayed(queue as BaseItemDto) - }, - onError: async () => { - setIsSkipping(false) - setNowPlaying((await TrackPlayer.getActiveTrack()) as JellifyTrack) - }, - }) //#endregion //#region RNTP Setup @@ -384,7 +137,7 @@ const PlayerContextInitializer = () => { } case Event.PlaybackActiveTrackChanged: { - if (initialized && !isSkipping) { + if (initialized) { const activeTrack = (await TrackPlayer.getActiveTrack()) as | JellifyTrack | undefined @@ -419,14 +172,6 @@ const PlayerContextInitializer = () => { //#endregion RNTP Setup //#region useEffects - useEffect(() => { - storage.set(MMKVStorageKeys.Queue, JSON.stringify(queue)) - }, [queue]) - - useEffect(() => { - if (initialized && playQueue) - storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(playQueue)) - }, [playQueue]) useEffect(() => { if (initialized && nowPlaying) @@ -435,15 +180,17 @@ const PlayerContextInitializer = () => { useEffect(() => { if (!initialized && playQueue.length > 0 && nowPlaying) { - TrackPlayer.setQueue(playQueue).then(() => { - TrackPlayer.skip( - playQueue.findIndex((track) => track.item.Id! === nowPlaying.item.Id!), - ) - }) + TrackPlayer.skip(playQueue.findIndex((track) => track.item.Id! === nowPlaying.item.Id!)) } setInitialized(true) }, [playQueue, nowPlaying]) + + useEffect(() => { + if (currentIndex > -1 && playQueue.length > currentIndex) + console.debug(`Setting now playing to queue index ${currentIndex}`) + setNowPlaying(playQueue[currentIndex]) + }, [currentIndex]) //#endregion useEffects //#region return @@ -452,19 +199,9 @@ const PlayerContextInitializer = () => { nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, - playQueue, - queue, - getQueueSectionData, - useAddToQueue, - useClearQueue, - setNowPlaying, - useReorderQueue, - useRemoveFromQueue, + useStartPlayback, useTogglePlayback, useSeekTo, - useSkip, - usePrevious, - usePlayNewQueue, playbackState, } //#endregion return @@ -476,65 +213,7 @@ export const PlayerContext = createContext({ nowPlayingIsFavorite: false, setNowPlayingIsFavorite: () => {}, nowPlaying: undefined, - setNowPlaying: () => {}, - playQueue: [], - queue: 'Recently Played', - getQueueSectionData: () => [], - useAddToQueue: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - useClearQueue: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - useRemoveFromQueue: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - useReorderQueue: { + useStartPlayback: { mutate: () => {}, mutateAsync: async () => {}, data: undefined, @@ -588,61 +267,6 @@ export const PlayerContext = createContext({ failureReason: null, submittedAt: 0, }, - useSkip: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - usePrevious: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - usePlayNewQueue: { - mutate: () => {}, - mutateAsync: async () => {}, - data: undefined, - error: null, - variables: undefined, - isError: false, - isIdle: true, - isPaused: false, - isPending: false, - isSuccess: false, - status: 'idle', - reset: () => {}, - context: {}, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }, - playbackState: undefined, }) //#endregion Create PlayerContext @@ -651,55 +275,9 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS }: { children: ReactNode }) => { - const { - initialized, - nowPlayingIsFavorite, - setNowPlayingIsFavorite, - nowPlaying, - playQueue, - queue, - getQueueSectionData, - useAddToQueue, - useClearQueue, - useRemoveFromQueue, - useReorderQueue, - useTogglePlayback, - useSeekTo, - useSkip, - usePrevious, - setNowPlaying, - usePlayNewQueue, - playbackState, - } = PlayerContextInitializer() + const context = PlayerContextInitializer() - return ( - - - {children} - - - ) + return {children} } export const usePlayerContext = () => useContext(PlayerContext) diff --git a/player/queue-provider.tsx b/player/queue-provider.tsx index d39a5b98..7359c121 100644 --- a/player/queue-provider.tsx +++ b/player/queue-provider.tsx @@ -1,20 +1,448 @@ -import React, { ReactNode } from 'react' +import React, { ReactNode, useContext, useEffect, useState } from 'react' import { createContext } from 'react' +import { Queue } from './types/queue-item' +import { Section } from '../components/Player/types' +import { useMutation, UseMutationResult } from '@tanstack/react-query' +import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces' +import { storage } from '../constants/storage' +import { MMKVStorageKeys } from '../enums/mmkv-storage-keys' +import { JellifyTrack } from '../types/JellifyTrack' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { mapDtoToTrack } from '../helpers/mappings' +import { useNetworkContext } from '../components/Network/provider' +import { QueuingType } from '../enums/queuing-type' +import TrackPlayer from 'react-native-track-player' +import { findPlayQueueIndexStart } from './helpers' +import { getQueue, seekTo } from 'react-native-track-player/lib/src/trackPlayer' +import { trigger } from 'react-native-haptic-feedback' +import * as Burnt from 'burnt' +import { markItemPlayed } from '../api/mutations/functions/item' +import { filterTracksOnNetworkStatus } from './helpers/queue' +import { SKIP_TO_PREVIOUS_THRESHOLD } from './config' -interface QueueContext {} - -const QueueContextInitailizer = () => { - return {} +interface QueueContext { + queueRef: Queue + playQueue: JellifyTrack[] + currentIndex: number + fetchQueueSectionData: () => Section[] + useAddToQueue: UseMutationResult + useLoadNewQueue: UseMutationResult + useRemoveUpcomingTracks: UseMutationResult + useRemoveFromQueue: UseMutationResult + useReorderQueue: UseMutationResult + useSkip: UseMutationResult + usePrevious: UseMutationResult } -export const QueueContext = createContext({}) +const QueueContextInitailizer = () => { + const queueRefJson = storage.getString(MMKVStorageKeys.Queue) + const playQueueJson = storage.getString(MMKVStorageKeys.PlayQueue) + + const queueRefInit = queueRefJson ? JSON.parse(queueRefJson) : 'Recently Played' + const playQueueInit = playQueueJson ? JSON.parse(playQueueJson) : [] + + const [queueRef, setQueueRef] = useState(queueRefInit) + const [playQueue, setPlayQueue] = useState(playQueueInit) + + const [updateRntp, setUpdateRntp] = useState(true) + + const [currentIndex, setCurrentIndex] = useState(-1) + + const { downloadedTracks, networkStatus } = useNetworkContext() + + //#region Functions + const fetchQueueSectionData: () => Section[] = () => { + return Object.keys(QueuingType).map((type) => { + return { + title: type, + data: playQueue.filter((track) => track.QueuingType === type), + } as Section + }) + } + + const resetQueue = () => { + console.debug(`Clearing queue of ${playQueue.length}`) + setPlayQueue([]) + } + + /** + * Takes a {@link BaseItemDto} of a track on Jellyfin, and updates it's + * position in the {@link queue} + * + * + * @param track The Jellyfin track object to update and replace in the queue + */ + const replaceQueueItem: (track: BaseItemDto) => Promise = async (track: BaseItemDto) => { + const queue = (await TrackPlayer.getQueue()) as JellifyTrack[] + + const queueItemIndex = queue.findIndex((queuedTrack) => queuedTrack.item.Id === track.Id!) + + // Update queued item at index if found, else silently do nothing + if (queueItemIndex !== -1) { + const queueItem = queue[queueItemIndex] + + TrackPlayer.remove([queueItemIndex]).then(() => { + TrackPlayer.add( + mapDtoToTrack(track, downloadedTracks ?? [], queueItem.QueuingType), + queueItemIndex, + ) + }) + } + } + + const loadQueue = async ( + audioItems: BaseItemDto[], + queuingRef: Queue, + startIndex: number = 1, + ) => { + console.debug(`Queuing ${audioItems.length} items`) + + const availableAudioItems = filterTracksOnNetworkStatus( + networkStatus, + audioItems, + downloadedTracks ?? [], + ) + + console.debug( + `Filtered out ${ + audioItems.length - availableAudioItems.length + } due to network status being ${networkStatus}`, + ) + + const queue = availableAudioItems.map((item) => + mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.FromSelection), + ) + + setPlayQueue(queue) + setQueueRef(queuingRef) + setCurrentIndex(startIndex) + + console.debug(`Queued ${queue.length} tracks, starting at ${startIndex}`) + } + + const playNextInQueue = async (item: BaseItemDto) => { + console.debug(`Playing item next in queue`) + + const playNextTrack = mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.PlayingNext) + + setUpdateRntp(false) + TrackPlayer.add([playNextTrack], currentIndex + 1) + setPlayQueue([ + ...playQueue.slice(0, currentIndex + 1), + playNextTrack, + ...playQueue.slice(currentIndex + 1), + ]) + } + + const playInQueue = async (items: BaseItemDto[]) => { + const insertIndex = await findPlayQueueIndexStart(playQueue) + console.debug(`Adding ${items.length} to queue at index ${insertIndex}`) + + setUpdateRntp(false) + await TrackPlayer.add( + items.map((item) => + mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.DirectlyQueued), + ), + insertIndex, + ) + + setPlayQueue((await getQueue()) as JellifyTrack[]) + + console.debug(`Queue has ${playQueue.length} tracks`) + } + //#endregion Functions + + //#region Hooks + const useAddToQueue = useMutation({ + mutationFn: ({ track, queuingType }: AddToQueueMutation) => { + return queuingType === QueuingType.PlayingNext + ? playNextInQueue(track) + : playInQueue([track]) + }, + onSuccess: (data, { queuingType }) => { + trigger('notificationSuccess') + + Burnt.alert({ + title: queuingType === QueuingType.PlayingNext ? 'Playing next' : 'Added to queue', + duration: 0.5, + preset: 'done', + }) + }, + onError: () => { + trigger('notificationError') + }, + }) + + const useLoadNewQueue = useMutation({ + mutationFn: async ({ index, track, tracklist, queuingType, queue }: QueueMutation) => + loadQueue(tracklist, queue, index), + onSuccess: async (data, { queue }: QueueMutation) => { + trigger('notificationSuccess') + + if (typeof queue === 'object') await markItemPlayed(queue) + }, + }) + + const useRemoveFromQueue = useMutation({ + mutationFn: async (index: number) => { + trigger('impactMedium') + + setUpdateRntp(false) + TrackPlayer.remove([index]) + setPlayQueue([ + ...playQueue.slice(0, index), + ...playQueue.slice(index + 1, playQueue.length - 1), + ]) + }, + }) + + /** + * + */ + const useRemoveUpcomingTracks = useMutation({ + mutationFn: async () => { + setUpdateRntp(false) + TrackPlayer.removeUpcomingTracks() + setPlayQueue([...playQueue.slice(0, currentIndex + 1)]) + }, + onSuccess: () => { + trigger('notificationSuccess') + }, + }) + + const useReorderQueue = useMutation({ + mutationFn: async ({ from, to, newOrder }: QueueOrderMutation) => { + setUpdateRntp(false) + TrackPlayer.move(from, to) + setPlayQueue(newOrder) + }, + onSuccess: () => { + trigger('notificationSuccess') + }, + }) + + const useSkip = useMutation({ + mutationFn: async (index?: number | undefined) => { + trigger('impactMedium') + + console.debug( + `Skip to next triggered. Index is ${`using ${ + index ? index : currentIndex + } as index ${index ? 'since it was provided' : ''}`}`, + ) + + if (index && index < playQueue.length - 1) setCurrentIndex(index) + else if (playQueue.length - 1 > currentIndex) setCurrentIndex(currentIndex + 1) + }, + }) + + const usePrevious = useMutation({ + mutationFn: async () => { + trigger('impactMedium') + + const { position } = await TrackPlayer.getProgress() + + console.debug(`Skip to previous triggered. Index is ${currentIndex}`) + + if (currentIndex > 0 && position < SKIP_TO_PREVIOUS_THRESHOLD) { + setCurrentIndex(currentIndex - 1) + } else await seekTo(0) + }, + }) + + const useUpdateRntpQueue = useMutation({ + mutationFn: async () => { + if (updateRntp) await TrackPlayer.setQueue(playQueue) + + setUpdateRntp(true) + }, + }) + + const useUpdateRntpIndex = useMutation({ + mutationFn: async (index: number) => { + await TrackPlayer.skip(index) + }, + }) + + //#endregion Hooks + + //#region useEffect(s) + /** + * Update RNTP Queue when our queue + * is updated + */ + useEffect(() => { + useUpdateRntpQueue.mutate() + storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(playQueue)) + }, [playQueue]) + + useEffect(() => { + storage.set(MMKVStorageKeys.Queue, JSON.stringify(queueRef)) + }, [queueRef]) + + useEffect(() => { + useUpdateRntpIndex.mutate(currentIndex) + }, [currentIndex]) + + //#endregion useEffect(s) + + return { + queueRef, + playQueue, + currentIndex, + setCurrentIndex, + fetchQueueSectionData, + useAddToQueue, + useLoadNewQueue, + useRemoveFromQueue, + useRemoveUpcomingTracks, + useReorderQueue, + useSkip, + usePrevious, + } +} + +export const QueueContext = createContext({ + queueRef: 'Recently Played', + playQueue: [], + currentIndex: -1, + fetchQueueSectionData: () => [], + useAddToQueue: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + useLoadNewQueue: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + useSkip: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + usePrevious: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + useRemoveFromQueue: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + useRemoveUpcomingTracks: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, + useReorderQueue: { + mutate: () => {}, + mutateAsync: async () => {}, + data: undefined, + error: null, + variables: undefined, + isError: false, + isIdle: true, + isPaused: false, + isPending: false, + isSuccess: false, + status: 'idle', + reset: () => {}, + context: {}, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }, +}) export const QueueProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ children, }: { children: ReactNode }) => { - const urmo = QueueContextInitailizer() + const context = QueueContextInitailizer() - return {children} + return {children} } + +export const useQueueContext = () => useContext(QueueContext) diff --git a/yarn.lock b/yarn.lock index 13b15d6e..cee04f00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6539,17 +6539,6 @@ glob@^10.2.2, glob@^10.3.10, glob@^10.4.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^6.0.1: - version "6.0.4" - resolved "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz" - integrity sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A== - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" @@ -6846,11 +6835,6 @@ ini@~1.3.0: resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -insync@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/insync/-/insync-2.1.1.tgz" - integrity sha512-UzUhOZFpCMM22Xlig9iUPqalf8n7c4eYScamce1C+jN3ad8FtmVm42ryMwVq0hAxHbwUhWFhPvTFQQpFdDUKkw== - internal-ip@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz" @@ -8478,13 +8462,6 @@ mimic-function@^5.0.0: resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== -"minimatch@2 || 3", minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - minimatch@^10.0.1: version "10.0.1" resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz" @@ -8492,6 +8469,13 @@ minimatch@^10.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + minimatch@^8.0.2: version "8.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz" @@ -8610,11 +8594,6 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -ncp@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz" - integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== - negotiator@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" @@ -8684,17 +8663,6 @@ normalize-path@^3.0.0: resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-bundle@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/npm-bundle/-/npm-bundle-3.0.3.tgz" - integrity sha512-fHF7FR32YNgjqi0MQMLnE78Ff9/wYd4/7/Cke3dLLi2QzETKotIiWGCxwDoXAZDWVoTuVRYQa2ZdiZPuBL7QnA== - dependencies: - glob "^6.0.1" - insync "^2.1.1" - mkdirp "^0.5.1" - ncp "^2.0.0" - rimraf "^2.4.4" - npm-package-arg@^11.0.0: version "11.0.3" resolved "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz" @@ -9911,7 +9879,7 @@ rfdc@^1.4.1: resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== -rimraf@^2.4.4, rimraf@^2.6.3: +rimraf@^2.6.3: version "2.7.1" resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==