Compare commits

..

4 Commits

Author SHA1 Message Date
Gauvain
1f7d24d63f fix(nav): block same-batch duplicate pushes from TV remote double-fire 2026-07-15 01:12:52 +02:00
Gauvain
7b15b15af3 fix(nav): clarify the focus-guard comment scope 2026-07-15 00:33:27 +02:00
Gauvain
f8b8cddbfa fix(navigation): dismiss the keyboard when using the header back button
Leaving a screen with the keyboard up (e.g. the Jellyseerr login) left it
lingering over the previous screen. Dismiss it before navigating back.
2026-07-15 00:32:53 +02:00
Gauvain
3bd7507462 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-07-15 00:32:03 +02:00
7 changed files with 63 additions and 86 deletions

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native";
import { Keyboard, Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
@@ -16,10 +16,17 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
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") {
return (
<Pressable
onPress={() => router.back()}
onPress={handleBack}
className='flex items-center justify-center w-9 h-9'
{...pressableProps}
>
@@ -30,7 +37,7 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android")
return (
<Pressable onPress={() => router.back()} {...pressableProps}>
<Pressable onPress={handleBack} {...pressableProps}>
<BlurView
{...props}
intensity={100}
@@ -48,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<Pressable
onPress={() => router.back()}
onPress={handleBack}
className=' rounded-full p-2'
{...pressableProps}
>

View File

@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
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 seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
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 onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,13 +1,19 @@
// 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 { useCallback, useMemo } from "react";
import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
/**
* Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation.
* preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
*
* - For object-form navigation, automatically adds offline=true when in offline context
* - 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
* import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,36 @@ export function useAppRouter() {
const router = useRouter();
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(
(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") {
router.push(href as any);
} else {
@@ -36,7 +70,7 @@ export function useAppRouter() {
} as any);
}
},
[router, isOffline],
[router, isOffline, navigation],
);
const replace = useCallback(

View File

@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
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);
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
}

View File

@@ -289,24 +289,7 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
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.
}
}
const totalSize = calculateTotalDownloadedSize();
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -327,7 +310,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, [processes]);
}, []);
return {
startBackgroundDownload,

View File

@@ -384,8 +384,6 @@
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"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_description": "Automatically cache songs as you listen for smoother playback and offline support",
"clear_music_cache": "Clear music cache",