Files
App/player/provider.tsx

587 lines
18 KiB
TypeScript

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<SetStateAction<boolean>>;
nowPlaying: JellifyTrack | undefined;
playQueue: JellifyTrack[];
queue: Queue;
getQueueSectionData: () => Section[];
useAddToQueue: UseMutationResult<void, Error, AddToQueueMutation, unknown>;
useClearQueue: UseMutationResult<void, Error, void, unknown>;
useRemoveFromQueue: UseMutationResult<void, Error, number, unknown>;
useReorderQueue: UseMutationResult<void, Error, QueueOrderMutation, unknown>;
useTogglePlayback: UseMutationResult<void, Error, number | undefined, unknown>;
useSeekTo: UseMutationResult<void, Error, number, unknown>;
useSkip: UseMutationResult<void, Error, number | undefined, unknown>;
usePrevious: UseMutationResult<void, Error, void, unknown>;
usePlayNewQueue: UseMutationResult<void, Error, QueueMutation, unknown>;
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<boolean>(false);
const [nowPlayingIsFavorite, setNowPlayingIsFavorite] = useState<boolean>(false);
const [nowPlaying, setNowPlaying] = useState<JellifyTrack | undefined>(nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined);
const [isSkipping, setIsSkipping] = useState<boolean>(false);
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(playQueueJson ? JSON.parse(playQueueJson) : []);
const [queue, setQueue] = useState<Queue>(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<PlayerContext>({
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 <PlayerContext.Provider value={{
initialized,
nowPlayingIsFavorite,
setNowPlayingIsFavorite,
nowPlaying,
playQueue,
queue,
getQueueSectionData,
useAddToQueue,
useClearQueue,
useRemoveFromQueue,
useReorderQueue,
useTogglePlayback,
useSeekTo,
useSkip,
usePrevious,
usePlayNewQueue,
playbackState,
}}>
{ children }
</PlayerContext.Provider>
}
export const usePlayerContext = () => useContext(PlayerContext);