feat(tv): fix home page loading skeletons and initialize auth/network status synchronously

This commit is contained in:
Fredrik Burmester
2026-05-27 10:40:48 +02:00
parent 05d9b8f32c
commit 82eaf62354
8 changed files with 265 additions and 45 deletions

View File

@@ -1,4 +1,7 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,7 +24,13 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearCache as clearAudioCache } from "@/providers/AudioStorage";
import {
apiAtom,
cacheVersionAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
InactivityTimeout,
@@ -30,11 +39,13 @@ import {
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
@@ -43,9 +54,11 @@ export default function SettingsTV() {
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const [, setCacheVersion] = useAtom(cacheVersionAtom);
const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -163,6 +176,86 @@ export default function SettingsTV() {
});
};
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[
{
text: t("common.cancel", "Cancel"),
style: "cancel",
},
{
text: t("common.ok", "OK"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
storage.remove("REACT_QUERY_OFFLINE_CACHE");
await queryClient.resetQueries();
// 2. Clear expo-image cache (memory & disk)
await Image.clearDiskCache();
Image.clearMemoryCache();
// 3. Clear AudioStorage (music) cache
await clearAudioCache();
// 4. Clear TopShelf cache
clearTopShelfCacheSafely();
// 5. Clear Subtitle Cache
storage.remove("downloadedSubtitles.json");
const subtitlesDir = new Directory(
Paths.cache,
"streamyfin-subtitles",
);
if (subtitlesDir.exists) {
await subtitlesDir.delete();
}
// 6. Clear MMKV caches like extracted image colors and other non-essential storage keys
const keysToKeep = [
"settings",
"serverUrl",
"token",
"user",
"deviceId",
"previousServers",
"hasAskedForNotificationPermission",
"hasShownIntro",
"multiAccountMigrated",
"selectedTVServer",
"downloads.v2.json",
];
const allKeys = storage.getAllKeys();
for (const key of allKeys) {
if (!keysToKeep.includes(key)) {
storage.remove(key);
}
}
// 7. Increment cache version to force remount of components
setCacheVersion((v) => v + 1);
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
);
}
},
},
],
);
};
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
@@ -790,6 +883,15 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton
label={t("home.settings.storage.clear_all_cache")}
value=''
onPress={handleClearCache}
isFirst
/>
{/* User Section */}
<TVSectionHeader
title={t("home.settings.user_info.user_info_title")}

View File

@@ -37,7 +37,11 @@ import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
apiAtom,
cacheVersionAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
@@ -69,6 +73,7 @@ export const Home = () => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const cacheVersion = useAtomValue(cacheVersionAtom);
const insets = useSafeAreaInsets();
const { settings } = useSettings();
const scrollRef = useRef<ScrollView>(null);
@@ -669,7 +674,7 @@ export const Home = () => {
);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View key={cacheVersion} style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
{!showHero && settings.showHomeBackdrop && (
<View

View File

@@ -27,7 +27,7 @@ import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = scaleSize(20);
const _SCALE_PADDING = scaleSize(20);
interface Props extends ViewProps {
title?: string | null;
@@ -276,8 +276,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.gaps.small,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
@@ -287,12 +288,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,

View File

@@ -158,7 +158,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
@@ -169,10 +170,31 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
@@ -217,6 +239,7 @@ export const StreamystatsPromotedWatchlists: React.FC<
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const typography = useScaledTVTypography();
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
@@ -291,15 +314,16 @@ export const StreamystatsPromotedWatchlists: React.FC<
width: scaleSize(128),
backgroundColor: "#262626",
borderRadius: scaleSize(4),
marginLeft: SCALE_PADDING,
marginBottom: scaleSize(16),
marginLeft: sizes.padding.horizontal,
marginBottom: scaleSize(20),
}}
/>
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
@@ -310,10 +334,31 @@ export const StreamystatsPromotedWatchlists: React.FC<
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>

View File

@@ -210,7 +210,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
style={{
flexDirection: "row",
gap: sizes.gaps.item,
paddingHorizontal: sizes.padding.scale,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
}}
>
@@ -221,10 +222,31 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
backgroundColor: "#262626",
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>

View File

@@ -19,7 +19,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { AppState, Platform } from "react-native";
import { getDeviceName } from "react-native-device-info";
import { getDeviceNameSync } from "react-native-device-info";
import uuid from "react-native-uuid";
import useRouter from "@/hooks/useAppRouter";
import { useInterval } from "@/hooks/useInterval";
@@ -45,9 +45,44 @@ interface Server {
address: string;
}
export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
const initialApi = (() => {
try {
const token = storage.getString("token") || null;
const serverUrl = storage.getString("serverUrl") || null;
if (serverUrl && token) {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.52.0" },
deviceInfo: {
name: deviceName,
id,
},
});
return jellyfinInstance.createApi(serverUrl, token);
}
} catch (e) {
console.error("Failed to initialize API synchronously:", e);
}
return null;
})();
const initialUser = (() => {
try {
const userStr = storage.getString("user");
if (userStr) {
return JSON.parse(userStr) as UserDto;
}
} catch (e) {
console.error("Failed to parse initial user synchronously:", e);
}
return null;
})();
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);
interface LoginOptions {
saveAccount?: boolean;
@@ -88,29 +123,32 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [jellyfin] = useState<Jellyfin | undefined>(() => {
try {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.52.0" },
deviceInfo: {
name: deviceName,
id,
},
});
} catch (e) {
console.error("Failed to initialize Jellyfin synchronously in state:", e);
return undefined;
}
});
const [deviceId] = useState<string | undefined>(() => {
try {
return getOrSetDeviceId();
} catch {
return undefined;
}
});
const { t } = useTranslation();
useEffect(() => {
(async () => {
const id = getOrSetDeviceId();
const deviceName = await getDeviceName();
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.52.0" },
deviceInfo: {
name: deviceName,
id,
},
}),
);
setDeviceId(id);
})();
}, []);
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);

View File

@@ -35,8 +35,8 @@ async function checkApiReachable(basePath?: string): Promise<boolean> {
}
export function NetworkStatusProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
const [isConnected, setIsConnected] = useState(true);
const [serverConnected, setServerConnected] = useState<boolean | null>(true);
const [loading, setLoading] = useState(false);
const [api] = useAtom(apiAtom);
const queryClient = useQueryClient();

View File

@@ -463,7 +463,13 @@
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
"downloaded_songs_deleted": "Downloaded songs deleted",
"clear_all_cache": "Clear All Cache",
"clear_all_cache_confirm": "Clear All Cache?",
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
"clear_all_cache_success": "Cache Cleared",
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
},
"intro": {
"title": "Intro",