import { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from "react"; 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, Progress, State, usePlaybackState, useProgress, useTrackPlayerEvents } from "react-native-track-player"; import { isEqual, isUndefined } from "lodash"; import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import { handlePlaybackProgressUpdated, handlePlaybackState } from "./handlers"; import { useUpdateOptions } from "../player/hooks"; import { UPDATE_INTERVAL } from "./config"; 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 { getQueue, pause, seekTo, skip, skipToNext, skipToPrevious } 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"; 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; useSeekTo: UseMutationResult; useSkip: UseMutationResult; usePrevious: UseMutationResult; usePlayNewQueue: UseMutationResult; playbackState: State | undefined; } 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!) //#region State const [initialized, setInitialized] = useState(false); const [nowPlayingIsFavorite, setNowPlayingIsFavorite] = useState(false); const [nowPlaying, setNowPlaying] = useState(nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined); const [isSkipping, setIsSkipping] = useState(false); 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 getQueueSectionData : () => Section[] = () => { return Object.keys(QueuingType).map((type) => { return { title: type, data: playQueue.filter(track => track.QueuingType === type) } as Section }); } 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[]) } 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) => { if (mutation.queuingType === QueuingType.PlayingNext) return addToNext([mapDtoToTrack(mutation.track, mutation.queuingType)]); else return addToQueue([mapDtoToTrack(mutation.track, 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 useTogglePlayback = useMutation({ mutationFn: (index?: number | undefined) => { trigger("impactMedium"); if (playbackState === State.Playing) return pause(); else return play(index); } }); const useSeekTo = useMutation({ mutationFn: async (position: number) => { trigger('impactLight'); await seekTo(position); handlePlaybackProgressUpdated(Client.sessionId, playStateApi, nowPlaying!, { buffered: 0, position, duration: convertRunTimeTicksToSeconds(nowPlaying!.duration!) }); } }); const useSkip = useMutation({ mutationFn: async (index?: number | undefined) => { trigger("impactMedium") 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); if (nowPlayingIndex > 0) { setNowPlaying(playQueue[nowPlayingIndex - 1]) await skipToPrevious(); } } }) const usePlayNewQueue = useMutation({ mutationFn: async (mutation: QueueMutation) => { trigger("effectDoubleClick"); setIsSkipping(true); // Optimistically set now playing setNowPlaying(mapDtoToTrack(mutation.tracklist[mutation.index ?? 0], QueuingType.FromSelection)); await resetQueue(false); await addToQueue(mutation.tracklist.map((track) => { return mapDtoToTrack(track, QueuingType.FromSelection) })); setQueue(mutation.queue); }, onSuccess: async (data, mutation: QueueMutation) => { setIsSkipping(false); await play(mutation.index) }, onError: async () => { setIsSkipping(false); setNowPlaying(await TrackPlayer.getActiveTrack() as JellifyTrack) } }); //#endregion //#region RNTP Setup const { state: playbackState } = usePlaybackState(); useTrackPlayerEvents([ Event.RemoteLike, Event.RemoteDislike, Event.PlaybackProgressUpdated, Event.PlaybackState, Event.PlaybackActiveTrackChanged, ], async (event) => { switch (event.type) { case (Event.RemoteLike) : { setNowPlayingIsFavorite(true); break; } case (Event.RemoteDislike) : { setNowPlayingIsFavorite(false); break; } case (Event.PlaybackState) : { handlePlaybackState(Client.sessionId, playStateApi, await TrackPlayer.getActiveTrack() as JellifyTrack, event.state); break; } case (Event.PlaybackProgressUpdated) : { handlePlaybackProgressUpdated(Client.sessionId, playStateApi, nowPlaying!, event); break; } case (Event.PlaybackActiveTrackChanged) : { if (initialized && !isSkipping) { const activeTrack = await TrackPlayer.getActiveTrack() as JellifyTrack | undefined; if (activeTrack && !isEqual(activeTrack, nowPlaying)) { setNowPlaying(activeTrack); // Set player favorite state to user data IsFavorite // This is super nullish so we need to do a lot of // checks on the fields // TODO: Turn this check into a helper function setNowPlayingIsFavorite( isUndefined(activeTrack) ? false : isUndefined(activeTrack!.item.UserData) ? false : activeTrack.item.UserData.IsFavorite ?? false ); await useUpdateOptions(nowPlayingIsFavorite); } else if (!!!activeTrack) { setNowPlaying(undefined) setNowPlayingIsFavorite(false); } else { // Do nothing } } } } }); //#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) storage.set(MMKVStorageKeys.NowPlaying, JSON.stringify(nowPlaying)) }, [ nowPlaying ]) useEffect(() => { if (!initialized && playQueue.length > 0 && nowPlaying) { TrackPlayer.setQueue(playQueue) .then(() => { TrackPlayer.skip(playQueue.findIndex(track => track.item.Id! === nowPlaying.item.Id!)); }); } setInitialized(true); }, [ playQueue, nowPlaying ]) //#endregion useEffects //#region return return { initialized, nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, playQueue, queue, getQueueSectionData, useAddToQueue, useClearQueue, useReorderQueue, useRemoveFromQueue, useTogglePlayback, useSeekTo, useSkip, usePrevious, usePlayNewQueue, playbackState, } //#endregion return } //#region Create PlayerContext export const PlayerContext = createContext({ initialized: false, nowPlayingIsFavorite: false, setNowPlayingIsFavorite: () => {}, nowPlaying: undefined, 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: { 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 }, useTogglePlayback: { 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 }, useSeekTo: { 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 }, 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 export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ children }: { children: ReactNode }) => { const { initialized, nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, playQueue, queue, getQueueSectionData, useAddToQueue, useClearQueue, useRemoveFromQueue, useReorderQueue, useTogglePlayback, useSeekTo, useSkip, usePrevious, usePlayNewQueue, playbackState, } = PlayerContextInitializer(); return { children } } export const usePlayerContext = () => useContext(PlayerContext);