mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Split loaders for recommanded items
This commit is contained in:
parent
393c58b10a
commit
2a1b805a7f
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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)} />;
|
||||
};
|
||||
|
@ -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)} />;
|
||||
};
|
||||
|
@ -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} />;
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user