From e6d4414fd6f394e702804f709c72a5345dd7b7e4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 21:52:23 +0200 Subject: [PATCH] feat: quick connect fixes #16 --- app/login.tsx | 40 +++++++++++---- hooks/useInterval.ts | 19 +++++++ providers/JellyfinProvider.tsx | 90 +++++++++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 hooks/useInterval.ts diff --git a/app/login.tsx b/app/login.tsx index 2c35112d..9cb37339 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -3,10 +3,9 @@ import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { Ionicons } from "@expo/vector-icons"; -import { AxiosError } from "axios"; import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Alert, KeyboardAvoidingView, @@ -22,7 +21,8 @@ const CredentialsSchema = z.object({ }); const Login: React.FC = () => { - const { setServer, login, removeServer } = useJellyfin(); + const { setServer, login, removeServer, initiateQuickConnect } = + useJellyfin(); const [api] = useAtom(apiAtom); const params = useLocalSearchParams(); @@ -87,6 +87,21 @@ const Login: React.FC = () => { setServer({ address: url.trim() }); }; + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert("Quick Connect", `Enter code ${code} to login`, [ + { + text: "Got It", + }, + ]); + } + } catch (error) { + Alert.alert("Error", "Failed to initiate Quick Connect"); + } + }; + if (api?.basePath) { return ( @@ -162,13 +177,18 @@ const Login: React.FC = () => { {error} - + + + + diff --git a/hooks/useInterval.ts b/hooks/useInterval.ts new file mode 100644 index 00000000..850f2f48 --- /dev/null +++ b/hooks/useInterval.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from "react"; + +export function useInterval(callback: () => void, delay: number | null) { + const savedCallback = useRef<() => void>(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function tick() { + savedCallback.current?.(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index a1d2a84f..b12fac27 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,15 +1,18 @@ +import { useInterval } from "@/hooks/useInterval"; import { Api, Jellyfin } from "@jellyfin/sdk"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; 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 +32,7 @@ interface JellyfinContextValue { removeServer: () => void; login: (username: string, password: string) => Promise; logout: () => Promise; + initiateQuickConnect: () => Promise; } const JellyfinContext = createContext( @@ -51,7 +55,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ }) => { const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); - const [isConnected, setIsConnected] = useState(false); useEffect(() => { (async () => { @@ -69,6 +72,88 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [api, setApi] = useAtom(apiAtom); const [user, setUser] = useAtom(userAtom); + const [isPolling, setIsPolling] = useState(false); + const [secret, setSecret] = useState(null); + + const headers = useMemo(() => { + if (!deviceId) return {}; + return { + authorization: `MediaBrowser Client="Streamyfin", Device=${ + Platform.OS === "android" ? "Android" : "iOS" + }, DeviceId="${deviceId}", Version="0.8.4"`, + }; + }, [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); + console.log("Initiating quick connect"); + 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}` + ); + + console.log("Polling quick connect"); + 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; + console.log("Quick connect authenticated", AccessToken, User.Id); + 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 => { const servers = await jellyfin?.discovery.getRecommendedServerCandidates( @@ -199,6 +284,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ login: (username, password) => loginMutation.mutateAsync({ username, password }), logout: () => logoutMutation.mutateAsync(), + initiateQuickConnect, }; useProtectedRoute(user, isLoading || isFetching);