Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ view already is (a swipe's momentum end, a jump) or on resize, and the prop
diff never sends it mid-gesture or mid-tap. react-native-web ignores the prop;
web keeps its layout-effect jump, which has no race there.

The episode pager can first render a single episode while its seasons list is
still loading. On Android, when the list arrives, its scroll view changes key so the full
sequence and the opened episode's offset mount together. Updating the existing
view's offset alongside a wider content container has the same Android race.
This one-time remount resets vertical scroll and expanded content if the user
has already started reading. iOS keeps the same subtree and its reading state.

A settle more than one page from the selected cour is still refused and jumped
back, so the URL can't follow a position nobody chose.

Expand Down
35 changes: 23 additions & 12 deletions src/features/episode-details/screen/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { useRef, useState } from 'react';
import { type ScrollViewInstance, Text, useWindowDimensions, View } from 'react-native';
import { useDeferredValue, useRef, useState } from 'react';
import { Platform, type ScrollViewInstance, Text, useWindowDimensions, View } from 'react-native';
import { FadeOut, useReducedMotion } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// oxlint-disable-next-line no-restricted-imports -- one composed colour, see the call site.
Expand Down Expand Up @@ -61,15 +61,15 @@ const BOUNDARY_TOLERANCE = 2;
*/
export function EpisodeScreen({ item, season, number, onBack }: EpisodeScreenProps) {
const view = useEpisode(item, season, number);
if (view.isLoading || view.orderLoading) {
// A long-running show's seasons list can arrive well after this episode.
if (view.isLoading) {
return <EpisodeScreenSkeleton onBack={onBack} />;
}
return (
<EpisodePager item={item} number={number} onBack={onBack} order={view.order} season={season} />
);
}

/** Mounted once the order is known, so `settled` starts on the opened episode. */
function EpisodePager({
item,
season,
Expand All @@ -89,10 +89,19 @@ function EpisodePager({
);
const order = found === -1 ? [{ season, number }] : fullOrder;
const index = Math.max(found, 0);
// The page the scroll last rested on: what the mounted window is centred on
// and where `contentOffset` points. Not `index` — that moves when a button
// step starts, and a changed `contentOffset` would cut its scroll short.
const [settled, setSettled] = useState(index);
// The episode the scroll last rested on: what the mounted window is centred
// on and where `contentOffset` points. Not `index` — that moves when a button
// step starts, and a changed `contentOffset` would cut its scroll short. An
// episode rather than a page, so it stays put when the order loads around it.
const [settledEpisode, setSettledEpisode] = useState<EpisodeRef>({ season, number });
const settled = Math.max(
order.findIndex(
(entry) => entry.season === settledEpisode.season && entry.number === settledEpisode.number,
),
0,
);
// Neighbours mount in a deferred pass, so the opened page commits alone first.
const reach = useDeferredValue(1, 0);

function commit(target: EpisodeRef) {
router.setParams({ episode: [String(target.season), String(target.number)] });
Expand All @@ -103,7 +112,7 @@ function EpisodePager({
if (Math.abs(x - page * width) > BOUNDARY_TOLERANCE) return;
const landed = order[page];
if (landed == null) return;
setSettled(page);
setSettledEpisode(landed);
if (page === index) return;
haptics.selection();
commit(landed);
Expand All @@ -116,12 +125,12 @@ function EpisodePager({
if (page === -1) return;
scroller.current?.scrollTo({ x: page * width, animated: !reduceMotion });
// A jump fires no momentum event.
if (reduceMotion) setSettled(page);
if (reduceMotion) setSettledEpisode(target);
commit(target);
}

const first = Math.max(Math.min(settled - 1, index), 0);
const last = Math.min(Math.max(settled + 1, index), order.length - 1);
const first = Math.max(Math.min(settled - reach, index), 0);
const last = Math.min(Math.max(settled + reach, index), order.length - 1);

return (
<View
Expand All @@ -132,6 +141,8 @@ function EpisodePager({
<GestureScrollView
contentOffset={{ x: settled * width, y: 0 }}
horizontal
// Android applies offsets before layout; mount the expanded content with its offset.
key={Platform.OS === 'android' && found === -1 ? 'pending' : 'resolved'}
onMomentumScrollEnd={(event) => settleAt(event.nativeEvent.contentOffset.x)}
pagingEnabled
ref={scroller}
Expand Down
3 changes: 0 additions & 3 deletions src/features/episode-details/use-episode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ export interface EpisodeView {
tmdbId: number | undefined;
/** The sequence this episode sits in (`episodeOrder`); empty until the seasons list loads. */
order: EpisodeRef[];
/** The seasons list is still in flight, so `order` is not final. */
orderLoading: boolean;
}

/**
Expand Down Expand Up @@ -66,6 +64,5 @@ export function useEpisode(
isLoading: episode == null && (seasons.isPending || tmdb.isPending),
tmdbId,
order: seasons.data == null ? [] : episodeOrder(seasons.data, season),
orderLoading: seasons.isLoading,
};
}
Loading