mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-02-13 23:14:12 -05:00
Improve transparent navbar handling (#1312)
This commit is contained in:
commit
df0cc9f998
@ -22,7 +22,7 @@ import type { SeedMovie } from "~/models/movie";
|
||||
import type { SeedSerie } from "~/models/serie";
|
||||
import type { Original } from "~/models/utils";
|
||||
import { record } from "~/otel";
|
||||
import { getYear } from "~/utils";
|
||||
import { getYear, uniq } from "~/utils";
|
||||
import { enqueueOptImage, flushImageQueue, type ImageTask } from "../images";
|
||||
|
||||
type Show = typeof shows.$inferInsert;
|
||||
@ -68,7 +68,11 @@ export const insertShow = record(
|
||||
column: sql`${shows.original}['logo']`,
|
||||
}),
|
||||
};
|
||||
const ret = await insertBaseShow(tx, { ...show, original: orig });
|
||||
const ret = await insertBaseShow(tx, {
|
||||
...show,
|
||||
genres: uniq(show.genres),
|
||||
original: orig,
|
||||
});
|
||||
if ("status" in ret) return ret;
|
||||
|
||||
const trans: ShowTrans[] = Object.entries(translations).map(
|
||||
|
||||
@ -29,6 +29,10 @@ export function getFile(path: string): BunFile | S3File {
|
||||
return Bun.file(path);
|
||||
}
|
||||
|
||||
export function uniq<T>(a: T[]): T[] {
|
||||
return uniqBy(a, (x) => x as string);
|
||||
}
|
||||
|
||||
export function uniqBy<T>(a: T[], key: (val: T) => string): T[] {
|
||||
const seen: Record<string, boolean> = {};
|
||||
return a.filter((item) => {
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"@expo-google-fonts/sora": "^0.4.2",
|
||||
"@expo/html-elements": "^0.13.7",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@legendapp/list": "^2.0.19",
|
||||
"@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@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
||||
"@legendapp/list": ["@legendapp/list@github:zoriya/legend-list#d5d3344", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "zoriya-legend-list-d5d3344"],
|
||||
|
||||
"@material-symbols/svg-400": ["@material-symbols/svg-400@0.40.2", "", {}, "sha512-e2yEgZW/OveVT1sGaZW1kkRWTPVghjsJYWy+vIea3q08Fv2o7FCYv23PESMyr5D4AaAXdM5dKWkF1e6yIm4swA=="],
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"@expo-google-fonts/sora": "^0.4.2",
|
||||
"@expo/html-elements": "^0.13.7",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@legendapp/list": "^2.0.19",
|
||||
"@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",
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -121,7 +121,7 @@ export const ItemDetails = ({
|
||||
<Chip
|
||||
key={x ?? i}
|
||||
label={t(`genres.${x}`)}
|
||||
href={"#"}
|
||||
href={`/genres/${x}`}
|
||||
size="small"
|
||||
className="mx-1"
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -14,7 +14,7 @@ export const Container = <AsProps = ViewProps>({
|
||||
return (
|
||||
<As
|
||||
className={cn(
|
||||
"flex w-full self-center px-4",
|
||||
"flex w-full flex-1 self-center px-4",
|
||||
"sm:w-xl md:w-3xl lg:w-5xl xl:w-7xl",
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -83,7 +83,7 @@ export const IconButton = <AsProps = PressableProps>({
|
||||
<Container
|
||||
focusRipple
|
||||
className={cn(
|
||||
"h-10 w-10 self-center overflow-hidden rounded-full p-2",
|
||||
"self-center overflow-hidden rounded-full p-2",
|
||||
"outline-0 hover:bg-gray-400/50 focus-visible:bg-gray-400/50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -89,7 +89,7 @@ export const PosterPlaceholder = ({ className, ...props }: ViewProps) => {
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<KyooLogo style={{ width: "50%", height: "50%" }} />
|
||||
<KyooLogo style={{ width: "50%", aspectRatio: "289.35/296.15" }} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -37,7 +37,6 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
|
||||
useEffect(() => {
|
||||
if (!ret.apiUrl) {
|
||||
setTimeout(() => {
|
||||
console.log("go to login");
|
||||
router.replace("/login");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@ -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,15 +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}
|
||||
stickyIndices={getStickyIndices?.(items ?? [])}
|
||||
stickyHeaderIndices={getStickyIndices?.(items ?? [])}
|
||||
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 }) =>
|
||||
@ -100,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}
|
||||
/>
|
||||
|
||||
@ -3,10 +3,9 @@ import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
|
||||
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
|
||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||
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, type ViewProps } from "react-native";
|
||||
import { WatchListInfo } from "~/components/items/watchlist-info";
|
||||
import { Rating } from "~/components/rating";
|
||||
import {
|
||||
@ -380,78 +379,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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 { HeaderBackground, 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>
|
||||
<>
|
||||
<HeaderBackground {...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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -41,7 +41,7 @@ export const SeasonHeader = ({
|
||||
return (
|
||||
<View
|
||||
id={`season-${seasonNumber}`}
|
||||
className={cn("m-1 flex-row", className)}
|
||||
className={cn("m-1 w-full flex-1 flex-row", className)}
|
||||
{...props}
|
||||
>
|
||||
<P className="mx-1 w-16 shrink-0 text-center text-2xl text-accent">
|
||||
@ -109,7 +109,11 @@ export const EntryList = ({
|
||||
query={EntryList.query(slug, season)}
|
||||
layout={EntryLine.layout}
|
||||
Empty={<EmptyView message={t("show.episode-none")} />}
|
||||
Divider={() => <Container as={HR} />}
|
||||
Divider={() => (
|
||||
<Container>
|
||||
<HR />
|
||||
</Container>
|
||||
)}
|
||||
getItemType={(item, idx) =>
|
||||
item ? item.kind : idx === 0 ? "season" : "episode"
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Path } from "react-native-svg";
|
||||
import { EntryLine, entryDisplayNumber } from "~/components/entries";
|
||||
@ -8,13 +8,14 @@ import type { Entry, Serie } from "~/models";
|
||||
import { Container, H2, Svg } from "~/primitives";
|
||||
import { Fetch } from "~/query";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { HeaderBackground, useScrollNavbar } from "../navbar";
|
||||
import { Header } from "./header";
|
||||
import { EntryList } from "./season";
|
||||
|
||||
export const SvgWave = (props: ComponentProps<typeof Svg>) => {
|
||||
// aspect-[width/height]: width/height of the svg
|
||||
return (
|
||||
<View className="aspect-[612/52.771] w-full">
|
||||
<View className="ml-[-10px] aspect-612/52 w-[110%]">
|
||||
<Svg width="100%" height="100%" viewBox="0 372.979 612 52.771" {...props}>
|
||||
<Path d="M0,375.175c68,-5.1,136,-0.85,204,7.948c68,9.052,136,22.652,204,24.777s136,-8.075,170,-12.878l34,-4.973v35.7h-612" />
|
||||
</Svg>
|
||||
@ -26,16 +27,18 @@ export const NextUp = (nextEntry: Entry) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container className="my-4 overflow-hidden rounded-2xl bg-card py-4 hover:bg-accent">
|
||||
<H2 className="mb-4 ml-2">{t("show.nextUp")}</H2>
|
||||
<EntryLine
|
||||
{...nextEntry}
|
||||
serieSlug={null}
|
||||
videosCount={nextEntry.videos.length}
|
||||
watchedPercent={nextEntry.progress.percent}
|
||||
displayNumber={entryDisplayNumber(nextEntry)}
|
||||
/>
|
||||
</Container>
|
||||
<View className="m-4 flex-1">
|
||||
<Container className="overflow-hidden rounded-2xl bg-card py-4">
|
||||
<H2 className="mb-4 ml-2">{t("show.nextUp")}</H2>
|
||||
<EntryLine
|
||||
{...nextEntry}
|
||||
serieSlug={null}
|
||||
videosCount={nextEntry.videos.length}
|
||||
watchedPercent={nextEntry.progress.percent}
|
||||
displayNumber={entryDisplayNumber(nextEntry)}
|
||||
/>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -43,19 +46,25 @@ NextUp.Loader = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container className="my-4 overflow-hidden rounded-2xl bg-card">
|
||||
<H2 className="ml-4">{t("show.nextUp")}</H2>
|
||||
<EntryLine.Loader />
|
||||
</Container>
|
||||
<View className="m-4 flex-1">
|
||||
<Container className="overflow-hidden rounded-2xl bg-card py-4">
|
||||
<H2 className="ml-4">{t("show.nextUp")}</H2>
|
||||
<EntryLine.Loader />
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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)}
|
||||
@ -76,14 +85,33 @@ 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">
|
||||
<HeaderBackground {...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,
|
||||
backdropComponent: () => (
|
||||
// hr bottom margin is m-4 and layout gap is 2 but it's only applied on the web and idk why
|
||||
<View className="absolute inset-0 mb-4 web:mb-6 bg-card" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -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 type { 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 (
|
||||
|
||||
@ -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 { HeaderBackground, 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}
|
||||
<>
|
||||
<HeaderBackground {...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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -7,8 +7,8 @@ import { Show } from "~/models";
|
||||
import { Button, Link, P, ts } from "~/primitives";
|
||||
import { useAccount } from "~/providers/account-context";
|
||||
import { InfiniteFetch, type QueryIdentifier } from "~/query";
|
||||
import { getDisplayDate } from "~/utils";
|
||||
import { EmptyView } from "~/ui/empty-view";
|
||||
import { getDisplayDate } from "~/utils";
|
||||
import { Header } from "./genre";
|
||||
|
||||
export const WatchlistList = () => {
|
||||
|
||||
@ -4,12 +4,19 @@ 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 { useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
useGlobalSearchParams,
|
||||
useNavigation,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import KyooLongLogo from "public/icon-long.svg";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@ -20,7 +27,16 @@ import {
|
||||
type TextInput,
|
||||
type TextInputProps,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import {
|
||||
A,
|
||||
Avatar,
|
||||
@ -190,78 +206,87 @@ 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 reverse = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: interpolate(scrollY.value, [0, imageHeight - height], [1, 0]),
|
||||
}),
|
||||
[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: {
|
||||
opacity,
|
||||
reverse,
|
||||
height,
|
||||
},
|
||||
headerHeight: height,
|
||||
};
|
||||
};
|
||||
|
||||
export const HeaderBackground = ({
|
||||
children,
|
||||
opacity,
|
||||
reverse,
|
||||
height,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ViewProps & ReturnType<typeof useScrollNavbar>["headerProps"]) => {
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
className={cn("absolute z-10 w-full bg-accent", className)}
|
||||
style={[{ height }, opacity, style]}
|
||||
{...props}
|
||||
/>
|
||||
<Animated.View
|
||||
className={cn(
|
||||
"absolute z-10 w-full bg-linear-to-b from-slate-950/70 to-transparent",
|
||||
className,
|
||||
)}
|
||||
style={[{ height }, reverse, style]}
|
||||
{...props}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -98,7 +98,7 @@ const ControlButtons = ({
|
||||
onMenuOpen: () => setMenu(true),
|
||||
onMenuClose: () => setMenu(false),
|
||||
className: "mr-4",
|
||||
iconClassName: "fill-slate-200",
|
||||
iconClassName: "fill-slate-200 dark:fill-slate-200",
|
||||
} satisfies Partial<
|
||||
ComponentProps<
|
||||
typeof Menu<ComponentProps<typeof IconButton<PressableProps>>>
|
||||
@ -120,14 +120,14 @@ const ControlButtons = ({
|
||||
href={`/watch/${previous}`}
|
||||
replace
|
||||
className="mr-4"
|
||||
iconClassName="fill-slate-200"
|
||||
iconClassName="fill-slate-200 dark:fill-slate-200"
|
||||
{...tooltip(t("player.previous"), true)}
|
||||
/>
|
||||
)}
|
||||
<PlayButton
|
||||
player={player}
|
||||
className="mr-4"
|
||||
iconClassName="fill-slate-200"
|
||||
iconClassName="fill-slate-200 dark:fill-slate-200"
|
||||
/>
|
||||
{next && (
|
||||
<IconButton
|
||||
@ -136,16 +136,22 @@ const ControlButtons = ({
|
||||
href={`/watch/${next}`}
|
||||
replace
|
||||
className="mr-4"
|
||||
iconClassName="fill-slate-200"
|
||||
iconClassName="fill-slate-200 dark:fill-slate-200"
|
||||
{...tooltip(t("player.next"), true)}
|
||||
/>
|
||||
)}
|
||||
{Platform.OS === "web" && (
|
||||
<VolumeSlider player={player} iconClassName="fill-slate-200" />
|
||||
<VolumeSlider
|
||||
player={player}
|
||||
iconClassName="fill-slate-200 dark:fill-slate-200"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<ProgressText player={player} className="mx-2 text-slate-300" />
|
||||
<ProgressText
|
||||
player={player}
|
||||
className="mx-2 text-slate-300 dark:text-slate-300"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row">
|
||||
<SubtitleMenu player={player} {...menuProps} />
|
||||
@ -153,7 +159,10 @@ const ControlButtons = ({
|
||||
<VideoMenu player={player} {...menuProps} />
|
||||
<QualityMenu player={player} {...menuProps} />
|
||||
{Platform.OS === "web" && (
|
||||
<FullscreenButton className="mr-4" iconClassName="fill-slate-200" />
|
||||
<FullscreenButton
|
||||
className="mr-4"
|
||||
iconClassName="fill-slate-200 dark:fill-slate-200"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -32,13 +32,15 @@ export const MiddleControls = ({
|
||||
href={previous}
|
||||
replace
|
||||
className={cn(
|
||||
"mx-12 h-16 w-16 bg-gray-800/70",
|
||||
"mx-6 bg-gray-800/70",
|
||||
!previous && "pointer-events-none opacity-0",
|
||||
)}
|
||||
iconClassName="h-16 w-16"
|
||||
/>
|
||||
<PlayButton
|
||||
player={player}
|
||||
className={cn("mx-12 h-32 w-32 bg-gray-800/70")}
|
||||
className={cn("mx-6 bg-gray-800/50")}
|
||||
iconClassName="h-24 w-24"
|
||||
/>
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
@ -46,9 +48,10 @@ export const MiddleControls = ({
|
||||
href={next}
|
||||
replace
|
||||
className={cn(
|
||||
"mx-12 h-16 w-16 bg-gray-800/70",
|
||||
"mx-6 bg-gray-800/70",
|
||||
!next && "pointer-events-none opacity-0",
|
||||
)}
|
||||
iconClassName="h-16 w-16"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -88,7 +88,10 @@ export const TouchControls = ({
|
||||
// instantly hide the controls when mouse leaves the view
|
||||
if (e.nativeEvent.pointerType === "mouse") show(false);
|
||||
}}
|
||||
className={cn("absolute inset-0", !shouldShow && "cursor-none")}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default",
|
||||
!shouldShow && "cursor-none",
|
||||
)}
|
||||
/>
|
||||
{shouldShow && children}
|
||||
</View>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user