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) {
return airDate.getFullYear().toString();
}
return null;
};
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 { type Stylable, px, useYoshiki } from "yoshiki/native";
import { Icon } from "./icons";
import { Skeleton } from "./skeleton";
import { P } from "./text";
const stringToColor = (string: string) => {
@ -40,7 +41,7 @@ const stringToColor = (string: string) => {
return color;
};
export const Avatar = forwardRef<
const AvatarC = forwardRef<
View,
{
src?: string;
@ -48,12 +49,11 @@ export const Avatar = forwardRef<
size?: number;
placeholder?: string;
color?: string;
isLoading?: boolean;
fill?: boolean;
as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
} & Stylable
>(function Avatar(
{ src, alt, size = px(24), color, placeholder, isLoading = false, fill = false, as, ...props },
>(function AvatarI(
{ src, alt, size = px(24), color, placeholder, fill = false, as, ...props },
ref,
) {
const { css, theme } = useYoshiki();
@ -106,3 +106,22 @@ export const Avatar = forwardRef<
</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/>.
*/
import type { TextProps } from "react-native";
import { type TextProps, View } from "react-native";
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
import { Link } from "./links";
import { Skeleton } from "./skeleton";
@ -63,6 +63,7 @@ export const Chip = ({
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
@ -102,3 +103,40 @@ export const Chip = ({
</Link>
);
};
Chip.Loader = ({
color,
size = "medium",
outline = false,
...props
}: { color?: string; size?: "small" | "medium" | "large"; outline?: boolean }) => {
const { css } = useYoshiki();
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
return (
<View
{...css(
[
{
pY: ts(1 * sizeMult),
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
borderStyle: "solid",
borderWidth: px(1),
},
!outline && {
bg: color ?? ((theme: Theme) => theme.accent),
},
],
props,
)}
>
<Skeleton {...css({ width: rem(3) })} />
</View>
);
};

View File

@ -45,7 +45,7 @@ type IconProps = {
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki();
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,
) as any;

View File

@ -19,7 +19,7 @@
*/
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 { Blurhash } from "react-native-blurhash";
import FastImage from "react-native-fast-image";
@ -93,3 +93,10 @@ export const Image = ({
</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 { useState } from "react";
import { type ReactElement, useState } from "react";
import { type ImageStyle, View, type ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { imageBorderRadius } from "../constants";
@ -73,3 +73,10 @@ export const Image = ({
</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 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";
@ -39,6 +39,14 @@ export const Poster = ({
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <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 = ({
alt,
layout,
@ -86,7 +94,7 @@ export const ImageBackground = <AsProps = ViewProps>({
{({ css, theme }) => (
<Container
{...(css(
[layout, !hideLoad && { borderRadius: imageBorderRadius, overflow: "hidden" }],
[layout, { borderRadius: imageBorderRadius, overflow: "hidden" }],
asProps,
) as AsProps)}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ import {
SeasonP,
useInfiniteFetch,
} 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 type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
@ -38,14 +38,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string };
export const SeasonHeader = ({
isLoading,
seasonNumber,
name,
seasons,
}: {
isLoading: boolean;
seasonNumber?: number;
name?: string;
seasonNumber: number;
name: string | null;
seasons?: SeasonProcessed[];
}) => {
const { css } = useYoshiki();
@ -63,21 +61,20 @@ export const SeasonHeader = ({
fontSize: rem(1.5),
})}
>
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber}
{seasonNumber}
</P>
<H6
aria-level={2}
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}
>
{isLoading ? <Skeleton /> : name}
</H6>
<H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
{name ?? t("show.season", { number: seasonNumber })}
</H2>
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
{seasons
?.filter((x) => x.episodesCount > 0)
.map((x) => (
<Menu.Item
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}
/>
))}
@ -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> => ({
parser: SeasonP,
path: ["show", slug, "seasons"],
@ -128,34 +150,39 @@ export const EpisodeList = <Props,>({
divider
Header={Header}
headerProps={headerProps}
getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")}
getItemType={(item) => (!item || item.firstOfSeason ? "withHeader" : "normal")}
contentContainerStyle={pageStyle}
>
{(item) => {
placeholderCount={5}
Render={({ item }) => {
const sea = item?.firstOfSeason
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null;
return (
<>
{item.firstOfSeason && (
<SeasonHeader
isLoading={!sea}
name={sea?.name}
seasonNumber={sea?.seasonNumber}
seasons={seasons}
/>
)}
{item.firstOfSeason &&
(sea ? (
<SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
) : (
<SeasonHeader.Loader />
))}
<EpisodeLine
{...item}
// Don't display "Go to show"
showSlug={null}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
displayNumber={episodeDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? 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>
<EpisodeLine
isLoading={false}
{...nextEpisode}
showSlug={null}
watchedPercent={watchedPercent || null}
watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode, "???")!}
displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)}

View File

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

View File

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

View File

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

View File

@ -75,13 +75,9 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
placeholderCount={2}
empty={displayEmpty.current ? t("home.none") : undefined}
>
{(x, i) => {
// 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>
Render={({ item }) => <ItemGrid {...itemMap(item)} />}
Loader={ItemGrid.Loader}
/>
</>
);
};

View File

@ -36,41 +36,43 @@ export const NewsList = () => {
<InfiniteFetch
query={NewsList.query()}
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)}
empty={t("home.none")}
>
{(x, i) =>
x.kind === "movie" || (x.isLoading && i % 2) ? (
Render={({ item }) => {
if (item.kind === "episode") {
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
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}
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={null}
type={"movie"}
/>
) : (
<EpisodeBox
isLoading={x.isLoading as any}
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>
);
}}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
/>
</>
);
};

View File

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

View File

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

View File

@ -39,55 +39,10 @@ export const WatchlistList = () => {
const { css } = useYoshiki();
const account = useAccount();
return (
<>
<Header title={t("home.watchlist")} />
{account ? (
<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>
) : (
if (!account) {
return (
<>
<Header title={t("home.watchlist")} />
<View {...css({ justifyContent: "center", alignItems: "center" })}>
<P>{t("home.watchlistLogin")}</P>
<Button
@ -96,7 +51,58 @@ export const WatchlistList = () => {
{...css({ minWidth: ts(24), margin: ts(2) })}
/>
</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 };
return {
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,
poster: data.type === "movie" ? data.poster : data.show!.poster,
subtitles: info?.subtitles,

View File

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

View File

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