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/>. * 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 { type Theme, px, rem, useYoshiki } from "yoshiki/native";
import { Link } from "./links"; import { Link } from "./links";
import { Skeleton } from "./skeleton"; import { Skeleton } from "./skeleton";
@ -63,6 +63,7 @@ export const Chip = ({
pX: ts(2.5 * sizeMult), pX: ts(2.5 * sizeMult),
borderRadius: ts(3), borderRadius: ts(3),
overflow: "hidden", overflow: "hidden",
justifyContent: "center",
}, },
outline && { outline && {
borderColor: color ?? ((theme: Theme) => theme.accent), borderColor: color ?? ((theme: Theme) => theme.accent),
@ -102,3 +103,40 @@ export const Chip = ({
</Link> </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 { 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 { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
import { Blurhash } from "react-native-blurhash"; import { Blurhash } from "react-native-blurhash";
import FastImage from "react-native-fast-image"; 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 { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle; 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 NextImage from "next/image";
import { useState } from "react"; import { ReactElement, useState } from "react";
import { type ImageStyle, View, type ViewStyle } from "react-native"; import { type ImageStyle, View, type ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { imageBorderRadius } from "../constants"; 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 { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle; 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 { 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 { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
import { percent } from "yoshiki/native"; import { percent } from "yoshiki/native";
import { imageBorderRadius } from "../constants"; import { imageBorderRadius } from "../constants";
@ -43,6 +43,7 @@ Poster.Loader = ({
layout, layout,
...props ...props
}: { }: {
children?: ReactElement;
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>; layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />; }) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;

View File

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

View File

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

View File

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