Split loaders for recommanded items

This commit is contained in:
Zoe Roux 2024-05-20 21:13:28 +02:00
parent 393c58b10a
commit 2a1b805a7f
No known key found for this signature in database
7 changed files with 209 additions and 148 deletions

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import type { TextProps } from "react-native";
import { View, type TextProps } from "react-native";
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
import { Link } from "./links";
import { Skeleton } from "./skeleton";
@ -63,6 +63,7 @@ export const Chip = ({
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
@ -102,3 +103,40 @@ export const Chip = ({
</Link>
);
};
Chip.Loader = ({
color,
size = "medium",
outline = false,
...props
}: { color?: string; size?: "small" | "medium" | "large"; outline?: boolean }) => {
const { css } = useYoshiki();
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
return (
<View
{...css(
[
{
pY: ts(1 * sizeMult),
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
borderStyle: "solid",
borderWidth: px(1),
},
!outline && {
bg: color ?? ((theme: Theme) => theme.accent),
},
],
props,
)}
>
<Skeleton {...css({ width: rem(3) })} />
</View>
);
};

View File

@ -19,7 +19,7 @@
*/
import { getCurrentToken } from "@kyoo/models";
import { useState } from "react";
import { ReactElement, useState } from "react";
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
import { Blurhash } from "react-native-blurhash";
import FastImage from "react-native-fast-image";
@ -94,9 +94,9 @@ export const Image = ({
);
};
Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
const { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
return <Skeleton variant="custom" {...css([layout, border], props)} />;
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
};

View File

@ -19,7 +19,7 @@
*/
import NextImage from "next/image";
import { useState } from "react";
import { ReactElement, useState } from "react";
import { type ImageStyle, View, type ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { imageBorderRadius } from "../constants";
@ -74,9 +74,9 @@ export const Image = ({
);
};
Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
Image.Loader = ({ layout, ...props }: { layout: ImageLayout, children?: ReactElement }) => {
const { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
return <Skeleton variant="custom" {...css([layout, border], props)} />;
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
};

View File

@ -19,7 +19,7 @@
*/
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
import type { ComponentProps, ComponentType, ReactNode } from "react";
import type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
import { percent } from "yoshiki/native";
import { imageBorderRadius } from "../constants";
@ -43,6 +43,7 @@ Poster.Loader = ({
layout,
...props
}: {
children?: ReactElement;
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;

View File

@ -19,11 +19,10 @@
*/
import { LinearGradient as LG } from "expo-linear-gradient";
import { AnimatePresence, MotiView, motify } from "moti";
import { MotiView, motify } from "moti";
import { useState } from "react";
import { Platform, View, type ViewProps } from "react-native";
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
import { hiddenIfNoJs } from "./utils/nojs";
const LinearGradient = motify(LG)();
@ -99,71 +98,59 @@ export const Skeleton = ({
props,
)}
>
<AnimatePresence>
{children}
{(forcedShow || !children || children === true) &&
[...Array(lines)].map((_, i) => (
<MotiView
key={`skeleton_${i}`}
// No clue why it is a number on mobile and a string on web but /shrug
animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
exit={{ opacity: 0 }}
transition={{ type: "timing" }}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
{...css(
[
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
],
hiddenIfNoJs,
)}
>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={["transparent", theme.overlay1, "transparent"]}
transition={{
loop: true,
repeatReverse: false,
}}
animate={{
translateX: width
? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
: undefined,
}}
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
Platform.OS === "web" && {
// @ts-ignore Web only properties
animation: "skeleton 1.6s linear 0.5s infinite",
transform: "translateX(-100%)",
},
])}
/>
</MotiView>
))}
</AnimatePresence>
{(forcedShow || !children || children === true) &&
[...Array(lines)].map((_, i) => (
<MotiView
key={`skeleton_${i}`}
// No clue why it is a number on mobile and a string on web but /shrug
animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
exit={{ opacity: 0 }}
transition={{ type: "timing" }}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
{...css([
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
])}
>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={["transparent", theme.overlay1, "transparent"]}
transition={{
loop: true,
repeatReverse: false,
}}
animate={{
translateX: width
? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
: undefined,
}}
{...css({
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
})}
/>
</MotiView>
))}
{children}
</View>
);
};

View File

@ -21,7 +21,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { View, type ViewProps } from "react-native";
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
import { hiddenIfNoJs } from "./utils/nojs";
export const SkeletonCss = () => (
<style jsx global>{`
@ -90,33 +89,29 @@ export const Skeleton = ({
props,
)}
>
{children}
{(forcedShow || !children || children === true) &&
[...Array(lines)].map((_, i) => (
<View
key={`skeleton_${i}`}
{...css(
[
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
],
hiddenIfNoJs,
)}
{...css([
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
])}
>
<LinearGradient
start={{ x: 0, y: 0.5 }}
@ -137,6 +132,7 @@ export const Skeleton = ({
/>
</View>
))}
{children}
</View>
);
};

View File

@ -33,6 +33,7 @@ import {
IconFab,
Link,
P,
Poster,
PosterBackground,
Skeleton,
SubP,
@ -48,11 +49,10 @@ import { ScrollView, View } from "react-native";
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemGrid, ItemWatchStatus } from "../browse/grid";
import { ItemContext } from "../components/context-menus";
import type { Layout, WithLoading } from "../fetch";
import type { Layout } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite";
export const ItemDetails = ({
isLoading,
slug,
type,
name,
@ -66,12 +66,12 @@ export const ItemDetails = ({
watchStatus,
unseenEpisodesCount,
...props
}: WithLoading<{
}: {
slug: string;
type: "movie" | "show" | "collection";
name: string;
tagline: string | null;
subtitle: string;
subtitle: string | null;
poster: KyooImage | null;
genres: Genre[] | null;
overview: string | null;
@ -79,7 +79,7 @@ export const ItemDetails = ({
playHref: string | null;
watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}>) => {
}) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("recommended-card");
const { t } = useTranslation();
@ -124,7 +124,6 @@ export const ItemDetails = ({
src={poster}
alt=""
quality="low"
forcedLoading={isLoading}
layout={{ height: percent(100) }}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
@ -138,18 +137,8 @@ export const ItemDetails = ({
p: ts(1),
})}
>
<Skeleton {...css({ width: percent(100) })}>
{isLoading || (
<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>
{name}
</P>
)}
</Skeleton>
{(subtitle || isLoading) && (
<Skeleton {...css({ height: rem(0.8) })}>
{isLoading || <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
</Skeleton>
)}
<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>{name}</P>
{subtitle && <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
</View>
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
</PosterBackground>
@ -163,7 +152,7 @@ export const ItemDetails = ({
alignContent: "flex-start",
})}
>
{slug && type && type !== "collection" && watchStatus !== undefined && (
{type !== "collection" && (
<ItemContext
type={type}
slug={slug}
@ -173,18 +162,10 @@ export const ItemDetails = ({
force
/>
)}
{(isLoading || tagline) && (
<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}>
{isLoading || <P {...css({ p: ts(1) })}>{tagline}</P>}
</Skeleton>
)}
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
</View>
<ScrollView {...css({ pX: ts(1) })}>
<Skeleton lines={5} {...css({ height: rem(0.8) })}>
{isLoading || (
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
)}
</Skeleton>
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
</ScrollView>
</View>
</Link>
@ -209,9 +190,9 @@ export const ItemDetails = ({
height: px(50),
})}
>
{(isLoading || genres) && (
{genres && (
<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
{(genres || [...Array(3)])?.map((x, i) => (
{genres.map((x, i) => (
<Chip key={x ?? i} label={x} size="small" {...css({ mX: ts(0.5) })} />
))}
</ScrollView>
@ -231,6 +212,65 @@ export const ItemDetails = ({
);
};
ItemDetails.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
height: ItemDetails.layout.size,
flexDirection: "row",
bg: (theme) => theme.variant.background,
borderRadius: calc(px(imageBorderRadius), "+", ts(0.25)),
overflow: "hidden",
borderColor: (theme) => theme.background,
borderWidth: ts(0.25),
borderStyle: "solid",
},
props,
)}
>
<Poster.Loader
layout={{ height: percent(100) }}
{...css({ borderTopRightRadius: 0, borderBottomRightRadius: 0 })}
>
<View
{...css({
bg: (theme) => theme.darkOverlay,
position: "absolute",
left: 0,
right: 0,
bottom: 0,
p: ts(1),
})}
>
<Skeleton {...css({ width: percent(100) })} />
<Skeleton {...css({ height: rem(0.8) })} />
</View>
</Poster.Loader>
<View {...css({ flexShrink: 1, flexGrow: 1 })}>
<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
<Skeleton {...css({ marginVertical: ts(2) })} />
<Skeleton lines={5} {...css({ height: rem(0.8) })} />
</View>
<View
{...css({
bg: (theme) => theme.themeOverlay,
pX: 4,
height: px(50),
flexDirection: "row",
alignItems: "center",
})}
>
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
</View>
</View>
</View>
);
};
ItemDetails.layout = {
size: ts(36),
numColumns: { xs: 1, md: 2, xl: 3 },
@ -252,29 +292,28 @@ export const Recommended = () => {
fetchMore={false}
nested
contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
>
{(x) => (
Render={({ item }) => (
<ItemDetails
isLoading={x.isLoading as any}
slug={x.slug}
type={x.kind}
name={x.name}
tagline={"tagline" in x ? x.tagline : null}
overview={x.overview}
poster={x.poster}
subtitle={x.kind !== "collection" && !x.isLoading ? getDisplayDate(x) : undefined}
genres={"genres" in x ? x.genres : null}
href={x.href}
playHref={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
watchStatus={
!x.isLoading && x.kind !== "collection" ? x.watchStatus?.status ?? null : null
}
slug={item.slug}
type={item.kind}
name={item.name}
tagline={"tagline" in item ? item.tagline : null}
overview={item.overview}
poster={item.poster}
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
genres={"genres" in item ? item.genres : null}
href={item.href}
playHref={item.kind !== "collection" ? item.playHref : null}
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
unseenEpisodesCount={
x.kind === "show" ? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount! : null
item.kind === "show"
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
: null
}
/>
)}
</InfiniteFetch>
Loader={ItemDetails.Loader}
/>
</View>
);
};