Split skeleton and normal state in all lists (#506)

This commit is contained in:
Zoe Roux 2024-05-20 23:12:33 +02:00 committed by GitHub
commit a37ace7d46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 734 additions and 542 deletions

View File

@ -42,6 +42,7 @@ export const getDisplayDate = (data: Show | Movie) => {
if (airDate) { if (airDate) {
return airDate.getFullYear().toString(); return airDate.getFullYear().toString();
} }
return null;
}; };
export const useLocalSetting = (setting: string, def: string) => { export const useLocalSetting = (setting: string, def: string) => {

View File

@ -23,6 +23,7 @@ import { type ComponentType, type RefAttributes, forwardRef } from "react";
import { Image, type ImageProps, View, type ViewStyle } from "react-native"; import { Image, type ImageProps, View, type ViewStyle } from "react-native";
import { type Stylable, px, useYoshiki } from "yoshiki/native"; import { type Stylable, px, useYoshiki } from "yoshiki/native";
import { Icon } from "./icons"; import { Icon } from "./icons";
import { Skeleton } from "./skeleton";
import { P } from "./text"; import { P } from "./text";
const stringToColor = (string: string) => { const stringToColor = (string: string) => {
@ -40,7 +41,7 @@ const stringToColor = (string: string) => {
return color; return color;
}; };
export const Avatar = forwardRef< const AvatarC = forwardRef<
View, View,
{ {
src?: string; src?: string;
@ -48,12 +49,11 @@ export const Avatar = forwardRef<
size?: number; size?: number;
placeholder?: string; placeholder?: string;
color?: string; color?: string;
isLoading?: boolean;
fill?: boolean; fill?: boolean;
as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>; as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
} & Stylable } & Stylable
>(function Avatar( >(function AvatarI(
{ src, alt, size = px(24), color, placeholder, isLoading = false, fill = false, as, ...props }, { src, alt, size = px(24), color, placeholder, fill = false, as, ...props },
ref, ref,
) { ) {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
@ -106,3 +106,22 @@ export const Avatar = forwardRef<
</Container> </Container>
); );
}); });
const AvatarLoader = ({ size = px(24), ...props }: { size?: number }) => {
const { css } = useYoshiki();
return (
<Skeleton
variant="round"
{...css(
{
height: size,
width: size,
},
props,
)}
/>
);
};
export const Avatar = Object.assign(AvatarC, { Loader: AvatarLoader });

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 { type TextProps, View } 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

@ -45,7 +45,7 @@ type IconProps = {
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => { export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const computed = css( const computed = css(
{ width: size, height: size, fill: color ?? theme.contrast } as any, { width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
props, props,
) as any; ) as any;

View File

@ -19,7 +19,7 @@
*/ */
import { getCurrentToken } from "@kyoo/models"; import { getCurrentToken } from "@kyoo/models";
import { useState } from "react"; import { type 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";
@ -93,3 +93,10 @@ export const Image = ({
</View> </View>
); );
}; };
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
const { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
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 { type 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";
@ -73,3 +73,10 @@ export const Image = ({
</BlurhashContainer> </BlurhashContainer>
); );
}; };
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
const { css } = useYoshiki();
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
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";
@ -39,6 +39,14 @@ export const Poster = ({
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>; layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />; }) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
Poster.Loader = ({
layout,
...props
}: {
children?: ReactElement;
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
export const PosterBackground = ({ export const PosterBackground = ({
alt, alt,
layout, layout,
@ -86,7 +94,7 @@ export const ImageBackground = <AsProps = ViewProps>({
{({ css, theme }) => ( {({ css, theme }) => (
<Container <Container
{...(css( {...(css(
[layout, !hideLoad && { borderRadius: imageBorderRadius, overflow: "hidden" }], [layout, { borderRadius: imageBorderRadius, overflow: "hidden" }],
asProps, asProps,
) as AsProps)} ) as AsProps)}
> >

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

@ -23,7 +23,7 @@ import { Alert, Avatar, Icon, IconButton, Menu, P, Skeleton, tooltip, ts } from
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { px, useYoshiki } from "yoshiki/native"; import { px, useYoshiki } from "yoshiki/native";
import type { Layout, WithLoading } from "../fetch"; import type { Layout } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";
import { SettingsContainer } from "../settings/base"; import { SettingsContainer } from "../settings/base";
@ -36,20 +36,19 @@ import Verifed from "@material-symbols/svg-400/rounded/verified_user.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
export const UserGrid = ({ export const UserGrid = ({
isLoading,
id, id,
username, username,
avatar, avatar,
isAdmin, isAdmin,
isVerified, isVerified,
...props ...props
}: WithLoading<{ }: {
id: string; id: string;
username: string; username: string;
avatar: string; avatar: string;
isAdmin: boolean; isAdmin: boolean;
isVerified: boolean; isVerified: boolean;
}>) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -66,11 +65,10 @@ export const UserGrid = ({
return ( return (
<View {...css({ alignItems: "center" }, props)}> <View {...css({ alignItems: "center" }, props)}>
<Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill /> <Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill />
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row", alignItems: "center" })}>
<Icon <Icon
icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI} icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI}
{...css({ {...css({
alignSelf: "center",
m: ts(1), m: ts(1),
})} })}
{...tooltip( {...tooltip(
@ -83,9 +81,7 @@ export const UserGrid = ({
), ),
)} )}
/> />
<Skeleton> <P>{username}</P>
<P>{username}</P>
</Skeleton>
<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}> <Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}>
{!isVerified && ( {!isVerified && (
<Menu.Item <Menu.Item
@ -159,6 +155,21 @@ export const UserGrid = ({
); );
}; };
UserGrid.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View {...css({ alignItems: "center" }, props)}>
<Avatar.Loader size={UserGrid.layout.size} />
<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1, flexGrow: 1 })}>
<Icon icon={UserI} {...css({ m: ts(1) })} />
<Skeleton {...css({ flexGrow: 1, width: ts(8) })} />
<IconButton icon={MoreVert} disabled />
</View>
</View>
);
};
UserGrid.layout = { UserGrid.layout = {
size: px(150), size: px(150),
numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 }, numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 },
@ -171,18 +182,20 @@ export const UserList = () => {
return ( return (
<SettingsContainer title={t("admin.users.label")}> <SettingsContainer title={t("admin.users.label")}>
<InfiniteFetch query={UserList.query()} layout={UserGrid.layout}> <InfiniteFetch
{(user) => ( query={UserList.query()}
layout={UserGrid.layout}
Render={({ item }) => (
<UserGrid <UserGrid
isLoading={user.isLoading as any} id={item.id}
id={user.id} username={item.username}
username={user.username} avatar={item.logo}
avatar={user.logo} isAdmin={item.isAdmin}
isAdmin={user.isAdmin} isVerified={item.isVerified}
isVerified={user.isVerified}
/> />
)} )}
</InfiniteFetch> Loader={UserGrid.Loader}
/>
</SettingsContainer> </SettingsContainer>
); );
}; };

View File

@ -23,6 +23,7 @@ import {
Icon, Icon,
Link, Link,
P, P,
Poster,
PosterBackground, PosterBackground,
Skeleton, Skeleton,
SubP, SubP,
@ -35,7 +36,7 @@ import { useState } from "react";
import { type ImageStyle, Platform, View } from "react-native"; import { type ImageStyle, Platform, View } from "react-native";
import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native"; import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemContext } from "../components/context-menus"; import { ItemContext } from "../components/context-menus";
import type { Layout, WithLoading } from "../fetch"; import type { Layout } from "../fetch";
export const ItemWatchStatus = ({ export const ItemWatchStatus = ({
watchStatus, watchStatus,
@ -113,23 +114,21 @@ export const ItemGrid = ({
type, type,
subtitle, subtitle,
poster, poster,
isLoading,
watchStatus, watchStatus,
watchPercent, watchPercent,
unseenEpisodesCount, unseenEpisodesCount,
...props ...props
}: WithLoading<{ }: {
href: string; href: string;
slug: string; slug: string;
name: string; name: string;
subtitle?: string; subtitle: string | null;
poster?: KyooImage | null; poster: KyooImage | null;
watchStatus: WatchStatusV | null; watchStatus: WatchStatusV | null;
watchPercent: number | null; watchPercent: number | null;
type: "movie" | "show" | "collection"; type: "movie" | "show" | "collection";
unseenEpisodesCount: number | null; unseenEpisodesCount: number | null;
}> & } & Stylable<"text">) => {
Stylable<"text">) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("grid"); const { css } = useYoshiki("grid");
@ -172,13 +171,12 @@ export const ItemGrid = ({
src={poster} src={poster}
alt={name} alt={name}
quality="low" quality="low"
forcedLoading={isLoading}
layout={{ width: percent(100) }} layout={{ width: percent(100) }}
{...(css("poster") as { style: ImageStyle })} {...(css("poster") as { style: ImageStyle })}
> >
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} /> <ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
{type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />} {type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />}
{slug && watchStatus !== undefined && type && type !== "collection" && ( {type !== "collection" && (
<ItemContext <ItemContext
type={type} type={type}
slug={slug} slug={slug}
@ -198,34 +196,44 @@ export const ItemGrid = ({
/> />
)} )}
</PosterBackground> </PosterBackground>
<Skeleton> <P numberOfLines={subtitle ? 1 : 2} {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
{isLoading || ( {name}
<P </P>
numberOfLines={subtitle ? 1 : 2} {subtitle && (
{...css([{ marginY: 0, textAlign: "center" }, "title"])} <SubP
> {...css({
{name} marginTop: 0,
</P> textAlign: "center",
)} })}
</Skeleton> >
{(isLoading || subtitle) && ( {subtitle}
<Skeleton {...css({ width: percent(50) })}> </SubP>
{isLoading || (
<SubP
{...css({
marginTop: 0,
textAlign: "center",
})}
>
{subtitle}
</SubP>
)}
</Skeleton>
)} )}
</Link> </Link>
); );
}; };
ItemGrid.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
flexDirection: "column",
alignItems: "center",
width: percent(100),
},
props,
)}
>
<Poster.Loader layout={{ width: percent(100) }} />
<Skeleton />
<Skeleton {...css({ width: percent(50) })} />
</View>
);
};
ItemGrid.layout = { ItemGrid.layout = {
size: px(150), size: px(150),
numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 }, numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 },

View File

@ -27,7 +27,6 @@ import {
} from "@kyoo/models"; } from "@kyoo/models";
import { type ComponentProps, useState } from "react"; import { type ComponentProps, useState } from "react";
import { createParam } from "solito"; import { createParam } from "solito";
import type { WithLoading } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";
import { DefaultLayout } from "../layout"; import { DefaultLayout } from "../layout";
import { ItemGrid } from "./grid"; import { ItemGrid } from "./grid";
@ -38,25 +37,20 @@ import { Layout, SortBy, SortOrd } from "./types";
const { useParam } = createParam<{ sortBy?: string }>(); const { useParam } = createParam<{ sortBy?: string }>();
export const itemMap = ( export const itemMap = (
item: WithLoading<LibraryItem>, item: LibraryItem,
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => { ): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
if (item.isLoading) return item as any; slug: item.slug,
name: item.name,
return { subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
isLoading: item.isLoading, href: item.href,
slug: item.slug, poster: item.poster,
name: item.name, thumbnail: item.thumbnail,
subtitle: item.kind !== "collection" ? getDisplayDate(item) : undefined, watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
href: item.href, type: item.kind,
poster: item.poster, watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
thumbnail: item.thumbnail, unseenEpisodesCount:
watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null, item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
type: item.kind, });
watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
unseenEpisodesCount:
item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
};
};
const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({ const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({
parser: LibraryItemP, parser: LibraryItemP,
@ -92,9 +86,9 @@ export const BrowsePage: QueryPage = () => {
setLayout={setLayout} setLayout={setLayout}
/> />
} }
> Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
{(item) => <LayoutComponent {...itemMap(item)} />} Loader={LayoutComponent.Loader}
</InfiniteFetch> />
); );
}; };

View File

@ -24,6 +24,7 @@ import {
ImageBackground, ImageBackground,
Link, Link,
P, P,
Poster,
PosterBackground, PosterBackground,
Skeleton, Skeleton,
imageBorderRadius, imageBorderRadius,
@ -32,9 +33,10 @@ import {
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { useState } from "react"; import { useState } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import type { Stylable } from "yoshiki";
import { percent, px, rem, useYoshiki } from "yoshiki/native"; import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemContext } from "../components/context-menus"; import { ItemContext } from "../components/context-menus";
import type { Layout, WithLoading } from "../fetch"; import type { Layout } from "../fetch";
import { ItemWatchStatus } from "./grid"; import { ItemWatchStatus } from "./grid";
export const ItemList = ({ export const ItemList = ({
@ -45,22 +47,21 @@ export const ItemList = ({
subtitle, subtitle,
thumbnail, thumbnail,
poster, poster,
isLoading,
watchStatus, watchStatus,
unseenEpisodesCount, unseenEpisodesCount,
...props ...props
}: WithLoading<{ }: {
href: string; href: string;
slug: string; slug: string;
type: "movie" | "show" | "collection"; type: "movie" | "show" | "collection";
name: string; name: string;
subtitle?: string; subtitle: string | null;
poster?: KyooImage | null; poster: KyooImage | null;
thumbnail?: KyooImage | null; thumbnail: KyooImage | null;
watchStatus: WatchStatusV | null; watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null; unseenEpisodesCount: number | null;
}>) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki("line");
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
return ( return (
@ -114,25 +115,21 @@ export const ItemList = ({
justifyContent: "center", justifyContent: "center",
})} })}
> >
<Skeleton {...css({ height: rem(2), alignSelf: "center" })}> <Heading
{isLoading || ( {...css([
<Heading "title",
{...css([ {
"title", textAlign: "center",
{ fontSize: rem(2),
textAlign: "center", letterSpacing: rem(0.002),
fontSize: rem(2), fontWeight: "900",
letterSpacing: rem(0.002), textTransform: "uppercase",
fontWeight: "900", },
textTransform: "uppercase", ])}
}, >
])} {name}
> </Heading>
{name} {type !== "collection" && (
</Heading>
)}
</Skeleton>
{slug && watchStatus !== undefined && type && type !== "collection" && (
<ItemContext <ItemContext
type={type} type={type}
slug={slug} slug={slug}
@ -151,32 +148,56 @@ export const ItemList = ({
/> />
)} )}
</View> </View>
{(isLoading || subtitle) && ( {subtitle && (
<Skeleton {...css({ width: rem(5), alignSelf: "center" })}> <P
{isLoading || ( {...css({
<P textAlign: "center",
{...css({ marginRight: ts(4),
textAlign: "center", })}
marginRight: ts(4), >
})} {subtitle}
> </P>
{subtitle}
</P>
)}
</Skeleton>
)} )}
</View> </View>
<PosterBackground <PosterBackground src={poster} alt="" quality="low" layout={{ height: percent(80) }}>
src={poster}
alt=""
quality="low"
forcedLoading={isLoading}
layout={{ height: percent(80) }}
>
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} /> <ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
</PosterBackground> </PosterBackground>
</ImageBackground> </ImageBackground>
); );
}; };
ItemList.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
justifyContent: "space-evenly",
flexDirection: "row",
height: ItemList.layout.size,
borderRadius: px(imageBorderRadius),
overflow: "hidden",
bg: (theme) => theme.dark.background,
marginX: ItemList.layout.gap,
},
props,
)}
>
<View
{...css({
width: { xs: "50%", lg: "30%" },
flexDirection: "column",
justifyContent: "center",
})}
>
<Skeleton {...css({ height: rem(2), alignSelf: "center" })} />
<Skeleton {...css({ width: rem(5), alignSelf: "center" })} />
</View>
<Poster.Loader layout={{ height: percent(80) }} />
</View>
);
};
ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout; ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;

View File

@ -155,30 +155,29 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
Header={CollectionHeader} Header={CollectionHeader}
headerProps={{ slug }} headerProps={{ slug }}
contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }} contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }}
> 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
} }
{...css({ marginX: ItemGrid.layout.gap })} {...css({ marginX: ItemGrid.layout.gap })}
/> />
)} )}
</InfiniteFetch> Loader={ItemDetails.Loader}
/>
); );
}; };

View File

@ -22,6 +22,7 @@ import { type KyooImage, WatchStatusV } from "@kyoo/models";
import { import {
H6, H6,
IconButton, IconButton,
Image,
ImageBackground, ImageBackground,
type ImageProps, type ImageProps,
Link, Link,
@ -41,20 +42,17 @@ import { type ImageStyle, Platform, type PressableProps, View } from "react-nati
import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native"; import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native";
import { ItemProgress } from "../browse/grid"; import { ItemProgress } from "../browse/grid";
import { EpisodesContext } from "../components/context-menus"; import { EpisodesContext } from "../components/context-menus";
import type { Layout, WithLoading } from "../fetch"; import type { Layout } from "../fetch";
export const episodeDisplayNumber = ( export const episodeDisplayNumber = (episode: {
episode: { seasonNumber?: number | null;
seasonNumber?: number | null; episodeNumber?: number | null;
episodeNumber?: number | null; absoluteNumber?: number | null;
absoluteNumber?: number | null; }) => {
},
def?: string,
) => {
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number") if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`; return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString(); if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return def; return "??";
}; };
export const displayRuntime = (runtime: number | null) => { export const displayRuntime = (runtime: number | null) => {
@ -69,23 +67,21 @@ export const EpisodeBox = ({
name, name,
overview, overview,
thumbnail, thumbnail,
isLoading,
href, href,
watchedPercent, watchedPercent,
watchedStatus, watchedStatus,
...props ...props
}: Stylable & }: Stylable & {
WithLoading<{ slug: string;
slug: string; // if show slug is null, disable "Go to show" in the context menu
// if show slug is null, disable "Go to show" in the context menu showSlug: string | null;
showSlug: string | null; name: string | null;
name: string | null; overview: string | null;
overview: string | null; href: string;
href: string; thumbnail?: ImageProps["src"] | null;
thumbnail?: ImageProps["src"] | null; watchedPercent: number | null;
watchedPercent: number | null; watchedStatus: WatchStatusV | null;
watchedStatus: WatchStatusV | null; }) => {
}>) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episodebox"); const { css } = useYoshiki("episodebox");
const { t } = useTranslation(); const { t } = useTranslation();
@ -128,58 +124,65 @@ export const EpisodeBox = ({
quality="low" quality="low"
alt="" alt=""
gradient={false} gradient={false}
hideLoad={false}
forcedLoading={isLoading}
layout={{ width: percent(100), aspectRatio: 16 / 9 }} layout={{ width: percent(100), aspectRatio: 16 / 9 }}
{...(css("poster") as any)} {...(css("poster") as any)}
> >
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && ( {(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
<ItemProgress watchPercent={watchedPercent ?? 100} /> <ItemProgress watchPercent={watchedPercent ?? 100} />
)} )}
{slug && watchedStatus !== undefined && ( <EpisodesContext
<EpisodesContext slug={slug}
slug={slug} showSlug={showSlug}
showSlug={showSlug} status={watchedStatus}
status={watchedStatus} isOpen={moreOpened}
isOpen={moreOpened} setOpen={(v) => setMoreOpened(v)}
setOpen={(v) => setMoreOpened(v)} {...css([
{...css([ {
{ position: "absolute",
position: "absolute", top: 0,
top: 0, right: 0,
right: 0, bg: (theme) => theme.darkOverlay,
bg: (theme) => theme.darkOverlay, },
}, "more",
"more", Platform.OS === "web" && moreOpened && { display: important("flex") },
Platform.OS === "web" && moreOpened && { display: important("flex") }, ])}
])} />
/>
)}
</ImageBackground> </ImageBackground>
<Skeleton {...css({ width: percent(50) })}> <P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
{isLoading || ( {name ?? t("show.episodeNoMetadata")}
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}> </P>
{name ?? t("show.episodeNoMetadata")} <SubP
</P> numberOfLines={3}
)} {...css({
</Skeleton> marginTop: 0,
<Skeleton {...css({ width: percent(75), height: rem(0.8) })}> textAlign: "center",
{isLoading || ( })}
<SubP >
numberOfLines={3} {overview}
{...css({ </SubP>
marginTop: 0,
textAlign: "center",
})}
>
{overview}
</SubP>
)}
</Skeleton>
</Link> </Link>
); );
}; };
EpisodeBox.Loader = (props: Stylable) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
},
props,
)}
>
<Image.Loader layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
<Skeleton {...css({ width: percent(50) })} />
<Skeleton {...css({ width: percent(75), height: rem(0.8) })} />
</View>
);
};
export const EpisodeLine = ({ export const EpisodeLine = ({
slug, slug,
showSlug, showSlug,
@ -187,7 +190,6 @@ export const EpisodeLine = ({
name, name,
thumbnail, thumbnail,
overview, overview,
isLoading,
id, id,
absoluteNumber, absoluteNumber,
episodeNumber, episodeNumber,
@ -198,7 +200,7 @@ export const EpisodeLine = ({
watchedStatus, watchedStatus,
href, href,
...props ...props
}: WithLoading<{ }: {
id: string; id: string;
slug: string; slug: string;
// if show slug is null, disable "Go to show" in the context menu // if show slug is null, disable "Go to show" in the context menu
@ -215,8 +217,7 @@ export const EpisodeLine = ({
watchedPercent: number | null; watchedPercent: number | null;
watchedStatus: WatchStatusV | null; watchedStatus: WatchStatusV | null;
href: string; href: string;
}> & } & PressableProps &
PressableProps &
Stylable) => { Stylable) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false);
@ -254,7 +255,6 @@ export const EpisodeLine = ({
quality="low" quality="low"
alt="" alt=""
gradient={false} gradient={false}
hideLoad={false}
layout={{ layout={{
width: percent(18), width: percent(18),
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
@ -293,48 +293,36 @@ export const EpisodeLine = ({
justifyContent: "space-between", justifyContent: "space-between",
})} })}
> >
<Skeleton> {/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
{isLoading || ( <H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P {[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}> </H6>
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
</H6>
)}
</Skeleton>
<View {...css({ flexDirection: "row", alignItems: "center" })}> <View {...css({ flexDirection: "row", alignItems: "center" })}>
<Skeleton> <SubP>
{isLoading || ( {[
<SubP> // @ts-ignore Source https://www.i18next.com/translation-function/formatting#datetime
{/* Source https://www.i18next.com/translation-function/formatting#datetime */} releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
{[ displayRuntime(runtime),
releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null, ]
displayRuntime(runtime), .filter((item) => item != null)
] .join(" · ")}
.filter((item) => item != null) </SubP>
.join(" · ")} <EpisodesContext
</SubP> slug={slug}
)} showSlug={showSlug}
</Skeleton> status={watchedStatus}
{slug && watchedStatus !== undefined && ( isOpen={moreOpened}
<EpisodesContext setOpen={(v) => setMoreOpened(v)}
slug={slug} {...css([
showSlug={showSlug} "more",
status={watchedStatus} { display: "flex", marginLeft: ts(3) },
isOpen={moreOpened} Platform.OS === "web" && moreOpened && { display: important("flex") },
setOpen={(v) => setMoreOpened(v)} ])}
{...css([ />
"more",
{ display: "flex", marginLeft: ts(3) },
Platform.OS === "web" && moreOpened && { display: important("flex") },
])}
/>
)}
</View> </View>
</View> </View>
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row", justifyContent: "space-between" })}>
<Skeleton> <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>
{isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>}
</Skeleton>
<IconButton <IconButton
{...css(["more", Platform.OS !== "web" && { opacity: 1 }])} {...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
icon={descriptionExpanded ? ExpandLess : ExpandMore} icon={descriptionExpanded ? ExpandLess : ExpandMore}
@ -349,6 +337,45 @@ export const EpisodeLine = ({
</Link> </Link>
); );
}; };
EpisodeLine.Loader = (props: Stylable) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
flexDirection: "row",
},
props,
)}
>
<Image.Loader
layout={{
width: percent(18),
aspectRatio: 16 / 9,
}}
{...css({ flexShrink: 0, m: ts(1) })}
/>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<View
{...css({
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
justifyContent: "space-between",
})}
>
<Skeleton {...css({ width: percent(30) })} />
<Skeleton {...css({ width: percent(15) })} />
</View>
<Skeleton />
</View>
</View>
);
};
EpisodeLine.layout = { EpisodeLine.layout = {
numColumns: 1, numColumns: 1,
size: 100, size: 100,

View File

@ -26,7 +26,7 @@ import {
SeasonP, SeasonP,
useInfiniteFetch, useInfiniteFetch,
} from "@kyoo/models"; } from "@kyoo/models";
import { H6, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives"; import { H2, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg"; import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -38,14 +38,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string }; type SeasonProcessed = Season & { href: string };
export const SeasonHeader = ({ export const SeasonHeader = ({
isLoading,
seasonNumber, seasonNumber,
name, name,
seasons, seasons,
}: { }: {
isLoading: boolean; seasonNumber: number;
seasonNumber?: number; name: string | null;
name?: string;
seasons?: SeasonProcessed[]; seasons?: SeasonProcessed[];
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -63,21 +61,20 @@ export const SeasonHeader = ({
fontSize: rem(1.5), fontSize: rem(1.5),
})} })}
> >
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber} {seasonNumber}
</P> </P>
<H6 <H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
aria-level={2} {name ?? t("show.season", { number: seasonNumber })}
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })} </H2>
>
{isLoading ? <Skeleton /> : name}
</H6>
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}> <Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
{seasons {seasons
?.filter((x) => x.episodesCount > 0) ?.filter((x) => x.episodesCount > 0)
.map((x) => ( .map((x) => (
<Menu.Item <Menu.Item
key={x.seasonNumber} key={x.seasonNumber}
label={`${x.seasonNumber}: ${x.name} (${x.episodesCount})`} label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.episodesCount})`}
href={x.href} href={x.href}
/> />
))} ))}
@ -88,6 +85,31 @@ export const SeasonHeader = ({
); );
}; };
SeasonHeader.Loader = () => {
const { css } = useYoshiki();
return (
<View>
<View {...css({ flexDirection: "row", marginX: ts(1), justifyContent: "space-between" })}>
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Skeleton
variant="custom"
{...css({
width: rem(4),
flexShrink: 0,
marginX: ts(1),
height: rem(1.5),
})}
/>
<Skeleton {...css({ marginX: ts(1), width: rem(12), height: rem(2) })} />
</View>
<IconButton icon={MenuIcon} disabled />
</View>
<HR />
</View>
);
};
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({ SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
parser: SeasonP, parser: SeasonP,
path: ["show", slug, "seasons"], path: ["show", slug, "seasons"],
@ -128,34 +150,39 @@ export const EpisodeList = <Props,>({
divider divider
Header={Header} Header={Header}
headerProps={headerProps} headerProps={headerProps}
getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")} getItemType={(item) => (!item || item.firstOfSeason ? "withHeader" : "normal")}
contentContainerStyle={pageStyle} contentContainerStyle={pageStyle}
> placeholderCount={5}
{(item) => { Render={({ item }) => {
const sea = item?.firstOfSeason const sea = item?.firstOfSeason
? seasons?.find((x) => x.seasonNumber === item.seasonNumber) ? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null; : null;
return ( return (
<> <>
{item.firstOfSeason && ( {item.firstOfSeason &&
<SeasonHeader (sea ? (
isLoading={!sea} <SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
name={sea?.name} ) : (
seasonNumber={sea?.seasonNumber} <SeasonHeader.Loader />
seasons={seasons} ))}
/>
)}
<EpisodeLine <EpisodeLine
{...item} {...item}
// Don't display "Go to show"
showSlug={null} showSlug={null}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!} displayNumber={episodeDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? null} watchedPercent={item.watchStatus?.watchedPercent ?? null}
watchedStatus={item.watchStatus?.status ?? null} watchedStatus={item.watchStatus?.status ?? null}
/> />
</> </>
); );
}} }}
</InfiniteFetch> Loader={({ index }) => (
<>
{index === 0 && <SeasonHeader.Loader />}
<EpisodeLine.Loader />
</>
)}
/>
); );
}; };

View File

@ -79,12 +79,11 @@ export const ShowWatchStatusCard = ({ watchedPercent, status, nextEpisode }: Sho
> >
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2> <H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine <EpisodeLine
isLoading={false}
{...nextEpisode} {...nextEpisode}
showSlug={null} showSlug={null}
watchedPercent={watchedPercent || null} watchedPercent={watchedPercent || null}
watchedStatus={status || null} watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode, "???")!} displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)} onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)} onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)} onFocus={() => setFocus(true)}

View File

@ -195,6 +195,7 @@ const downloadIcon = (status: State["status"]) => {
return Downloading; return Downloading;
case "FAILED": case "FAILED":
return ErrorIcon; return ErrorIcon;
case "PENDING":
case "PAUSED": case "PAUSED":
case "STOPPED": case "STOPPED":
return NotStarted; return NotStarted;

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 Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models"; import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { HR, useBreakpointMap } from "@kyoo/primitives"; import { HR, useBreakpointMap } from "@kyoo/primitives";
import { type ContentStyle, FlashList } from "@shopify/flash-list"; import { type ContentStyle, FlashList } from "@shopify/flash-list";
import { import {
@ -30,7 +30,7 @@ import {
} from "react"; } from "react";
import { FlatList, View, type ViewStyle } from "react-native"; import { FlatList, View, type ViewStyle } from "react-native";
import { ErrorView } from "./errors"; import { ErrorView } from "./errors";
import { EmptyView, type Layout, OfflineView, type WithLoading, addHeader } from "./fetch"; import { EmptyView, type Layout, OfflineView, addHeader } from "./fetch";
const emulateGap = ( const emulateGap = (
layout: "grid" | "vertical" | "horizontal", layout: "grid" | "vertical" | "horizontal",
@ -65,7 +65,8 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
query, query,
placeholderCount = 2, placeholderCount = 2,
incremental = false, incremental = false,
children, Render,
Loader,
layout, layout,
empty, empty,
divider = false, divider = false,
@ -82,16 +83,14 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
placeholderCount?: number; placeholderCount?: number;
layout: Layout; layout: Layout;
horizontal?: boolean; horizontal?: boolean;
children: ( Render: (props: { item: Data; index: number }) => ReactElement | null;
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>, Loader: (props: { index: number }) => ReactElement | null;
i: number,
) => ReactElement | null;
empty?: string | JSX.Element; empty?: string | JSX.Element;
incremental?: boolean; incremental?: boolean;
divider?: boolean | ComponentType; divider?: boolean | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement; Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props; headerProps?: Props;
getItemType?: (item: WithLoading<Data>, index: number) => Kind; getItemType?: (item: Data | null, index: number) => Kind;
getItemSize?: (kind: Kind) => number; getItemSize?: (kind: Kind) => number;
fetchMore?: boolean; fetchMore?: boolean;
nested?: boolean; nested?: boolean;
@ -111,9 +110,7 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
if (incremental) items ??= oldItems.current; if (incremental) items ??= oldItems.current;
const count = items ? numColumns - (items.length % numColumns) : placeholderCount; const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].map( const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
(_, i) => ({ id: `gen${i}`, isLoading: true }) as Data,
);
const data = isFetching || !items ? [...(items || []), ...placeholders] : items; const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList; const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
@ -137,12 +134,12 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
}, },
]} ]}
> >
{children({ isLoading: false, ...item } as any, index)} {item ? <Render index={index} item={item} /> : <Loader index={index} />}
</View> </View>
)} )}
data={data} data={data}
horizontal={layout.layout === "horizontal"} horizontal={layout.layout === "horizontal"}
keyExtractor={(item: any) => item.id} keyExtractor={(item: any, index) => (item ? item.id : index)}
numColumns={layout.layout === "horizontal" ? 1 : numColumns} numColumns={layout.layout === "horizontal" ? 1 : numColumns}
estimatedItemSize={size} estimatedItemSize={size}
onEndReached={fetchMore ? fetchNextPage : undefined} onEndReached={fetchMore ? fetchNextPage : undefined}

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 Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models"; import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { HR } from "@kyoo/primitives"; import { HR } from "@kyoo/primitives";
import type { ContentStyle } from "@shopify/flash-list"; import type { ContentStyle } from "@shopify/flash-list";
import { import {
@ -33,7 +33,7 @@ import {
} from "react"; } from "react";
import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki"; import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
import { ErrorView } from "./errors"; import { ErrorView } from "./errors";
import { EmptyView, type Layout, type WithLoading, addHeader } from "./fetch"; import { EmptyView, type Layout, addHeader } from "./fetch";
const InfiniteScroll = <Props,>({ const InfiniteScroll = <Props,>({
children, children,
@ -145,7 +145,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
query, query,
incremental = false, incremental = false,
placeholderCount = 2, placeholderCount = 2,
children, Render,
layout, layout,
empty, empty,
divider: Divider = false, divider: Divider = false,
@ -154,21 +154,20 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
getItemType, getItemType,
getItemSize, getItemSize,
nested, nested,
Loader,
...props ...props
}: { }: {
query: ReturnType<typeof useInfiniteFetch<_, Data>>; query: ReturnType<typeof useInfiniteFetch<_, Data>>;
incremental?: boolean; incremental?: boolean;
placeholderCount?: number; placeholderCount?: number;
layout: Layout; layout: Layout;
children: ( Render: (props: { item: Data; index: number }) => ReactElement | null;
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>, Loader: (props: { index: number }) => ReactElement | null;
i: number,
) => ReactElement | null;
empty?: string | JSX.Element; empty?: string | JSX.Element;
divider?: boolean | ComponentType; divider?: boolean | ComponentType;
Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement; Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
headerProps: HeaderProps; headerProps: HeaderProps;
getItemType?: (item: WithLoading<Data>, index: number) => Kind; getItemType?: (item: Data | null, index: number) => Kind;
getItemSize?: (kind: Kind) => number; getItemSize?: (kind: Kind) => number;
fetchMore?: boolean; fetchMore?: boolean;
contentContainerStyle?: ContentStyle; contentContainerStyle?: ContentStyle;
@ -193,7 +192,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
loader={[...Array(placeholderCount)].map((_, i) => ( loader={[...Array(placeholderCount)].map((_, i) => (
<Fragment key={i.toString()}> <Fragment key={i.toString()}>
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)} {Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
{children({ isLoading: true } as any, i)} <Loader index={i} />
</Fragment> </Fragment>
))} ))}
Header={Header} Header={Header}
@ -203,7 +202,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
{(items ?? oldItems.current)?.map((item, i) => ( {(items ?? oldItems.current)?.map((item, i) => (
<Fragment key={(item as any).id}> <Fragment key={(item as any).id}>
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)} {Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
{children({ ...item, isLoading: false } as any, i)} <Render item={item} index={i} />
</Fragment> </Fragment>
))} ))}
</InfiniteScroll> </InfiniteScroll>

View File

@ -75,13 +75,9 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
layout={{ ...ItemGrid.layout, layout: "horizontal" }} layout={{ ...ItemGrid.layout, layout: "horizontal" }}
placeholderCount={2} placeholderCount={2}
empty={displayEmpty.current ? t("home.none") : undefined} empty={displayEmpty.current ? t("home.none") : undefined}
> Render={({ item }) => <ItemGrid {...itemMap(item)} />}
{(x, i) => { Loader={ItemGrid.Loader}
// only display empty list if a loading as been displayed (not durring ssr) />
if (x.isLoading) displayEmpty.current = true;
return <ItemGrid key={x.id ?? i} {...itemMap(x)} />;
}}
</InfiniteFetchList>
</> </>
); );
}; };

View File

@ -36,41 +36,43 @@ export const NewsList = () => {
<InfiniteFetch <InfiniteFetch
query={NewsList.query()} query={NewsList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }} layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) => (x.kind === "movie" || (x.isLoading && i % 2) ? "movie" : "episode")} getItemType={(x, i) => (x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode")}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)} getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")} empty={t("home.none")}
> Render={({ item }) => {
{(x, i) => if (item.kind === "episode") {
x.kind === "movie" || (x.isLoading && i % 2) ? ( return (
<EpisodeBox
slug={item.slug}
showSlug={item.show!.slug}
name={`${item.show!.name} ${episodeDisplayNumber(item)}`}
overview={item.name}
thumbnail={item.thumbnail}
href={item.href}
watchedPercent={item.watchStatus?.watchedPercent || null}
watchedStatus={item.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
/>
);
}
return (
<ItemGrid <ItemGrid
isLoading={x.isLoading as any} href={item.href}
href={x.href} slug={item.slug}
slug={x.slug} name={item.name!}
name={x.name!} subtitle={getDisplayDate(item)}
subtitle={!x.isLoading ? getDisplayDate(x) : undefined} poster={item.poster}
poster={x.poster} watchStatus={item.watchStatus?.status || null}
watchStatus={x.watchStatus?.status || null} watchPercent={item.watchStatus?.watchedPercent || null}
watchPercent={x.watchStatus?.watchedPercent || null} unseenEpisodesCount={null}
type={"movie"} type={"movie"}
/> />
) : ( );
<EpisodeBox }}
isLoading={x.isLoading as any} Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
slug={x.slug} />
showSlug={x.kind === "episode" ? x.show!.slug : null}
name={x.kind === "episode" ? `${x.show!.name} ${episodeDisplayNumber(x)}` : undefined}
overview={x.name}
thumbnail={x.thumbnail}
href={x.href}
watchedPercent={x.watchStatus?.watchedPercent || null}
watchedStatus={x.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
/>
)
}
</InfiniteFetch>
</> </>
); );
}; };

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>
); );
}; };

View File

@ -41,9 +41,9 @@ export const VerticalRecommended = () => {
layout={{ ...ItemList.layout, layout: "vertical" }} layout={{ ...ItemList.layout, layout: "vertical" }}
fetchMore={false} fetchMore={false}
nested nested
> Render={({ item }) => <ItemList {...itemMap(item)} />}
{(x, i) => <ItemList key={x.id ?? i} {...itemMap(x)} />} Loader={() => <ItemList.Loader />}
</InfiniteFetch> />
</View> </View>
); );
}; };

View File

@ -39,55 +39,10 @@ export const WatchlistList = () => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const account = useAccount(); const account = useAccount();
return ( if (!account) {
<> return (
<Header title={t("home.watchlist")} /> <>
{account ? ( <Header title={t("home.watchlist")} />
<InfiniteFetch
query={WatchlistList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) =>
(x.kind === "show" && x.watchStatus?.nextEpisode) || (x.isLoading && i % 2)
? "episode"
: "item"
}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
>
{(x, i) => {
const episode = x.kind === "show" ? x.watchStatus?.nextEpisode : null;
return (x.kind === "show" && x.watchStatus?.nextEpisode) || (x.isLoading && i % 2) ? (
<EpisodeBox
isLoading={x.isLoading as any}
slug={episode?.slug}
showSlug={x.slug}
name={episode ? `${x.name} ${episodeDisplayNumber(episode)}` : undefined}
overview={episode?.name}
thumbnail={episode?.thumbnail ?? x.thumbnail}
href={episode?.href}
watchedPercent={x.watchStatus?.watchedPercent || null}
watchedStatus={x.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
/>
) : (
<ItemGrid
isLoading={x.isLoading as any}
href={x.href}
slug={x.slug}
name={x.name!}
subtitle={!x.isLoading ? getDisplayDate(x) : undefined}
poster={x.poster}
watchStatus={x.watchStatus?.status || null}
watchPercent={x.watchStatus?.watchedPercent || null}
unseenEpisodesCount={x.kind === "show" ? x.watchStatus?.unseenEpisodesCount : null}
type={x.kind}
/>
);
}}
</InfiniteFetch>
) : (
<View {...css({ justifyContent: "center", alignItems: "center" })}> <View {...css({ justifyContent: "center", alignItems: "center" })}>
<P>{t("home.watchlistLogin")}</P> <P>{t("home.watchlistLogin")}</P>
<Button <Button
@ -96,7 +51,58 @@ export const WatchlistList = () => {
{...css({ minWidth: ts(24), margin: ts(2) })} {...css({ minWidth: ts(24), margin: ts(2) })}
/> />
</View> </View>
)} </>
);
}
return (
<>
<Header title={t("home.watchlist")} />
<InfiniteFetch
query={WatchlistList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) =>
(x?.kind === "show" && x.watchStatus?.nextEpisode) || (!x && i % 2) ? "episode" : "item"
}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
Render={({ item }) => {
const episode = item.kind === "show" ? item.watchStatus?.nextEpisode : null;
if (episode) {
return (
<EpisodeBox
slug={episode.slug}
showSlug={item.slug}
name={`${item.name} ${episodeDisplayNumber(episode)}`}
overview={episode.name}
thumbnail={episode.thumbnail ?? item.thumbnail}
href={episode.href}
watchedPercent={item.watchStatus?.watchedPercent || null}
watchedStatus={item.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
/>
);
}
return (
<ItemGrid
href={item.href}
slug={item.slug}
name={item.name!}
subtitle={getDisplayDate(item)}
poster={item.poster}
watchStatus={item.watchStatus?.status || null}
watchPercent={item.watchStatus?.watchedPercent || null}
unseenEpisodesCount={
(item.kind === "show" && item.watchStatus?.unseenEpisodesCount) || null
}
type={item.kind}
/>
);
}}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
/>
</> </>
); );
}; };

View File

@ -53,7 +53,7 @@ const mapData = (
if (!data) return { isLoading: true }; if (!data) return { isLoading: true };
return { return {
isLoading: false, isLoading: false,
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`, name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`,
showName: data.type === "movie" ? data.name! : data.show!.name, showName: data.type === "movie" ? data.name! : data.show!.name,
poster: data.type === "movie" ? data.poster : data.show!.poster, poster: data.type === "movie" ? data.poster : data.show!.poster,
subtitles: info?.subtitles, subtitles: info?.subtitles,

View File

@ -77,9 +77,9 @@ export const SearchPage: QueryPage<{ q?: string }> = ({ q }) => {
/> />
} }
contentContainerStyle={pageStyle} contentContainerStyle={pageStyle}
> Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
{(item) => <LayoutComponent {...itemMap(item)} />} Loader={LayoutComponent.Loader}
</InfiniteFetch> />
); );
}; };

View File

@ -39,7 +39,8 @@
"droped": "Mark as dropped", "droped": "Mark as dropped",
"null": "Mark as not seen" "null": "Mark as not seen"
}, },
"nextUp": "Next up" "nextUp": "Next up",
"season": "Season {{number}}"
}, },
"browse": { "browse": {
"sortby": "Sort by {{key}}", "sortby": "Sort by {{key}}",