mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 10:14:17 +01:00
Compare commits
12 Commits
fix/skip-b
...
fix/auth-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f08b51e1e | ||
|
|
06076b1a85 | ||
|
|
e79ae53e4e | ||
|
|
1860863c60 | ||
|
|
69651cdfa3 | ||
|
|
25ee7dad85 | ||
|
|
34d9b43dcb | ||
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
98dfefbdd0 | ||
|
|
49a903f48a |
@@ -854,6 +854,13 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Image } from "expo-image";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -551,6 +552,7 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
52
components/PendingAccountSaveModal.tsx
Normal file
52
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import {
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
writeErrorLog(`Failed to save account: ${error?.message ?? error}`);
|
||||||
|
toast.error(t("save_account.not_saved"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
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 { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
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
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
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(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro]);
|
}, [showIntro, pendingAccountSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
|
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
|
settings?.useEpisodeImagesForNextUp,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster
|
||||||
|
item={item}
|
||||||
|
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,13 +21,14 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import type { SavedServer } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -35,6 +36,7 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
|
|||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
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 [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||||
username: string;
|
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
// 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
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,29 +131,22 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
setPendingAccountSave({ serverName });
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
@@ -149,23 +156,9 @@ export const Login: React.FC = () => {
|
|||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
|
setQuickConnectActive(true);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.quick_connect"),
|
t("login.quick_connect"),
|
||||||
t("login.enter_code_to_login", { code: code }),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
@@ -443,16 +437,6 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
<SaveAccountModal
|
|
||||||
visible={showSaveModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
setPendingLogin(null);
|
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
|
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -44,6 +45,9 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
@@ -52,15 +56,32 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.use_episode_images_next_up")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.use_episode_images_next_up_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
|
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.hide_remote_session_button_hint",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.hideRemoteSessionButton}
|
value={settings.hideRemoteSessionButton}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export interface TVPosterCardProps {
|
|||||||
|
|
||||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
|
||||||
|
/** For horizontal episodes, prefer the episode's own image over the series thumb */
|
||||||
|
preferEpisodeImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +111,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
glowColor = "white",
|
glowColor = "white",
|
||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
|
preferEpisodeImage = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -139,6 +143,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
|
// Opt-in: use the episode's own image instead of the series thumb.
|
||||||
|
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
// First try parent/series thumb (horizontal series artwork).
|
// First try parent/series thumb (horizontal series artwork).
|
||||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
@@ -174,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
item,
|
item,
|
||||||
width: width * 2, // 2x for quality on large screens
|
width: width * 2, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item, orientation, width, imageUrlGetter]);
|
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
||||||
|
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState, Platform } from "react-native";
|
||||||
import { getDeviceNameSync } from "react-native-device-info";
|
import { getDeviceNameSync } from "react-native-device-info";
|
||||||
import uuid from "react-native-uuid";
|
import uuid from "react-native-uuid";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
@@ -91,6 +93,12 @@ export const apiAtom = atom<Api | null>(initialApi);
|
|||||||
export const userAtom = atom<UserDto | null>(initialUser);
|
export const userAtom = atom<UserDto | null>(initialUser);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
export const cacheVersionAtom = atom<number>(0);
|
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 {
|
interface LoginOptions {
|
||||||
saveAccount?: boolean;
|
saveAccount?: boolean;
|
||||||
@@ -108,6 +116,11 @@ interface JellyfinContextValue {
|
|||||||
serverName?: string,
|
serverName?: string,
|
||||||
options?: LoginOptions,
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveCurrentAccount: (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
stopQuickConnectPolling: () => void;
|
stopQuickConnectPolling: () => void;
|
||||||
@@ -165,6 +178,69 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
const queryClient = useQueryClient();
|
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);
|
||||||
|
|
||||||
|
// Shared teardown for manual logout AND forced session expiry — keeping it
|
||||||
|
// in one place prevents the two paths from drifting (a 401 expiry must wipe
|
||||||
|
// plugin settings / Jellyseerr state too, or the next login on the same
|
||||||
|
// device inherits the previous user's data).
|
||||||
|
// Saved credentials are kept so the user can quick-login again.
|
||||||
|
const clearSessionState = useCallback(async () => {
|
||||||
|
// All synchronous teardown first: if the async Jellyseerr cleanup below
|
||||||
|
// fails or resolves late (user may already be re-authenticating), the
|
||||||
|
// session/cache state is already gone.
|
||||||
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||||
|
clearTVDiscoverySafely();
|
||||||
|
setUser(null);
|
||||||
|
setApi(null);
|
||||||
|
setPluginSettings(undefined);
|
||||||
|
queryClient.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clearAllJellyseerData();
|
||||||
|
} catch (e) {
|
||||||
|
writeErrorLog(
|
||||||
|
`Failed to clear Jellyseerr data: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [setUser, setApi, setPluginSettings, clearAllJellyseerData, queryClient]);
|
||||||
|
|
||||||
|
const handleSessionExpired = useCallback(() => {
|
||||||
|
if (sessionExpiredRef.current) return; // run once per session
|
||||||
|
sessionExpiredRef.current = true;
|
||||||
|
clearSessionState().catch((e) =>
|
||||||
|
writeErrorLog(`Session-expiry cleanup failed: ${e?.message ?? e}`),
|
||||||
|
);
|
||||||
|
}, [clearSessionState]);
|
||||||
|
|
||||||
|
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(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
@@ -307,6 +383,40 @@ 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") {
|
||||||
|
// Never persist a "pin" credential without its hash — it would be
|
||||||
|
// impossible to unlock.
|
||||||
|
if (!options?.pinCode) throw new Error("PIN code is required");
|
||||||
|
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({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
username,
|
username,
|
||||||
@@ -338,18 +448,25 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (securityType === "pin" && options.pinCode) {
|
if (securityType === "pin" && options.pinCode) {
|
||||||
pinHash = await hashPIN(options.pinCode);
|
pinHash = await hashPIN(options.pinCode);
|
||||||
}
|
}
|
||||||
|
if (securityType === "pin" && !pinHash) {
|
||||||
await saveAccountCredential({
|
// Never persist a "pin" credential without its hash — it would be
|
||||||
serverUrl: api.basePath,
|
// impossible to unlock. Skip the save rather than failing a login
|
||||||
serverName: serverName || "",
|
// that already succeeded, and tell the user it didn't happen.
|
||||||
token: auth.data.AccessToken,
|
writeErrorLog("Account save skipped: PIN required but missing");
|
||||||
userId: auth.data.User.Id || "",
|
toast.error(t("save_account.not_saved"));
|
||||||
username,
|
} else {
|
||||||
savedAt: Date.now(),
|
await saveAccountCredential({
|
||||||
securityType,
|
serverUrl: api.basePath,
|
||||||
pinHash,
|
serverName: serverName || "",
|
||||||
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
token: auth.data.AccessToken,
|
||||||
});
|
userId: auth.data.User.Id || "",
|
||||||
|
username,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType,
|
||||||
|
pinHash,
|
||||||
|
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
@@ -409,19 +526,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
writeErrorLog("Failed to delete expo push token for device"),
|
writeErrorLog("Failed to delete expo push token for device"),
|
||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
await clearSessionState();
|
||||||
storage.remove("user");
|
|
||||||
clearTVDiscoverySafely();
|
|
||||||
setUser(null);
|
|
||||||
setApi(null);
|
|
||||||
setPluginSettings(undefined);
|
|
||||||
await clearAllJellyseerData();
|
|
||||||
|
|
||||||
// Clear React Query cache to prevent data from previous account lingering
|
|
||||||
queryClient.clear();
|
|
||||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
||||||
|
|
||||||
// Note: We keep saved credentials for quick switching back
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
@@ -509,7 +614,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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 +727,66 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
// Validate the token and refresh user data in the background. Do NOT
|
||||||
// fetch fresh user data in the background
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||||
setInitialLoaded(true);
|
// 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) => {
|
||||||
|
// The response can resolve long after startup (no axios timeout).
|
||||||
|
// If the session changed meanwhile (logout, account switch), drop
|
||||||
|
// it instead of repopulating a stale user / re-saving credentials.
|
||||||
|
if (getTokenFromStorage() !== token) return;
|
||||||
|
setUser(response.data);
|
||||||
|
|
||||||
try {
|
// Migrate current session to secure storage if not already saved
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
setUser(response.data);
|
const existingCredential = await getAccountCredential(
|
||||||
|
|
||||||
// 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({
|
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverName: "",
|
storedUser.Id,
|
||||||
token,
|
);
|
||||||
userId: storedUser.Id,
|
if (!existingCredential) {
|
||||||
username: storedUser.Name,
|
await saveAccountCredential({
|
||||||
savedAt: Date.now(),
|
serverUrl,
|
||||||
securityType: "none",
|
serverName: "",
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
token,
|
||||||
});
|
userId: storedUser.Id,
|
||||||
} else if (
|
username: storedUser.Name,
|
||||||
response.data.PrimaryImageTag !==
|
savedAt: Date.now(),
|
||||||
existingCredential.primaryImageTag
|
securityType: "none",
|
||||||
) {
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
// Update image tag if it has changed
|
});
|
||||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
} else if (
|
||||||
userId: existingCredential.userId,
|
response.data.PrimaryImageTag !==
|
||||||
username: existingCredential.username,
|
existingCredential.primaryImageTag
|
||||||
securityType: existingCredential.securityType,
|
) {
|
||||||
savedAt: existingCredential.savedAt,
|
// Update image tag if it has changed
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||||
});
|
userId: existingCredential.userId,
|
||||||
|
username: existingCredential.username,
|
||||||
|
securityType: existingCredential.securityType,
|
||||||
|
savedAt: existingCredential.savedAt,
|
||||||
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (e) {
|
.catch((e) => {
|
||||||
// Background fetch failed — app already rendered with cached data
|
// Expected, handled case (offline, or a token the server rejects —
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
// the UI prompts re-login): warn, don't error. Log only
|
||||||
}
|
// status/message — never the raw error (axios errors carry the
|
||||||
} else {
|
// request config incl. the Authorization header / token).
|
||||||
setInitialLoaded(true);
|
console.warn(
|
||||||
|
"Background user validation failed:",
|
||||||
|
e?.response?.status ?? e?.message ?? "unknown error",
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -681,6 +800,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName, options) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
|
saveCurrentAccount,
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
stopQuickConnectPolling,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save account",
|
"title": "Save account",
|
||||||
"save_for_later": "Save this account",
|
"save_for_later": "Save this account",
|
||||||
|
"not_saved": "Account was not saved",
|
||||||
"security_option": "Security option",
|
"security_option": "Security option",
|
||||||
"no_protection": "No protection",
|
"no_protection": "No protection",
|
||||||
"no_protection_desc": "Quick login without authentication",
|
"no_protection_desc": "Quick login without authentication",
|
||||||
@@ -137,7 +138,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
"merge_next_up_continue_watching": "Merge Continue watching & Next up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide remote session button",
|
"hide_remote_session_button": "Hide remote session button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic home backdrop",
|
"show_home_backdrop": "Dynamic home backdrop",
|
||||||
"show_hero_carousel": "Hero carousel",
|
"show_hero_carousel": "Hero carousel",
|
||||||
"show_series_poster_on_episode": "Show series poster on episodes",
|
"show_series_poster_on_episode": "Show series poster on episodes",
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"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_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ export type Settings = {
|
|||||||
hideBrightnessSlider: boolean;
|
hideBrightnessSlider: boolean;
|
||||||
usePopularPlugin: boolean;
|
usePopularPlugin: boolean;
|
||||||
mergeNextUpAndContinueWatching: boolean;
|
mergeNextUpAndContinueWatching: boolean;
|
||||||
|
// Use the episode's own image (instead of the series thumb) for the
|
||||||
|
// "Next Up" and "Continue Watching" home rows.
|
||||||
|
useEpisodeImagesForNextUp: boolean;
|
||||||
// TV-specific settings
|
// TV-specific settings
|
||||||
showHomeBackdrop: boolean;
|
showHomeBackdrop: boolean;
|
||||||
showTVHeroCarousel: boolean;
|
showTVHeroCarousel: boolean;
|
||||||
@@ -382,6 +385,7 @@ export const defaultValues: Settings = {
|
|||||||
hideBrightnessSlider: false,
|
hideBrightnessSlider: false,
|
||||||
usePopularPlugin: true,
|
usePopularPlugin: true,
|
||||||
mergeNextUpAndContinueWatching: false,
|
mergeNextUpAndContinueWatching: false,
|
||||||
|
useEpisodeImagesForNextUp: false,
|
||||||
// TV-specific settings
|
// TV-specific settings
|
||||||
showHomeBackdrop: true,
|
showHomeBackdrop: true,
|
||||||
showTVHeroCarousel: true,
|
showTVHeroCarousel: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user