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);