diff --git a/app/_layout.tsx b/app/_layout.tsx
index ed6a5beec..22b297a16 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -11,6 +11,7 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
+import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -547,6 +548,7 @@ function Layout() {
closeButton
/>
{!Platform.isTV && }
+ {!Platform.isTV && }
diff --git a/components/PendingAccountSaveModal.tsx b/components/PendingAccountSaveModal.tsx
new file mode 100644
index 000000000..95d68a6e1
--- /dev/null
+++ b/components/PendingAccountSaveModal.tsx
@@ -0,0 +1,45 @@
+import { useAtom, useAtomValue } from "jotai";
+import type React from "react";
+import { useEffect } from "react";
+import { Platform } from "react-native";
+import { SaveAccountModal } from "@/components/SaveAccountModal";
+import {
+ pendingAccountSaveAtom,
+ useJellyfin,
+ userAtom,
+} from "@/providers/JellyfinProvider";
+
+/**
+ * Post-login save-account prompt. Login flows (password or Quick Connect)
+ * only flag the intent via pendingAccountSaveAtom; the protection picker
+ * shows here, AFTER the session is authorized — the login screen itself
+ * unmounts as soon as the user is set, so it can't host the modal.
+ */
+export const PendingAccountSaveModal: React.FC = () => {
+ const [pending, setPending] = useAtom(pendingAccountSaveAtom);
+ const user = useAtomValue(userAtom);
+ const { saveCurrentAccount } = useJellyfin();
+
+ // A logout before answering drops the intent — it must not resurface on
+ // the next (possibly different) login.
+ useEffect(() => {
+ if (!user && pending) setPending(null);
+ }, [user, pending, setPending]);
+
+ if (Platform.isTV) return null;
+
+ return (
+ setPending(null)}
+ onSave={(securityType, pinCode) => {
+ const serverName = pending?.serverName;
+ setPending(null);
+ saveCurrentAccount({ securityType, pinCode, serverName }).catch(
+ () => {},
+ );
+ }}
+ />
+ );
+};
diff --git a/components/login/Login.tsx b/components/login/Login.tsx
index 7cf5f43fc..71f531fe0 100644
--- a/components/login/Login.tsx
+++ b/components/login/Login.tsx
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
-import { useAtomValue } from "jotai";
+import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -21,13 +21,14 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
-import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
-import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
-import type {
- AccountSecurityType,
- SavedServer,
-} from "@/utils/secureCredentials";
+import {
+ apiAtom,
+ pendingAccountSaveAtom,
+ useJellyfin,
+ userAtom,
+} from "@/providers/JellyfinProvider";
+import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -35,6 +36,7 @@ const CredentialsSchema = z.object({
export const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
+ const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const {
apiUrl: _apiUrl,
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
password: _password || "",
});
- // Save account state
+ // Save account state — only the intent lives here; the protection picker is
+ // the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false);
- const [showSaveModal, setShowSaveModal] = useState(false);
- const [pendingLogin, setPendingLogin] = useState<{
- username: string;
- password: string;
- } | null>(null);
+
+ // Tracks an in-flight Quick Connect attempt (code issued, provider polling).
+ const [quickConnectActive, setQuickConnectActive] = useState(false);
+
+ // A Quick Connect login with "save account" on flags the post-login save:
+ // the protection picker shows globally once the session exists (this screen
+ // unmounts on login, so it can't host the modal).
+ useEffect(() => {
+ if (user) {
+ if (quickConnectActive && saveAccount) {
+ setPendingAccountSave({ serverName });
+ }
+ setQuickConnectActive(false);
+ }
+ }, [user]);
// Handle URL params for server connection
useEffect(() => {
@@ -117,29 +131,22 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
- if (saveAccount) {
- setPendingLogin({
- username: credentials.username,
- password: credentials.password,
- });
- setShowSaveModal(true);
- } else {
- await performLogin(credentials.username, credentials.password);
+ const ok = await performLogin(credentials.username, credentials.password);
+ // The protection picker shows AFTER a successful login (global modal) —
+ // never for a failed one.
+ if (ok && saveAccount) {
+ setPendingAccountSave({ serverName });
}
};
const performLogin = async (
username: string,
password: string,
- options?: {
- saveAccount?: boolean;
- securityType?: AccountSecurityType;
- pinCode?: string;
- },
- ) => {
+ ): Promise => {
setLoading(true);
try {
- await login(username, password, serverName, options);
+ await login(username, password, serverName);
+ return true;
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -149,23 +156,9 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occured"),
);
}
+ return false;
} finally {
setLoading(false);
- setPendingLogin(null);
- }
- };
-
- const handleSaveAccountConfirm = async (
- securityType: AccountSecurityType,
- pinCode?: string,
- ) => {
- setShowSaveModal(false);
- if (pendingLogin) {
- await performLogin(pendingLogin.username, pendingLogin.password, {
- saveAccount: true,
- securityType,
- pinCode,
- });
}
};
@@ -259,6 +252,7 @@ export const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
+ setQuickConnectActive(true);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
@@ -443,16 +437,6 @@ export const Login: React.FC = () => {
)}
-
- {
- setShowSaveModal(false);
- setPendingLogin(null);
- }}
- onSave={handleSaveAccountConfirm}
- username={pendingLogin?.username || credentials.username}
- />
);
};
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 6d5ff8d8d..bab07162f 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -92,6 +92,12 @@ export const apiAtom = atom(initialApi);
export const userAtom = atom(initialUser);
export const wsAtom = atom(null);
export const cacheVersionAtom = atom(0);
+// Set by a login flow that wants the account saved: the protection picker
+// shows AFTER the session is authorized (the login screen unmounts on
+// success, so the modal lives at the root — see PendingAccountSaveModal).
+export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
+ null,
+);
interface LoginOptions {
saveAccount?: boolean;
@@ -109,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string,
options?: LoginOptions,
) => Promise;
+ saveCurrentAccount: (options?: {
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ serverName?: string;
+ }) => Promise;
logout: () => Promise;
initiateQuickConnect: () => Promise;
stopQuickConnectPolling: () => void;
@@ -348,6 +359,37 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
+ // Persist the CURRENT session to secure storage — used by the post-login
+ // save-account modal (the protection picker shows AFTER a successful
+ // login, for both the password and Quick Connect flows).
+ const saveCurrentAccount = useCallback(
+ async (options?: {
+ securityType?: AccountSecurityType;
+ pinCode?: string;
+ serverName?: string;
+ }) => {
+ const token = storage.getString("token");
+ if (!api?.basePath || !user?.Id || !user.Name || !token) return;
+ const securityType = options?.securityType || "none";
+ let pinHash: string | undefined;
+ if (securityType === "pin" && options?.pinCode) {
+ pinHash = await hashPIN(options.pinCode);
+ }
+ await saveAccountCredential({
+ serverUrl: api.basePath,
+ serverName: options?.serverName || "",
+ token,
+ userId: user.Id,
+ username: user.Name,
+ savedAt: Date.now(),
+ securityType,
+ pinHash,
+ primaryImageTag: user.PrimaryImageTag ?? undefined,
+ });
+ },
+ [api?.basePath, user],
+ );
+
const loginMutation = useMutation({
mutationFn: async ({
username,
@@ -427,7 +469,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default:
throw new Error(
t(
- "login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
+ "login.an_unexpected_error_occured_did_you_enter_the_correct_url",
),
);
}
@@ -732,6 +774,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }),
+ saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect,
stopQuickConnectPolling,