mirror of
https://github.com/Jellify-Music/App.git
synced 2026-02-05 01:48:30 -06:00
587 lines
18 KiB
TypeScript
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); |