diff --git a/src/api/video-queries.ts b/src/api/video-queries.ts index 1586b55..d95aeee 100644 --- a/src/api/video-queries.ts +++ b/src/api/video-queries.ts @@ -17,6 +17,10 @@ export const useSearchVideos = createInfiniteQuery< export const useVideoDetail = createQuery({ queryKey: ['video'], fetcher: (variables) => fetchVideoDetail(variables.id), + // The fetcher already times out after 15s; with the default 2 retries a + // dead network keeps the watch screen spinning for ~48s before the error + // screen appears, which reads as a freeze. Fail once, let the user retry. + retry: 0, }); /** Single page of a user's uploads (the site's user page has no pagination). */ diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 0dc1ccb..cc8482a 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -55,7 +55,7 @@ export default function BrowseScreen() { // Default browse = genre only (no query, no sort), matching the site. Query is // added only when typing; sort/tags/broad only when set in the filter sheet. - const { data, fetchNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = useSearchVideos({ variables: { genre, @@ -117,7 +117,12 @@ export default function BrowseScreen() { fetchNextPage()} + onEndReached={() => { + // Short lists fire onEndReached repeatedly (especially with one + // row of results); without the guard each fire queues another + // page fetch and walks the whole site on a struggling network. + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }} onRefresh={() => refetch()} isRefreshing={isFetching && !isLoading} isFetchingNextPage={isFetchingNextPage} diff --git a/src/app/tag/[name].tsx b/src/app/tag/[name].tsx index 56f69bc..021d22a 100644 --- a/src/app/tag/[name].tsx +++ b/src/app/tag/[name].tsx @@ -15,7 +15,7 @@ export default function TagScreen() { // text — the `tags[]` filter knows nothing about them. const searchTags = params.type === 'query' ? undefined : [name]; const query = params.type === 'query' ? name : undefined; - const { data, fetchNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch, isFetching } = useSearchVideos({ variables: { tags: searchTags, query } }); const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]); @@ -29,7 +29,9 @@ export default function TagScreen() { fetchNextPage()} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }} onRefresh={() => refetch()} isRefreshing={isFetching && !isLoading} isFetchingNextPage={isFetchingNextPage} diff --git a/src/app/watch/[id].tsx b/src/app/watch/[id].tsx index df7833f..9aec8e4 100644 --- a/src/app/watch/[id].tsx +++ b/src/app/watch/[id].tsx @@ -17,7 +17,7 @@ import { import { useEvent } from 'expo'; import { Link, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useVideoPlayer } from 'expo-video'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, Share, Text, View } from 'react-native'; export { ScreenErrorBoundary as ErrorBoundary }; @@ -81,13 +81,20 @@ export default function WatchScreen() { const { autoplay } = usePlaybackSettings(); + // Focus guard for the slow-network case: the detail query can resolve long + // after the user has backed out of this screen (it stays mounted in the + // stack). Firing play() then would start a player that was never attached + // to a view — or play audio underneath the previous screen — so autoplay + // must only run while this screen actually has focus. + const isFocusedRef = useRef(true); + // Start playback automatically when the screen is entered or the source // changes (new video via the stack, or a resolution switch) unless the // user opted out in Settings. Coming back from a deeper screen (author / // tag) stays paused — the focus cleanup below paused it on leave. // biome-ignore lint/correctness/useExhaustiveDependencies: videoSource is an intentional trigger — replay when the source lands or switches. useEffect(() => { - if (!autoplay) return; + if (!autoplay || !isFocusedRef.current) return; try { player.play(); } catch { @@ -102,7 +109,9 @@ export default function WatchScreen() { // "shared object already released", which is safe to ignore. useFocusEffect( useCallback(() => { + isFocusedRef.current = true; return () => { + isFocusedRef.current = false; try { player.pause(); } catch {