Navbar scroll

This commit is contained in:
Zoe Roux 2026-02-11 19:06:38 +01:00
parent 69a6bff197
commit b3d5b99862
No known key found for this signature in database
14 changed files with 254 additions and 231 deletions

View File

@ -10,7 +10,7 @@
"@expo-google-fonts/sora": "^0.4.2",
"@expo/html-elements": "^0.13.7",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.0.0-beta.31",
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
@ -442,7 +442,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@legendapp/list": ["@legendapp/list@3.0.0-beta.31", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-9KpPvwK/14QTJZVbEgDxjzRwcgSf1gkYSxw/d8GE03uasjnzCHe/fm4qrqpobDLYYt90wuemhjkE3YE18XMKEg=="],
"@legendapp/list": ["@legendapp/list@github:zoriya/legend-list#7acb558", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "zoriya-legend-list-7acb558"],
"@material-symbols/svg-400": ["@material-symbols/svg-400@0.40.2", "", {}, "sha512-e2yEgZW/OveVT1sGaZW1kkRWTPVghjsJYWy+vIea3q08Fv2o7FCYv23PESMyr5D4AaAXdM5dKWkF1e6yIm4swA=="],

View File

@ -20,7 +20,7 @@
"@expo-google-fonts/sora": "^0.4.2",
"@expo/html-elements": "^0.13.7",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^3.0.0-beta.31",
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",

View File

@ -1,4 +1,3 @@
import { getFocusedRouteNameFromRoute } from "@react-navigation/native";
import { Stack } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable, useResolveClassNames } from "uniwind";
@ -26,18 +25,6 @@ export default function Layout() {
headerTintColor: color as string,
}}
>
<Stack.Screen
name="(tabs)"
options={({ route }) => {
if (getFocusedRouteNameFromRoute(route) === "index") {
return {
headerTransparent: true,
headerStyle: { backgroundColor: undefined },
};
}
return {};
}}
/>
<Stack.Screen
name="info/[slug]"
options={{

View File

@ -61,7 +61,7 @@ export const EntryLine = ({
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
className={cn(
"group flex-row items-center",
"group flex-row items-center p-1",
href === null && "opacity-50",
className,
)}

View File

@ -63,7 +63,7 @@ export const ItemGrid = ({
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
className={cn(
"group items-center outline-0",
"group items-center p-1 outline-0",
horizontal && "h-full w-[200px]",
className,
)}

View File

@ -1,4 +1,7 @@
import { ImageBackground as EImageBackground } from "expo-image";
import {
ImageBackground as EImageBackground,
type ImageBackgroundProps,
} from "expo-image";
import type { ComponentProps, ReactNode } from "react";
import type { ImageStyle } from "react-native";
import { Platform, View } from "react-native";
@ -25,8 +28,7 @@ export const ImageBackground = ({
alt?: string;
style?: ImageStyle;
children: ReactNode;
className?: string;
}) => {
} & Partial<ImageBackgroundProps>) => {
const { apiUrl, authToken } = useToken();
if (!src) {

View File

@ -37,7 +37,6 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
if (!ret.apiUrl) {
setTimeout(() => {
console.log("go to login");
router.replace("/login");
}, 0);
}

View File

@ -1,7 +1,7 @@
import { LegendList as RLegendList } from "@legendapp/list";
import type { LegendListProps } from "@legendapp/list";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import { type ComponentType, type ReactElement, useRef } from "react";
import type { ViewStyle } from "react-native";
import { withUniwind } from "uniwind";
import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
import { type QueryIdentifier, useInfiniteFetch } from "./query";
@ -12,8 +12,6 @@ export type Layout = {
layout: "grid" | "horizontal" | "vertical";
};
const LegendList = withUniwind(RLegendList) as typeof RLegendList;
export const InfiniteFetch = <Data, Type extends string = string>({
query,
placeholderCount = 4,
@ -29,8 +27,6 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Header,
fetchMore = true,
contentContainerStyle,
contentContainerClassName,
className,
...props
}: {
query: QueryIdentifier<Data>;
@ -40,6 +36,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
getItemType?: (item: Data, index: number) => Type;
getItemSizeMult?: (item: Data, index: number, type: Type) => number;
getStickyIndices?: (items: Data[]) => number[];
stickyHeaderConfig?: LegendListProps["stickyHeaderConfig"];
Render: (props: { item: Data; index: number }) => ReactElement | null;
Loader: (props: { index: number }) => ReactElement | null;
Empty?: JSX.Element;
@ -48,18 +45,13 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Header?: ComponentType<{ children: JSX.Element }> | ReactElement;
fetchMore?: boolean;
contentContainerStyle?: ViewStyle;
contentContainerClassName?: string;
className?: string;
onScroll?: LegendListProps["onScroll"];
scrollEventThrottle?: LegendListProps["scrollEventThrottle"];
}): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout);
const oldItems = useRef<Data[] | undefined>(undefined);
let {
items,
fetchNextPage,
isFetching,
refetch,
isRefetching,
} = useInfiniteFetch(query);
let { items, fetchNextPage, isFetching, refetch, isRefetching } =
useInfiniteFetch(query);
if (incremental && items) oldItems.current = items;
if (incremental) items ??= oldItems.current;
@ -74,16 +66,15 @@ export const InfiniteFetch = <Data, Type extends string = string>({
isFetching || !items ? [...(items || []), ...placeholders] : items;
return (
<LegendList
<AnimatedLegendList
data={data}
recycleItems
getItemType={getItemType}
estimatedItemSize={getItemSizeMult ? undefined : size}
stickyHeaderIndices={getStickyIndices?.(items ?? [])}
// stickyHeaderConfig={{offset}}
getEstimatedItemSize={
getItemSizeMult
? (idx, item, type) => getItemSizeMult(item, idx, type as Type) * size
? (item, idx, type) => getItemSizeMult(item, idx, type as Type) * size
: undefined
}
renderItem={({ item, index }) =>
@ -101,10 +92,13 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Divider === true ? HR : (Divider as any) || undefined
}
ListEmptyComponent={Empty}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
...contentContainerStyle,
gap,
marginHorizontal: numColumns > 1 ? gap : 0,
marginLeft: numColumns > 1 ? gap : 0,
marginRight: numColumns > 1 ? gap : 0,
}}
{...props}
/>

View File

@ -6,7 +6,7 @@ import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import { Stack } from "expo-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { View, ViewProps } from "react-native";
import { WatchListInfo } from "~/components/items/watchlist-info";
import { Rating } from "~/components/rating";
import {
@ -380,78 +380,76 @@ Description.Loader = ({ ...props }: object) => {
export const Header = ({
kind,
slug,
onImageLayout,
}: {
kind: "movie" | "serie";
slug: string;
onImageLayout?: ViewProps["onLayout"];
}) => {
return (
<>
<Stack.Screen
options={{
headerTransparent: true,
headerStyle: { backgroundColor: undefined },
}}
/>
<Fetch
query={Header.query(kind, slug)}
Render={(data) => (
<View className="flex-1">
<Head
title={data.name}
description={data.description}
image={data.thumbnail?.high}
/>
<ImageBackground
src={data.thumbnail}
quality="high"
alt=""
className="absolute top-0 right-0 left-0 h-[40vh] w-full sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]"
>
<View className="absolute inset-0 bg-linear-to-b from-transparent to-slate-950/70" />
</ImageBackground>
<TitleLine
kind={kind}
slug={slug}
name={data.name}
tagline={data.tagline}
date={getDisplayDate(data)}
rating={data.rating}
runtime={data.kind === "movie" ? data.runtime : null}
poster={data.poster}
playHref={data.kind !== "collection" ? data.playHref : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
watchStatus={
data.kind !== "collection"
? (data.watchStatus?.status ?? null)
: null
}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description
description={data.description}
tags={data.tags}
genres={data.genres}
studios={data.kind !== "collection" ? data.studios! : []}
externalIds={data.externalId}
/>
<Fetch
query={Header.query(kind, slug)}
Render={(data) => (
<View className="flex-1">
<Head
title={data.name}
description={data.description}
image={data.thumbnail?.high}
/>
<ImageBackground
src={data.thumbnail}
quality="high"
alt=""
className="absolute top-0 right-0 left-0 h-[40vh] w-full sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]"
onLayout={onImageLayout}
>
<View className="absolute inset-0 bg-linear-to-b from-transparent to-slate-950/70" />
</ImageBackground>
<TitleLine
kind={kind}
slug={slug}
name={data.name}
tagline={data.tagline}
date={getDisplayDate(data)}
rating={data.rating}
runtime={data.kind === "movie" ? data.runtime : null}
poster={data.poster}
playHref={data.kind !== "collection" ? data.playHref : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
watchStatus={
data.kind !== "collection"
? (data.watchStatus?.status ?? null)
: null
}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description
description={data.description}
tags={data.tags}
genres={data.genres}
studios={data.kind !== "collection" ? data.studios! : []}
externalIds={data.externalId}
/>
{/* {type === "show" && ( */}
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
{/* )} */}
</View>
)}
Loader={() => (
<View className="flex-1">
<View className="absolute top-0 right-0 left-0 h-[40vh] w-full bg-linear-to-b from-transparent to-slate-950/70 sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]" />
<TitleLine.Loader
kind={kind}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description.Loader />
</View>
)}
/>
</>
{/* {type === "show" && ( */}
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
{/* )} */}
</View>
)}
Loader={() => (
<View className="flex-1">
<View
className="absolute top-0 right-0 left-0 h-[40vh] w-full bg-linear-to-b from-transparent to-slate-950/70 sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]"
onLayout={onImageLayout}
/>
<TitleLine.Loader
kind={kind}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description.Loader />
</View>
)}
/>
);
};

View File

@ -1,15 +1,30 @@
import { ScrollView } from "react-native";
import { useState } from "react";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryState } from "~/utils";
import { useScrollNavbar } from "../navbar";
import { Header } from "./header";
export const MovieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
const insets = useSafeAreaInsets();
const [imageHeight, setHeight] = useState(300);
const { scrollHandler, headerProps } = useScrollNavbar({ imageHeight });
return (
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom }}>
<Header kind="movie" slug={slug} />
</ScrollView>
<>
<Animated.View {...headerProps} />
<Animated.ScrollView
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<Header
kind="movie"
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
</Animated.ScrollView>
</>
);
};

View File

@ -1,4 +1,4 @@
import type { ComponentProps } from "react";
import { useState, type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@ -10,6 +10,9 @@ import { Fetch } from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "./header";
import { EntryList } from "./season";
import { useScrollNavbar } from "../navbar";
import Animated from "react-native-reanimated";
import { ViewProps } from "react-native";
export const SvgWave = (props: ComponentProps<typeof Svg>) => {
// aspect-[width/height]: width/height of the svg
@ -54,12 +57,16 @@ NextUp.Loader = () => {
);
};
const SerieHeader = () => {
const [slug] = useQueryState("slug", undefined!);
const SerieHeader = ({
slug,
onImageLayout,
}: {
slug: string;
onImageLayout?: ViewProps["onLayout"];
}) => {
return (
<View className="bg-background">
<Header kind="serie" slug={slug} />
<Header kind="serie" slug={slug} onImageLayout={onImageLayout} />
<Fetch
// Use the same fetch query as header
query={Header.query("serie", slug)}
@ -80,14 +87,27 @@ export const SerieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
const [season] = useQueryState("season", undefined!);
const insets = useSafeAreaInsets();
const [imageHeight, setHeight] = useState(300);
const { scrollHandler, headerProps, headerHeight } = useScrollNavbar({
imageHeight,
});
return (
<View className="flex-1 bg-card">
<Animated.View {...headerProps} />
<EntryList
slug={slug}
season={season}
Header={SerieHeader}
Header={() => (
<SerieHeader
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
)}
contentContainerStyle={{ paddingBottom: insets.bottom }}
onScroll={scrollHandler}
scrollEventThrottle={16}
stickyHeaderConfig={{ offset: headerHeight }}
/>
</View>
);

View File

@ -1,6 +1,7 @@
import Info from "@material-symbols/svg-400/rounded/info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { LinearGradient } from "expo-linear-gradient";
import { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { min, percent, px, rem, vh } from "yoshiki/native";
@ -37,8 +38,7 @@ export const Header = ({
tagline: string | null;
link: string | null;
infoLink: string;
className?: string;
}) => {
} & Partial<ComponentProps<typeof ImageBackground>>) => {
const { t } = useTranslation();
return (

View File

@ -1,7 +1,10 @@
import { RefreshControl, ScrollView } from "react-native";
import { useState } from "react";
import { RefreshControl } from "react-native";
import Animated from "react-native-reanimated";
import { Genre } from "~/models";
import { Fetch, useRefresh } from "~/query";
import { shuffle } from "~/utils";
import { useScrollNavbar } from "../navbar";
import { GenreGrid } from "./genre";
import { Header } from "./header";
import { NewsList } from "./news";
@ -12,46 +15,61 @@ import { WatchlistList } from "./watchlist";
export const HomePage = () => {
const genres = shuffle(Object.values(Genre.enum));
const [isRefreshing, refresh] = useRefresh(HomePage.queries(genres));
const [imageHeight, setHeight] = useState(300);
const { scrollHandler, headerProps } = useScrollNavbar({
imageHeight,
tab: true,
});
return (
<ScrollView
refreshControl={
<RefreshControl onRefresh={refresh} refreshing={isRefreshing} />
}
>
<Fetch
query={Header.query()}
Render={(x) => (
<Header
name={x.name}
tagline={x.kind !== "collection" ? x.tagline : null}
description={x.description}
thumbnail={x.thumbnail}
link={x.kind !== "collection" ? x.playHref : null}
infoLink={x.href}
<>
<Animated.View {...headerProps} />
<Animated.ScrollView
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
progressViewOffset={60}
onRefresh={refresh}
refreshing={isRefreshing}
/>
)}
Loader={Header.Loader}
/>
<WatchlistList />
<NewsList />
{genres
.filter((_, i) => i < 2)
.map((x) => (
<GenreGrid key={x} genre={x} />
))}
<Recommended />
{genres
.filter((_, i) => i >= 2 && i < 6)
.map((x) => (
<GenreGrid key={x} genre={x} />
))}
<VerticalRecommended />
{/*
}
>
<Fetch
query={Header.query()}
Render={(x) => (
<Header
name={x.name}
tagline={x.kind !== "collection" ? x.tagline : null}
description={x.description}
thumbnail={x.thumbnail}
link={x.kind !== "collection" ? x.playHref : null}
infoLink={x.href}
onLayout={(info) => setHeight(info.nativeEvent.layout.height)}
/>
)}
Loader={Header.Loader}
/>
<WatchlistList />
<NewsList />
{genres
.filter((_, i) => i < 2)
.map((x) => (
<GenreGrid key={x} genre={x} />
))}
<Recommended />
{genres
.filter((_, i) => i >= 2 && i < 6)
.map((x) => (
<GenreGrid key={x} genre={x} />
))}
<VerticalRecommended />
{/*
TODO: Lazy load those items
{randomItems.filter((_, i) => i >= 6).map((x) => <GenreGrid key={x} genre={x} />)}
*/}
</ScrollView>
</Animated.ScrollView>
</>
);
};

View File

@ -4,7 +4,12 @@ import Login from "@material-symbols/svg-400/rounded/login.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
useGlobalSearchParams,
useNavigation,
usePathname,
useRouter,
} from "expo-router";
import KyooLongLogo from "public/icon-long.svg";
import {
type ComponentProps,
@ -12,6 +17,8 @@ import {
useEffect,
useRef,
useState,
useLayoutEffect,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import {
@ -20,6 +27,7 @@ import {
type TextInput,
type TextInputProps,
View,
Animated,
} from "react-native";
import {
A,
@ -35,6 +43,15 @@ import {
import { useAccount, useAccounts } from "~/providers/account-context";
import { logout } from "~/ui/login/logic";
import { cn } from "~/utils";
import {
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import { useCSSVariable } from "uniwind";
export const NavbarTitle = ({
className,
@ -190,78 +207,51 @@ export const NavbarRight = () => {
);
};
// export const Navbar = ({
// left,
// right,
// background,
// ...props
// }: {
// left?: ReactElement | null;
// right?: ReactElement | null;
// background?: ReactElement;
// } & Stylable) => {
// const { css } = useYoshiki();
// const { t } = useTranslation();
//
// return (
// <Header
// {...css(
// {
// backgroundColor: (theme) => theme.accent,
// paddingX: ts(2),
// height: { xs: 48, sm: 64 },
// flexDirection: "row",
// justifyContent: { xs: "space-between", sm: "flex-start" },
// alignItems: "center",
// shadowColor: "#000",
// shadowOffset: {
// width: 0,
// height: 4,
// },
// shadowOpacity: 0.3,
// shadowRadius: 4.65,
// elevation: 8,
// zIndex: 1,
// },
// props,
// )}
// >
// {background}
// <View
// {...css({
// flexDirection: "row",
// alignItems: "center",
// height: percent(100),
// })}
// >
// {left !== undefined ? (
// left
// ) : (
// <>
// <NavbarTitle {...css({ marginX: ts(2) })} />
// <A
// href="/browse"
// {...css({
// textTransform: "uppercase",
// fontWeight: "bold",
// color: (theme) => theme.contrast,
// })}
// >
// {t("navbar.browse")}
// </A>
// </>
// )}
// </View>
// <View
// {...css({
// flexGrow: 1,
// flexShrink: 1,
// flexDirection: "row",
// display: { xs: "none", sm: "flex" },
// marginX: ts(2),
// })}
// />
// {right !== undefined ? right : <NavbarRight />}
// </Header>
// );
// };
export const useScrollNavbar = ({
imageHeight,
tab = false,
}: {
imageHeight: number;
tab?: boolean;
}) => {
const insets = useSafeAreaInsets();
const height = insets.top + (Platform.OS === "ios" ? 44 : 56);
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollY.value = event.contentOffset.y;
});
const opacity = useAnimatedStyle(
() => ({
opacity: interpolate(scrollY.value, [0, imageHeight - height], [0, 1]),
}),
[imageHeight, height],
);
const nav = useNavigation();
const focused = useIsFocused();
const accent = useCSSVariable("--color-accent");
useLayoutEffect(() => {
const n = tab ? nav.getParent() : nav;
if (focused) {
n?.setOptions({
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
});
}
return () =>
n?.setOptions({
headerTransparent: false,
headerStyle: { backgroundColor: accent as string },
});
}, [nav, tab, focused, accent]);
return {
scrollHandler,
headerProps: {
className: cn("absolute z-10 w-full bg-accent"),
style: [{ height }, opacity],
},
headerHeight: height,
};
};