diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index d80b07e3..8fb8dcef 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -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 */} + + + {/* User Section */} { const { t } = useTranslation(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); + const cacheVersion = useAtomValue(cacheVersionAtom); const insets = useSafeAreaInsets(); const { settings } = useSettings(); const scrollRef = useRef(null); @@ -669,7 +674,7 @@ export const Home = () => { ); return ( - + {/* Dynamic backdrop with crossfade - only shown when hero is disabled */} {!showHero && settings.showHomeBackdrop && ( = ({ 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 = ({ backgroundColor: "#262626", width: itemWidth, aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15, - borderRadius: scaleSize(12), - marginBottom: scaleSize(8), + borderRadius: scaleSize(24), }} /> = ({ 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 = ({ backgroundColor: "#262626", width: posterSizes.poster, aspectRatio: 10 / 15, - borderRadius: scaleSize(12), - marginBottom: scaleSize(8), + borderRadius: scaleSize(24), }} /> + + + Placeholder text here + + ))} @@ -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), }} /> @@ -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), }} /> + + + Placeholder text here + + ))} diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index 151d37a2..ad9e388f 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -210,7 +210,8 @@ export const StreamystatsRecommendations: React.FC = ({ 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 = ({ backgroundColor: "#262626", width: sizes.posters.poster, aspectRatio: 10 / 15, - borderRadius: scaleSize(12), - marginBottom: scaleSize(8), + borderRadius: scaleSize(24), }} /> + + + Placeholder text here + + ))} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index bd4d0f50..da58ef76 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -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(null); -export const userAtom = atom(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(initialApi); +export const userAtom = atom(initialUser); export const wsAtom = atom(null); +export const cacheVersionAtom = atom(0); interface LoginOptions { saveAccount?: boolean; @@ -88,29 +123,32 @@ const JellyfinContext = createContext( export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const [jellyfin, setJellyfin] = useState(undefined); - const [deviceId, setDeviceId] = useState(undefined); + const [jellyfin] = useState(() => { + 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(() => { + 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(false); diff --git a/providers/NetworkStatusProvider.tsx b/providers/NetworkStatusProvider.tsx index 25b4fd62..b71aaeab 100644 --- a/providers/NetworkStatusProvider.tsx +++ b/providers/NetworkStatusProvider.tsx @@ -35,8 +35,8 @@ async function checkApiReachable(basePath?: string): Promise { } export function NetworkStatusProvider({ children }: { children: ReactNode }) { - const [isConnected, setIsConnected] = useState(false); - const [serverConnected, setServerConnected] = useState(null); + const [isConnected, setIsConnected] = useState(true); + const [serverConnected, setServerConnected] = useState(true); const [loading, setLoading] = useState(false); const [api] = useAtom(apiAtom); const queryClient = useQueryClient(); diff --git a/translations/en.json b/translations/en.json index 00f1ab11..a11f2a75 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",