mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-27 17:18:29 +01:00
feat(tv): fix home page loading skeletons and initialize auth/network status synchronously
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user