Compare commits

..

5 Commits

Author SHA1 Message Date
Gauvain
3ff3847d80 fix(login): warn instead of swallowing account-save errors 2026-07-15 00:39:01 +02:00
Gauvain
8cf141e03b fix(home): defer first-launch intro until the save-account sheet is dismissed
On first login with "save account" checked, the intro sheet's 1s timer
fired while the post-login save-account bottom sheet was still up and
stole its presentation (both are gorhom modals), closing it. Gate the
intro on pendingAccountSaveAtom so it only schedules once no account
save is pending: before the sheet opens, or after it is dismissed.
2026-07-15 00:38:30 +02:00
Gauvain
34f01e2c13 fix(login): ask how to protect a saved account after the login succeeds
The protection picker used to show before the login attempt, so a wrong
password still walked the user through choosing a PIN/password for an
account that never logged in - and a Quick Connect login could not save
the account at all.

Login flows now only flag the intent (pendingAccountSaveAtom); the
picker is a global PendingAccountSaveModal mounted at the root, shown
once the session is authorized - the login screen unmounts on success,
so it cannot host the modal itself. Works identically for the password
and Quick Connect flows; the credential is saved from the live session
token (saveCurrentAccount). Cancelling saves nothing, and a logout
before answering drops the intent.
2026-07-15 00:38:22 +02:00
Gauvain
98dfefbdd0 fix(auth): clear the session on any 401 via a response interceptor
When the server revokes the token (device/session deleted), a 401 can
surface from any authenticated request. Nothing cleaned it up: the dead
token stayed in storage, every reload re-fired authenticated calls (401
spam, uncaught rejections) and the app lingered half-authenticated.

A response interceptor on the authenticated api clears the session once
on the first 401 so the app drops cleanly to the login screen. It only
attaches when api.accessToken is set, so a wrong-password 401 on the
login screen is never treated as session expiry. Saved credentials are
kept for quick re-login.
2026-07-15 00:33:37 +02:00
Gauvain
49a903f48a fix(auth): stop the offline splash hang and soften handled auth logs
On a cold start without network, startup awaited getCurrentUser on an
axios instance with no timeout, so the splash hung for the full OS TCP
timeout (75-120 s). Render from the cached user immediately and run the
token validation/refresh in the background; setInitialLoaded moves to a
finally so every path dismisses the splash.

Handled failures (quick-login with a revoked token, background
validation while offline) now log as warnings, and the background path
logs only status/message - axios errors carry the Authorization header.
2026-07-15 00:33:22 +02:00
25 changed files with 597 additions and 891 deletions

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occurred_while_playing_the_video"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}

View File

@@ -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";
@@ -27,7 +28,6 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
@@ -86,8 +86,7 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -352,12 +351,9 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log(
"Notification received while app running:",
notification.request.content.title,
"Notification received while app running",
notification,
);
},
);
@@ -426,146 +422,144 @@ function Layout() {
>
<JellyfinProvider>
<InactivityProvider>
<WifiSsidProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</WifiSsidProvider>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider>
</JellyfinProvider>
</PersistQueryClientProvider>

View File

@@ -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 (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => console.warn("Failed to save account:", error),
);
}}
/>
);
};

View File

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>

View File

@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -89,6 +93,9 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet();
// Gate the intro so it can't steal presentation from the post-login
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
@@ -97,7 +104,9 @@ const HomeMobile = () => {
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
// Defer while the save-account sheet is up; this effect re-runs and schedules
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
const timer = setTimeout(() => {
showIntro();
}, 1000);
@@ -106,7 +115,7 @@ const HomeMobile = () => {
clearTimeout(timer);
};
}
}, [showIntro]);
}, [showIntro, pendingAccountSave]);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {

View File

@@ -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,55 +131,34 @@ 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<boolean> => {
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);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
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 = () => {
</View>
)}
</KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occurred"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occurred");
: t("login.an_unexpected_error_occured");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useWifiSSID } from "@/hooks/useWifiSSID";
import { openLocationSettings } from "@/modules/wifi-ssid";
import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv";
import {
@@ -27,26 +26,16 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
interface StatusDisplayProps {
currentSSID: string | null;
connectedToWifi: boolean;
isUsingLocalUrl: boolean;
locationBlocked: boolean;
onOpenLocationSettings: () => void;
t: (key: string) => string;
}
function StatusDisplay({
currentSSID,
connectedToWifi,
isUsingLocalUrl,
locationBlocked,
onOpenLocationSettings,
t,
}: StatusDisplayProps): React.ReactElement {
const wifiStatus = currentSSID
? currentSSID
: connectedToWifi
? t("home.settings.network.ssid_hidden")
: t("home.settings.network.not_connected");
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl
? t("home.settings.network.local")
: t("home.settings.network.remote");
@@ -66,23 +55,6 @@ function StatusDisplay({
</Text>
<Text className={urlTypeColor}>{urlType}</Text>
</View>
{locationBlocked && (
<View className='mt-2 pt-2 border-t border-neutral-800'>
<Text className='text-xs text-amber-400'>
{t("home.settings.network.location_off_description")}
</Text>
<TouchableOpacity
onPress={onOpenLocationSettings}
className='mt-2 self-start'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text className='text-xs text-blue-400 font-semibold'>
{t("home.settings.network.open_location_settings")}
</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
@@ -90,17 +62,7 @@ function StatusDisplay({
export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID();
const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
useServerUrl();
// Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
// on Android this means device Location services are off.
const locationBlocked =
permissionStatus === "granted" && connectedToWifi && !currentSSID;
const handleOpenLocationSettings = useCallback(() => {
openLocationSettings();
}, []);
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
@@ -233,23 +195,18 @@ export function LocalNetworkSettings(): React.ReactElement | null {
)}
</ListGroup>
{!locationBlocked && (
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
)}
<View className='py-2'>
<Button
onPress={handleAddCurrentNetwork}
disabled={!currentSSID || permissionStatus !== "granted"}
>
{addNetworkButtonText}
</Button>
</View>
<StatusDisplay
currentSSID={currentSSID}
connectedToWifi={connectedToWifi}
isUsingLocalUrl={isUsingLocalUrl}
locationBlocked={locationBlocked}
onOpenLocationSettings={handleOpenLocationSettings}
t={t}
/>
</View>

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_authorized"),
t("home.settings.quick_connect.quick_connect_autorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,5 +1,125 @@
export {
type PermissionStatus,
type UseWifiSSIDReturn,
useWifiSsid as useWifiSSID,
} from "@/providers/WifiSsidProvider";
import { useCallback, useEffect, useState } from "react";
import { Platform } from "react-native";
import { getSSID } from "@/modules/wifi-ssid";
export type PermissionStatus =
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
isLoading: boolean;
}
// WiFi SSID is not available on tvOS
if (Platform.isTV) {
// Export a stub hook for tvOS
module.exports = {
useWifiSSID: (): UseWifiSSIDReturn => ({
ssid: null,
permissionStatus: "unavailable" as PermissionStatus,
requestPermission: async () => false,
isLoading: false,
}),
};
}
// Only import Location on non-TV platforms
const Location = Platform.isTV ? null : require("expo-location");
function mapLocationStatus(status: number | undefined): PermissionStatus {
if (!Location) return "unavailable";
switch (status) {
case Location.PermissionStatus?.GRANTED:
return "granted";
case Location.PermissionStatus?.DENIED:
return "denied";
default:
return "undetermined";
}
}
export function useWifiSSID(): UseWifiSSIDReturn {
const [ssid, setSSID] = useState<string | null>(null);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
Platform.isTV ? "unavailable" : "undetermined",
);
const [isLoading, setIsLoading] = useState(!Platform.isTV);
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
setSSID(result);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (Platform.isTV || !Location) {
setPermissionStatus("unavailable");
return false;
}
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const newStatus = mapLocationStatus(status);
setPermissionStatus(newStatus);
if (newStatus === "granted") {
await fetchSSID();
}
return newStatus === "granted";
} catch {
setPermissionStatus("unavailable");
return false;
}
}, [fetchSSID]);
useEffect(() => {
if (Platform.isTV || !Location) {
setIsLoading(false);
return;
}
async function initialize() {
setIsLoading(true);
try {
const { status } = await Location.getForegroundPermissionsAsync();
const mappedStatus = mapLocationStatus(status);
setPermissionStatus(mappedStatus);
if (mappedStatus === "granted") {
await fetchSSID();
}
} catch {
setPermissionStatus("unavailable");
}
setIsLoading(false);
}
initialize();
}, [fetchSSID]);
// Refresh SSID when permission status changes to granted
useEffect(() => {
if (Platform.isTV) return;
if (permissionStatus === "granted") {
fetchSSID();
// Also set up an interval to periodically check SSID
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
return () => clearInterval(interval);
}
}, [permissionStatus, fetchSSID]);
return {
ssid,
permissionStatus,
requestPermission,
isLoading,
};
}

View File

@@ -1,19 +0,0 @@
apply plugin: 'expo-module-gradle-plugin'
group = 'expo.modules.wifissid'
version = '1.0.0'
expoModule {
canBePublished false
}
android {
namespace "expo.modules.wifissid"
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
dependencies {
}

View File

@@ -1,9 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Reading the connected Wi-Fi SSID requires ACCESS_WIFI_STATE, and (since
Android 8) ACCESS_FINE_LOCATION granted at runtime. Location is requested
at runtime by the app via expo-location; the manifest declares the
Wi-Fi/network permissions this module needs directly. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>

View File

@@ -1,121 +0,0 @@
package expo.modules.wifissid
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.provider.Settings
import android.util.Log
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class WifiSsidModule : Module() {
companion object {
private const val TAG = "WifiSsid"
private const val UNKNOWN_SSID = "<unknown ssid>"
}
private val context
get() = requireNotNull(appContext.reactContext)
override fun definition() = ModuleDefinition {
Name("WifiSsid")
AsyncFunction("getSSID") {
val (ssid, connectedToWifi) = readWifi()
mapOf(
"ssid" to ssid,
"connectedToWifi" to connectedToWifi,
)
}
Function("getSSIDSync") {
val (ssid, connectedToWifi) = readWifi()
mapOf(
"ssid" to ssid,
"connectedToWifi" to connectedToWifi,
)
}
Function("openLocationSettings") {
openLocationSettings()
}
}
/**
* Returns the current Wi-Fi SSID (or null when it can't be read) and whether
* the device is connected to a Wi-Fi network. When the device is connected but
* the SSID is null, the OS is withholding the network name — on Android this
* typically means device Location services are off (the SSID is treated as
* location-sensitive).
*/
private fun readWifi(): Pair<String?, Boolean> {
val androidContext = context.applicationContext
var connectedToWifi = false
var ssid: String? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val caps = cm?.let { manager ->
manager.activeNetwork?.let { net -> manager.getNetworkCapabilities(net) }
}
connectedToWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
if (hasFineLocationPermission()) {
ssid = (caps?.transportInfo as? WifiInfo)?.ssid?.sanitize()
}
} catch (e: Exception) {
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager", e)
}
} else {
// Pre-Q (API 2628): NetworkCapabilities/transportInfo aren't available, so
// detect a Wi-Fi connection via the legacy active-network API. Without this,
// connectedToWifi stays false on Android 8/9 even when connected, so the
// "connected but SSID hidden" state never surfaces there.
try {
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@Suppress("DEPRECATION")
connectedToWifi = cm?.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI
} catch (e: Exception) {
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager (legacy)", e)
}
}
if (ssid == null && hasFineLocationPermission()) {
try {
val wm = androidContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
@Suppress("DEPRECATION")
ssid = wm?.connectionInfo?.ssid?.sanitize()
} catch (e: Exception) {
Log.e(TAG, "Error reading SSID via WifiManager", e)
}
}
return Pair(ssid, connectedToWifi)
}
private fun openLocationSettings() {
try {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.applicationContext.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Unable to open Location settings", e)
}
}
private fun hasFineLocationPermission(): Boolean =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun String.sanitize(): String? {
val cleaned = trim('"')
return if (cleaned.isBlank() || cleaned == UNKNOWN_SSID) null else cleaned
}
}

View File

@@ -1,11 +1,8 @@
{
"name": "wifi-ssid",
"version": "1.0.0",
"platforms": ["ios", "android"],
"platforms": ["ios"],
"ios": {
"modules": ["WifiSsidModule"]
},
"android": {
"modules": ["expo.modules.wifissid.WifiSsidModule"]
}
}

View File

@@ -1,92 +1,44 @@
import { Platform, requireNativeModule } from "expo-modules-core";
import { Linking } from "react-native";
export interface WifiSsidResult {
ssid: string | null;
/**
* Whether the device is connected to a Wi-Fi network. When true but `ssid` is
* null, the OS is withholding the network name — on Android this happens when
* device Location services are off (the SSID is location-sensitive).
*/
connectedToWifi: boolean;
}
// Load the native module on iOS and Android (not TV/web). Wrapped so a
// missing/unlinked module degrades to null instead of crashing the bundle.
const WifiSsidModule = (() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
try {
return requireNativeModule("WifiSsid");
} catch {
return null;
}
})();
// Only load the native module on iOS
const WifiSsidModule =
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
/**
* Get the current WiFi SSID and whether the device is on a Wi-Fi network.
* Get the current WiFi SSID on iOS.
* Returns null on Android or if not connected to WiFi.
*
* iOS requirements:
* Requires:
* - Location permission granted
* - com.apple.developer.networking.wifi-info entitlement
*
* Android requirements:
* - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
* - Access WiFi Information capability enabled in Apple Developer Portal
*/
export async function getSSID(): Promise<WifiSsidResult> {
export async function getSSID(): Promise<string | null> {
if (!WifiSsidModule) {
return { ssid: null, connectedToWifi: false };
return null;
}
try {
return normalize(await WifiSsidModule.getSSID());
const ssid = await WifiSsidModule.getSSID();
return ssid ?? null;
} catch (error) {
console.error("[WifiSsid] Error getting SSID:", error);
return { ssid: null, connectedToWifi: false };
}
}
/** Synchronous version (uses the older CNCopyCurrentNetworkInfo API on iOS). */
export function getSSIDSync(): WifiSsidResult {
if (!WifiSsidModule) {
return { ssid: null, connectedToWifi: false };
}
try {
return normalize(WifiSsidModule.getSSIDSync());
} catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error);
return { ssid: null, connectedToWifi: false };
return null;
}
}
/**
* Open the system Location settings so the user can enable Location services
* (required on Android for the OS to reveal the Wi-Fi SSID). Falls back to the
* app's settings page where there is no dedicated native action (e.g. iOS).
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
*/
export function openLocationSettings(): void {
try {
if (WifiSsidModule?.openLocationSettings) {
WifiSsidModule.openLocationSettings();
return;
}
} catch (error) {
console.error("[WifiSsid] Error opening location settings:", error);
export function getSSIDSync(): string | null {
if (!WifiSsidModule) {
return null;
}
Linking.openSettings();
}
// Both native modules return { ssid, connectedToWifi }. The string branch is a
// defensive fallback in case a build returns the older plain-string shape.
function normalize(result: unknown): WifiSsidResult {
if (typeof result === "string") {
const ssid = result || null;
return { ssid, connectedToWifi: ssid !== null };
try {
return WifiSsidModule.getSSIDSync() ?? null;
} catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error);
return null;
}
if (result && typeof result === "object") {
const obj = result as { ssid?: string | null; connectedToWifi?: boolean };
return {
ssid: obj.ssid ?? null,
connectedToWifi: Boolean(obj.connectedToWifi),
};
}
return { ssid: null, connectedToWifi: false };
}

View File

@@ -1,113 +1,50 @@
import ExpoModulesCore
#if !os(tvOS)
import Network
import NetworkExtension
import SystemConfiguration.CaptiveNetwork
#endif
import UIKit
public class WifiSsidModule: Module {
#if !os(tvOS)
// NWPathMonitor reports whether the active path uses Wi-Fi WITHOUT requiring
// the wifi-info entitlement, so we can tell "on Wi-Fi but can't read the SSID"
// apart from "not connected to Wi-Fi" even when fetchCurrent returns nil.
private let pathMonitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "expo.modules.wifissid.pathmonitor")
private let lock = NSLock()
private var cachedOnWifi = false
private var pathMonitorStarted = false
private var isOnWifi: Bool {
lock.lock()
defer { lock.unlock() }
return cachedOnWifi
}
private func startPathMonitorIfNeeded() {
lock.lock()
if pathMonitorStarted {
lock.unlock()
return
}
pathMonitorStarted = true
lock.unlock()
pathMonitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let onWifi = path.status == .satisfied && path.usesInterfaceType(.wifi)
self.lock.lock()
self.cachedOnWifi = onWifi
self.lock.unlock()
}
pathMonitor.start(queue: monitorQueue)
}
deinit {
pathMonitor.cancel()
}
#endif
public func definition() -> ModuleDefinition {
Name("WifiSsid")
AsyncFunction("getSSID") { () -> [String: Any] in
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
// Not available on tvOS
AsyncFunction("getSSID") { () -> String? in
#if os(tvOS)
return ["ssid": NSNull(), "connectedToWifi": false]
return nil
#else
self.startPathMonitorIfNeeded()
let ssid = await self.resolveSSID()
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
if let ssid = ssid {
result["ssid"] = ssid
} else {
result["ssid"] = NSNull()
}
return result
#endif
}
Function("getSSIDSync") { () -> [String: Any] in
#if os(tvOS)
return ["ssid": NSNull(), "connectedToWifi": false]
#else
self.startPathMonitorIfNeeded()
let ssid = self.getSSIDViaCNCopy()
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
if let ssid = ssid {
result["ssid"] = ssid
} else {
result["ssid"] = NSNull()
}
return result
#endif
}
Function("openLocationSettings") {
#if !os(tvOS)
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
return await withCheckedContinuation { continuation in
NEHotspotNetwork.fetchCurrent { network in
if let ssid = network?.ssid {
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
continuation.resume(returning: ssid)
} else {
// Fallback to CNCopyCurrentNetworkInfo for older iOS
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
let ssid = self.getSSIDViaCNCopy()
continuation.resume(returning: ssid)
}
}
}
#endif
}
// Synchronous version using only CNCopyCurrentNetworkInfo
// Not available on tvOS
Function("getSSIDSync") { () -> String? in
#if os(tvOS)
return nil
#else
return self.getSSIDViaCNCopy()
#endif
}
}
#if !os(tvOS)
private func resolveSSID() async -> String? {
await withCheckedContinuation { continuation in
NEHotspotNetwork.fetchCurrent { network in
if let ssid = network?.ssid {
continuation.resume(returning: ssid)
} else {
continuation.resume(returning: self.getSSIDViaCNCopy())
}
}
}
}
private func getSSIDViaCNCopy() -> String? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
return nil
}
@@ -117,10 +54,12 @@ public class WifiSsidModule: Module {
}
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
return ssid
}
}
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
return nil
}
#endif

View File

@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
}

View File

@@ -289,24 +289,7 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
const totalSize = calculateTotalDownloadedSize();
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -327,7 +310,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, [processes]);
}, []);
return {
startBackgroundDownload,

View File

@@ -15,6 +15,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
@@ -91,6 +92,12 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(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;
@@ -108,6 +115,11 @@ interface JellyfinContextValue {
serverName?: string,
options?: LoginOptions,
) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
@@ -165,6 +177,46 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
storage.remove("token");
storage.remove("user");
setUser(null);
setApi(null);
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Saved credentials are kept so the user can quick-login again.
}, [setUser, setApi, queryClient]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => {
if (!deviceId) return {};
return {
@@ -307,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,
@@ -386,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",
),
);
}
@@ -509,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
},
onError: (error) => {
console.error("Quick login failed:", error);
// Expected, handled case (e.g. revoked token → "Session Expired", or
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
},
});
@@ -620,54 +705,62 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// Validate the token and refresh user data in the background. Do NOT
// await this: the Jellyfin SDK axios instance has no timeout, so when
// offline this call hangs for the full OS TCP timeout (75-120s) and
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
setUser(response.data);
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
})
.catch((e) => {
// Expected, handled case (offline, or a token the server rejects —
// the UI prompts re-login): warn, don't error. Log only
// status/message — never the raw error (axios errors carry the
// request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
);
});
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -681,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,

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -19,7 +18,6 @@ interface ServerUrlContextValue {
effectiveServerUrl: string | null;
isUsingLocalUrl: boolean;
currentSSID: string | null;
connectedToWifi: boolean;
refreshUrlState: () => void;
}
@@ -34,7 +32,7 @@ interface Props {
export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin();
const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
const { ssid, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
@@ -103,25 +101,15 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
};
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
const value = useMemo(
() => ({
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
connectedToWifi,
refreshUrlState,
}),
[
effectiveServerUrl,
isUsingLocalUrl,
ssid,
connectedToWifi,
refreshUrlState,
],
);
return (
<ServerUrlContext.Provider value={value}>
<ServerUrlContext.Provider
value={{
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
refreshUrlState,
}}
>
{children}
</ServerUrlContext.Provider>
);

View File

@@ -1,157 +0,0 @@
import type React from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Platform } from "react-native";
import { getSSID } from "@/modules/wifi-ssid";
export type PermissionStatus =
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
connectedToWifi: boolean;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
isLoading: boolean;
}
const WifiSsidContext = createContext<UseWifiSSIDReturn | null>(null);
// WiFi SSID is not available on tvOS, and expo-location isn't needed there.
const Location = Platform.isTV ? null : require("expo-location");
const POLL_INTERVAL_MS = 10_000;
function mapLocationStatus(status: number | undefined): PermissionStatus {
if (!Location) return "unavailable";
switch (status) {
case Location.PermissionStatus?.GRANTED:
return "granted";
case Location.PermissionStatus?.DENIED:
return "denied";
default:
return "undetermined";
}
}
interface Props {
children: ReactNode;
}
/**
* Single source of truth for the current WiFi SSID and the location permission
* that gates it. State is shared via context so that granting permission in one
* place (e.g. the network settings screen) is reflected everywhere else — most
* importantly ServerUrlProvider, which switches between the local and remote
* server URLs based on the connected SSID.
*
* Previously each consumer created its own useWifiSSID instance with independent
* state, so the provider never learned that permission had been granted later
* in the session and reported "not connected to WiFi" forever.
*/
export function WifiSsidProvider({ children }: Props): React.ReactElement {
const [ssid, setSSID] = useState<string | null>(null);
const [connectedToWifi, setConnectedToWifi] = useState(false);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
Platform.isTV ? "unavailable" : "undetermined",
);
const [isLoading, setIsLoading] = useState(!Platform.isTV);
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
setSSID(result.ssid);
setConnectedToWifi(result.connectedToWifi);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (Platform.isTV || !Location) {
setPermissionStatus("unavailable");
return false;
}
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const newStatus = mapLocationStatus(status);
setPermissionStatus(newStatus);
return newStatus === "granted";
} catch {
setPermissionStatus("unavailable");
return false;
}
}, []);
// Check the location permission once on mount. The poll effect below takes
// over fetching the SSID whenever permission becomes "granted".
useEffect(() => {
if (Platform.isTV || !Location) {
setIsLoading(false);
return;
}
let cancelled = false;
async function initialize() {
setIsLoading(true);
try {
const { status } = await Location.getForegroundPermissionsAsync();
if (cancelled) return;
setPermissionStatus(mapLocationStatus(status));
} catch {
if (!cancelled) setPermissionStatus("unavailable");
}
if (!cancelled) setIsLoading(false);
}
initialize();
return () => {
cancelled = true;
};
}, []);
// Fetch the SSID immediately, then poll, whenever permission is granted.
// Because permissionStatus is shared, this also covers the case where
// permission was granted later (from another consumer) than this provider's
// mount time — the original root cause of the "not connected to WiFi" bug.
useEffect(() => {
if (Platform.isTV || permissionStatus !== "granted") return;
fetchSSID();
const interval = setInterval(fetchSSID, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [permissionStatus, fetchSSID]);
const value = useMemo(
() => ({
ssid,
connectedToWifi,
permissionStatus,
requestPermission,
isLoading,
}),
[ssid, connectedToWifi, permissionStatus, requestPermission, isLoading],
);
return (
<WifiSsidContext.Provider value={value}>
{children}
</WifiSsidContext.Provider>
);
}
export function useWifiSsid(): UseWifiSSIDReturn {
const context = useContext(WifiSsidContext);
if (!context) {
throw new Error("useWifiSsid must be used within WifiSsidProvider");
}
return context;
}

View File

@@ -16,14 +16,14 @@
"got_it": "Got it",
"connection_failed": "Connection failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occurred": "An unexpected error occurred",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server",
"invalid_username_or_password": "Invalid username or password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin server discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
@@ -174,10 +174,7 @@
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to Wi-Fi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings.",
"ssid_hidden": "SSID hidden",
"location_off_description": "Connected to Wi-Fi, but the network name is hidden. Enable device Location services and grant the app location permission to detect it.",
"open_location_settings": "Open Location settings"
"permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings."
},
"user_info": {
"user_info_title": "User info",
@@ -191,7 +188,7 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
"success": "Success",
"quick_connect_authorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid code",
"authorize": "Authorize"
@@ -301,7 +298,7 @@
"show_custom_menu_links": "Show custom menu links",
"show_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable haptic feedback",
"default_quality": "Default quality",
"default_playback_speed": "Default playback speed",
@@ -387,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete all downloaded files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache",
@@ -603,7 +598,7 @@
"mpv_player_title": "MPV player",
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}",