Compare commits

..

6 Commits

Author SHA1 Message Date
Gauvain
8b02cfcb65 fix(logs): pin the filter bar on iOS, scroll/expand/copy log entries on Android 2026-07-15 18:44:09 +02:00
Gauvain
3020bf5903 fix(ui): unify the header back icon and fix its Android alignment 2026-07-15 18:44:05 +02:00
Gauvain
1f76119082 fix(settings): align the Settings/Plugins back chevron with the edge 2026-07-15 18:43:54 +02:00
Gauvain
2e79815d78 fix(downloads): delete-all confirmation, live storage usage, series poster cache (#1808)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 11:19:51 +02:00
Gauvain
43ce50fe70 fix(notifications): drop deprecated handler flags and payload logging (#1805)
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 10:50:47 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 08:36:11 +02:00
19 changed files with 223 additions and 150 deletions

View File

@@ -73,7 +73,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
@@ -158,7 +157,6 @@ export default function IndexLayout() {
headerLeft: () => ( headerLeft: () => (
<Pressable <Pressable
onPress={() => _router.back()} onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { File, Paths } from "expo-file-system"; import { File, Paths } from "expo-file-system";
import { requireOptionalNativeModule } from "expo-modules-core";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing"; import type * as SharingType from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -72,6 +75,25 @@ export default function Page() {
} }
}, [filteredLogs, Sharing]); }, [filteredLogs, Sharing]);
const copyLog = useCallback(
async (log: NonNullable<typeof logs>[number]) => {
// Skip on builds that don't ship the expo-clipboard native module
// (probe returns null instead of throwing); same guard as Quick Connect.
if (!requireOptionalNativeModule("ExpoClipboard")) return;
const Clipboard = await import("expo-clipboard");
const text = [
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
log.message,
log.data ? JSON.stringify(log.data, null, 2) : null,
]
.filter(Boolean)
.join("\n");
await Clipboard.setStringAsync(text);
toast.success(t("home.settings.logs.copied"));
},
[logs, t],
);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -88,8 +110,15 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View className='flex-1'> <ScrollView
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> // Like the sibling settings pages, let iOS auto-inset the content below the
// transparent header (no manual header-height math). The filter bar is a
// sticky header so it stays pinned just under the header while logs scroll.
contentInsetAdjustmentBehavior='automatic'
stickyHeaderIndices={[0]}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
queryKey='log' queryKey='log'
@@ -112,67 +141,77 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView <View className='flex flex-col space-y-2 px-4'>
className='pb-4 px-4' {filteredLogs?.map((log, index) => (
contentContainerStyle={{ paddingBottom: insets.bottom }} <View className='bg-neutral-900 rounded-xl p-3' key={index}>
> <TouchableOpacity
<View className='flex flex-col space-y-2'> disabled={!log.data}
{filteredLogs?.map((log, index) => ( onPress={() =>
<View className='bg-neutral-900 rounded-xl p-3' key={index}> setState((v) => ({
<TouchableOpacity ...v,
disabled={!log.data} [log.timestamp]: !v[log.timestamp],
onPress={() => }))
setState((v) => ({ }
...v, >
[log.timestamp]: !v[log.timestamp], <View className='flex flex-row justify-between'>
})) <Text
} className={`mb-1
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"} ${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"} ${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"} ${log.level === "DEBUG" && "text-purple-500"}
`} `}
> >
{log.level} {log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text selectable className='text-xs'>
{log.message}
</Text> </Text>
</TouchableOpacity>
{log.data && ( <Text className='text-xs'>
<> {new Date(log.timestamp).toLocaleString()}
{!state[log.timestamp] && ( </Text>
<Text className='text-xs mt-0.5'> </View>
{t("home.settings.logs.click_for_more_info")} <Text className='text-xs'>{log.message}</Text>
</Text> {/* Keep the whole collapsed row tappable: the hint lives inside
)} the toggle so tapping it expands too. */}
<Collapsible collapsed={!state[log.timestamp]}> {log.data && !state[log.timestamp] && (
<View className='mt-2 flex flex-col space-y-2'> <Text className='text-xs mt-0.5'>
<ScrollView className='rounded-xl' style={codeBlockStyle}> {t("home.settings.logs.click_for_more_info")}
<Text>{JSON.stringify(log.data, null, 2)}</Text> </Text>
</ScrollView>
</View>
</Collapsible>
</>
)} )}
</View> </TouchableOpacity>
))}
{filteredLogs?.length === 0 && ( {log.data && (
<Text className='opacity-50'> <Collapsible collapsed={!state[log.timestamp]}>
{t("home.settings.logs.no_logs_available")} <View className='mt-2 flex flex-col space-y-2'>
</Text> <ScrollView
)} className='rounded-xl'
</View> style={codeBlockStyle}
</ScrollView> nestedScrollEnabled
</View> >
{/* Only the raw payload is selectable (per request); the
header/message stay tap-to-toggle. */}
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
{!Platform.isTV && (
<TouchableOpacity
onPress={() => copyLog(log)}
className='flex flex-row items-center self-end px-2 py-1'
>
<Ionicons name='copy-outline' size={16} color='white' />
<Text className='text-xs ml-1'>
{t("home.settings.logs.copy")}
</Text>
</TouchableOpacity>
)}
</View>
</Collapsible>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
); );
} }

View File

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

View File

@@ -85,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -350,9 +351,12 @@ function Layout() {
notificationListener.current = notificationListener.current =
Notifications?.addNotificationReceivedListener( Notifications?.addNotificationReceivedListener(
(notification: Notification) => { (notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log( console.log(
"Notification received while app running", "Notification received while app running:",
notification, notification.request.content.title,
); );
}, },
); );

View File

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.18", "expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8", "expo-camera": "~56.0.8",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",
@@ -1001,6 +1002,8 @@
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="], "expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="], "expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { Keyboard, Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -16,37 +16,30 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable <Pressable
onPress={handleBack} onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
<Ionicons name='arrow-back' size={24} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</Pressable> </Pressable>
); );
} }
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={handleBack} {...pressableProps}> <Pressable onPress={() => router.back()} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
className='overflow-hidden rounded-full p-2' className='overflow-hidden rounded-full p-2'
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</BlurView> </BlurView>
@@ -55,14 +48,17 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={handleBack} onPress={() => router.back()}
className=' rounded-full p-2' // Match the Settings page back button: chevron flush to the edge with a
// 16px gap before the title (the old `p-2` pushed both arrow and title
// too far right). drop-shadow keeps it readable over images.
style={{ marginRight: 16 }}
{...pressableProps} {...pressableProps}
> >
<Ionicons <Feather
className='drop-shadow-2xl' className='drop-shadow-2xl'
name='arrow-back' name='chevron-left'
size={24} size={28}
color='white' color='white'
/> />
</Pressable> </Pressable>

View File

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

View File

@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
} else { } else {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.an_unexpected_error_occured"), t("login.an_unexpected_error_occurred"),
); );
} }
} finally { } finally {

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,13 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { NavigationContext } from "expo-router/react-navigation"; import { useCallback, useMemo } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
/** /**
* Drop-in replacement for expo-router's useRouter that automatically * Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation and guards against duplicate * preserves offline state across navigation.
* screens from rapid taps.
* *
* - For object-form navigation, automatically adds offline=true when in offline context * - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param) * - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
* *
* @example * @example
* import useRouter from "@/hooks/useAppRouter"; * import useRouter from "@/hooks/useAppRouter";
@@ -25,36 +19,8 @@ export function useAppRouter() {
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
// Synchronous re-entry guard for TV: a single remote "select" on Android TV
// can fire onPress more than once within the same JS batch
// (react-native-tvos#110/#138), BEFORE react-navigation commits the pushed
// route — so isFocused() still reads true for the duplicate. The ref flips
// synchronously on the first push and resets when the screen regains focus.
const pushInFlightRef = useRef(false);
useEffect(() => {
if (!navigation) return;
return navigation.addListener("focus", () => {
pushInFlightRef.current = false;
});
}, [navigation]);
const push = useCallback( const push = useCallback(
(href: Parameters<typeof router.push>[0]) => { (href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation) {
if (navigation.isFocused?.() === false || pushInFlightRef.current)
return;
pushInFlightRef.current = true;
}
if (typeof href === "string") { if (typeof href === "string") {
router.push(href as any); router.push(href as any);
} else { } else {
@@ -70,7 +36,7 @@ export function useAppRouter() {
} as any); } as any);
} }
}, },
[router, isOffline, navigation], [router, isOffline],
); );
const replace = useCallback( const replace = useCallback(

View File

@@ -54,6 +54,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.18", "expo-build-properties": "~56.0.18",
"expo-camera": "~56.0.8", "expo-camera": "~56.0.8",
"expo-clipboard": "~56.0.4",
"expo-constants": "~56.0.18", "expo-constants": "~56.0.18",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.20", "expo-dev-client": "~56.0.20",

View File

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

View File

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

View File

@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default: default:
throw new Error( throw new Error(
t( t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url", "login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
), ),
); );
} }

View File

@@ -16,14 +16,14 @@
"got_it": "Got it", "got_it": "Got it",
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An unexpected error occurred", "an_unexpected_error_occurred": "An unexpected error occurred",
"change_server": "Change server", "change_server": "Change server",
"invalid_username_or_password": "Invalid username or password", "invalid_username_or_password": "Invalid username or password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in", "user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.", "server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error", "there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?", "an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin server discovered", "too_old_server_text": "Unsupported Jellyfin server discovered",
"too_old_server_description": "Please update Jellyfin to the latest version" "too_old_server_description": "Please update Jellyfin to the latest version"
}, },
@@ -188,7 +188,7 @@
"authorize_button": "Authorize Quick Connect", "authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the Quick Connect code...", "enter_the_quick_connect_code": "Enter the Quick Connect code...",
"success": "Success", "success": "Success",
"quick_connect_autorized": "Quick Connect authorized", "quick_connect_authorized": "Quick Connect authorized",
"error": "Error", "error": "Error",
"invalid_code": "Invalid code", "invalid_code": "Invalid code",
"authorize": "Authorize" "authorize": "Authorize"
@@ -298,7 +298,7 @@
"show_custom_menu_links": "Show custom menu links", "show_custom_menu_links": "Show custom menu links",
"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_liraries_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.",
"disable_haptic_feedback": "Disable haptic feedback", "disable_haptic_feedback": "Disable haptic feedback",
"default_quality": "Default quality", "default_quality": "Default quality",
"default_playback_speed": "Default playback speed", "default_playback_speed": "Default playback speed",
@@ -384,6 +384,8 @@
"device_usage": "Device {{availableSpace}}%", "device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used", "size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete all downloaded files", "delete_all_downloaded_files": "Delete all downloaded files",
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
"music_cache_title": "Music cache", "music_cache_title": "Music cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache", "clear_music_cache": "Clear music cache",
@@ -408,7 +410,9 @@
"click_for_more_info": "Click for more info", "click_for_more_info": "Click for more info",
"level": "Level", "level": "Level",
"no_logs_available": "No logs available", "no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs" "delete_all_logs": "Delete all logs",
"copy": "Copy",
"copied": "Copied to clipboard"
}, },
"languages": { "languages": {
"title": "Languages", "title": "Languages",
@@ -598,7 +602,7 @@
"mpv_player_title": "MPV player", "mpv_player_title": "MPV player",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error", "client_error": "Client error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}", "message_from_server": "Message from server: {{message}}",