Merge branch 'master' into pr/106

This commit is contained in:
Fredrik Burmester
2024-09-01 17:11:52 +02:00
74 changed files with 2739 additions and 1528 deletions

View File

@@ -1,15 +1,19 @@
import { useInterval } from "@/hooks/useInterval";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Platform } from "react-native";
@@ -29,6 +33,7 @@ interface JellyfinContextValue {
removeServer: () => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
@@ -51,7 +56,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [isConnected, setIsConnected] = useState<boolean>(false);
useEffect(() => {
(async () => {
@@ -59,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.8.2" },
clientInfo: { name: "Streamyfin", version: "0.10.3" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -69,6 +73,101 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
useQuery({
queryKey: ["user", api],
queryFn: async () => {
if (!api) return null;
const response = await getUserApi(api).getCurrentUser();
if (response.data) setUser(response.data);
return user;
},
enabled: !!api,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true,
refetchOnMount: true,
refetchOnReconnect: true,
});
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.10.3"`,
};
}, [deviceId]);
const initiateQuickConnect = useCallback(async () => {
if (!api || !deviceId) return;
try {
const response = await api.axiosInstance.post(
api.basePath + "/QuickConnect/Initiate",
null,
{
headers,
}
);
if (response?.status === 200) {
setSecret(response?.data?.Secret);
setIsPolling(true);
return response.data?.Code;
} else {
throw new Error("Failed to initiate quick connect");
}
} catch (error) {
console.error(error);
throw error;
}
}, [api, deviceId, headers]);
const pollQuickConnect = useCallback(async () => {
if (!api || !secret) return;
try {
const response = await api.axiosInstance.get(
`${api.basePath}/QuickConnect/Connect?Secret=${secret}`
);
if (response.status === 200) {
if (response.data.Authenticated) {
setIsPolling(false);
const authResponse = await api.axiosInstance.post(
api.basePath + "/Users/AuthenticateWithQuickConnect",
{
secret,
},
{
headers,
}
);
const { AccessToken, User } = authResponse.data;
api.accessToken = AccessToken;
setUser(User);
await AsyncStorage.setItem("token", AccessToken);
await AsyncStorage.setItem("user", JSON.stringify(User));
return true;
}
}
return false;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 400) {
setIsPolling(false);
setSecret(null);
throw new Error("The code has expired. Please try again.");
} else {
console.error("Error polling Quick Connect:", error);
throw error;
}
}
}, [api, secret, headers]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
@@ -122,7 +221,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.log("Axios error", error.response?.status);
switch (error.response?.status) {
case 401:
throw new Error("Invalid username or password");
@@ -199,6 +297,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
};
useProtectedRoute(user, isLoading || isFetching);

View File

@@ -10,19 +10,21 @@ import React, {
} from "react";
import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Alert, Platform } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import { getDeviceId } from "@/utils/device";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
type CurrentlyPlayingState = {
url: string;
@@ -36,14 +38,15 @@ interface PlaybackContextType {
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: () => void;
pauseVideo: () => void;
playVideo: (triggerRef?: boolean) => void;
pauseVideo: (triggerRef?: boolean) => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void;
setVolume: (volume: number) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
@@ -61,9 +64,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [settings] = useSettings();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const previousVolume = useRef<number | null>(null);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
@@ -71,18 +78,14 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
const setVolume = useCallback(
(newVolume: number) => {
previousVolume.current = volume;
_setVolume(newVolume);
videoRef.current?.setVolume(newVolume);
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
});
[_setVolume]
);
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
@@ -90,15 +93,28 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
});
const setCurrentlyPlayingState = useCallback(
(state: CurrentlyPlayingState | null) => {
const vlcLink = "vlc://" + state?.url;
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
async (state: CurrentlyPlayingState | null) => {
if (!api) return;
if (state) {
if (state && state.item.Id && user?.Id) {
const vlcLink = "vlc://" + state?.url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
const res = await getMediaInfoApi(api).getPlaybackInfo({
itemId: state.item.Id,
userId: user.Id,
});
await postCapabilities({
api,
itemId: state.item.Id,
sessionId: res.data.PlaySessionId,
});
setSession(res.data);
setCurrentlyPlaying(state);
setIsPlaying(true);
@@ -113,62 +129,86 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setIsPlaying(false);
}
},
[settings]
[settings, user, api]
);
// Define control methods
const playVideo = useCallback(() => {
videoRef.current?.resume();
setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: true,
});
}, [
api,
currentlyPlaying?.item.Id,
sessionData?.PlaySessionId,
progressTicks,
]);
const playVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.resume();
}
_setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: false,
});
},
[api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
);
const pauseVideo = useCallback(() => {
videoRef.current?.pause();
setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: false,
});
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
const pauseVideo = useCallback(
(triggerRef: boolean = true) => {
if (triggerRef === true) {
videoRef.current?.pause();
}
_setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: session?.PlaySessionId,
IsPaused: true,
});
},
[session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
);
const stopPlayback = useCallback(async () => {
await reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
sessionId: sessionData?.PlaySessionId,
sessionId: session?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
setCurrentlyPlayingState(null);
}, [currentlyPlaying, sessionData, progressTicks]);
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
const onProgress = useCallback(
const setIsPlaying = useCallback(
debounce((value: boolean) => {
_setIsPlaying(value);
}, 500),
[]
);
const _onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!session?.PlaySessionId ||
!currentlyPlaying?.item.Id ||
currentTime === 0
)
return;
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: sessionData?.PlaySessionId,
sessionId: session?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
);
const onProgress = useCallback(
debounce((e: OnProgressData) => {
_onProgress(e);
}, 1000),
[_onProgress]
);
const presentFullscreenPlayer = useCallback(() => {
@@ -184,7 +224,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes('https') ? 'wss' : 'ws'
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
@@ -192,8 +232,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
api?.accessToken
}&deviceId=${deviceId}`;
console.log(protocol, url);
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
@@ -204,7 +242,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
console.log("KeepAlive message sent");
}
}, 30000);
};
@@ -215,7 +252,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
};
newWebSocket.onclose = (e) => {
console.log("WebSocket connection closed:", e.reason);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
@@ -238,6 +274,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
console.log("[WS] ~ ", json);
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
@@ -246,6 +284,19 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
} else if (command === "Mute") {
console.log("Command ~ Mute");
setVolume(0);
} else if (command === "Unmute") {
console.log("Command ~ Unmute");
setVolume(previousVolume.current || 20);
} else if (command === "SetVolume") {
console.log("Command ~ SetVolume");
} else if (json?.Data?.Name === "DisplayMessage") {
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
Alert.alert(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
@@ -255,12 +306,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
value={{
onProgress,
progressTicks,
setVolume,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData,
sessionData: session,
videoRef,
playVideo,
setCurrentlyPlayingState,