mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-23 15:30:34 -04:00
Split skeleton and normal state in all lists (#506)
This commit is contained in:
commit
a37ace7d46
@ -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) => {
|
||||
|
@ -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 });
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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)} />;
|
||||
};
|
||||
|
@ -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)} />;
|
||||
};
|
||||
|
@ -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)}
|
||||
>
|
||||
|
@ -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,8 +98,6 @@ export const Skeleton = ({
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{children}
|
||||
{(forcedShow || !children || children === true) &&
|
||||
[...Array(lines)].map((_, i) => (
|
||||
<MotiView
|
||||
@ -110,8 +107,7 @@ export const Skeleton = ({
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ type: "timing" }}
|
||||
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
||||
{...css(
|
||||
[
|
||||
{...css([
|
||||
{
|
||||
bg: (theme) => theme.overlay0,
|
||||
},
|
||||
@ -129,9 +125,7 @@ export const Skeleton = ({
|
||||
overflow: "hidden",
|
||||
borderRadius: px(6),
|
||||
},
|
||||
],
|
||||
hiddenIfNoJs,
|
||||
)}
|
||||
])}
|
||||
>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
@ -146,24 +140,17 @@ export const Skeleton = ({
|
||||
? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
|
||||
: undefined,
|
||||
}}
|
||||
{...css([
|
||||
{
|
||||
{...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>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
@ -21,7 +21,6 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||
import { hiddenIfNoJs } from "./utils/nojs";
|
||||
|
||||
export const SkeletonCss = () => (
|
||||
<style jsx global>{`
|
||||
@ -90,13 +89,11 @@ export const Skeleton = ({
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{(forcedShow || !children || children === true) &&
|
||||
[...Array(lines)].map((_, i) => (
|
||||
<View
|
||||
key={`skeleton_${i}`}
|
||||
{...css(
|
||||
[
|
||||
{...css([
|
||||
{
|
||||
bg: (theme) => theme.overlay0,
|
||||
},
|
||||
@ -114,9 +111,7 @@ export const Skeleton = ({
|
||||
overflow: "hidden",
|
||||
borderRadius: px(6),
|
||||
},
|
||||
],
|
||||
hiddenIfNoJs,
|
||||
)}
|
||||
])}
|
||||
>
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
@ -137,6 +132,7 @@ export const Skeleton = ({
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -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,19 +196,10 @@ export const ItemGrid = ({
|
||||
/>
|
||||
)}
|
||||
</PosterBackground>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
<P
|
||||
numberOfLines={subtitle ? 1 : 2}
|
||||
{...css([{ marginY: 0, textAlign: "center" }, "title"])}
|
||||
>
|
||||
<P numberOfLines={subtitle ? 1 : 2} {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
|
||||
{name}
|
||||
</P>
|
||||
)}
|
||||
</Skeleton>
|
||||
{(isLoading || subtitle) && (
|
||||
<Skeleton {...css({ width: percent(50) })}>
|
||||
{isLoading || (
|
||||
{subtitle && (
|
||||
<SubP
|
||||
{...css({
|
||||
marginTop: 0,
|
||||
@ -220,12 +209,31 @@ export const ItemGrid = ({
|
||||
{subtitle}
|
||||
</SubP>
|
||||
)}
|
||||
</Skeleton>
|
||||
)}
|
||||
</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 },
|
||||
|
@ -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,15 +37,11 @@ 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,
|
||||
item: LibraryItem,
|
||||
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
subtitle: item.kind !== "collection" ? getDisplayDate(item) : undefined,
|
||||
subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
|
||||
href: item.href,
|
||||
poster: item.poster,
|
||||
thumbnail: item.thumbnail,
|
||||
@ -55,8 +50,7 @@ export const itemMap = (
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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,8 +115,6 @@ export const ItemList = ({
|
||||
justifyContent: "center",
|
||||
})}
|
||||
>
|
||||
<Skeleton {...css({ height: rem(2), alignSelf: "center" })}>
|
||||
{isLoading || (
|
||||
<Heading
|
||||
{...css([
|
||||
"title",
|
||||
@ -130,9 +129,7 @@ export const ItemList = ({
|
||||
>
|
||||
{name}
|
||||
</Heading>
|
||||
)}
|
||||
</Skeleton>
|
||||
{slug && watchStatus !== undefined && type && type !== "collection" && (
|
||||
{type !== "collection" && (
|
||||
<ItemContext
|
||||
type={type}
|
||||
slug={slug}
|
||||
@ -151,9 +148,7 @@ export const ItemList = ({
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{(isLoading || subtitle) && (
|
||||
<Skeleton {...css({ width: rem(5), alignSelf: "center" })}>
|
||||
{isLoading || (
|
||||
{subtitle && (
|
||||
<P
|
||||
{...css({
|
||||
textAlign: "center",
|
||||
@ -163,20 +158,46 @@ export const ItemList = ({
|
||||
{subtitle}
|
||||
</P>
|
||||
)}
|
||||
</Skeleton>
|
||||
)}
|
||||
</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;
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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: {
|
||||
export const episodeDisplayNumber = (episode: {
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
absoluteNumber?: number | null;
|
||||
},
|
||||
def?: string,
|
||||
) => {
|
||||
}) => {
|
||||
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,13 +67,11 @@ export const EpisodeBox = ({
|
||||
name,
|
||||
overview,
|
||||
thumbnail,
|
||||
isLoading,
|
||||
href,
|
||||
watchedPercent,
|
||||
watchedStatus,
|
||||
...props
|
||||
}: Stylable &
|
||||
WithLoading<{
|
||||
}: Stylable & {
|
||||
slug: string;
|
||||
// if show slug is null, disable "Go to show" in the context menu
|
||||
showSlug: string | null;
|
||||
@ -85,7 +81,7 @@ export const EpisodeBox = ({
|
||||
thumbnail?: ImageProps["src"] | null;
|
||||
watchedPercent: number | null;
|
||||
watchedStatus: WatchStatusV | null;
|
||||
}>) => {
|
||||
}) => {
|
||||
const [moreOpened, setMoreOpened] = useState(false);
|
||||
const { css } = useYoshiki("episodebox");
|
||||
const { t } = useTranslation();
|
||||
@ -128,15 +124,12 @@ 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}
|
||||
@ -154,17 +147,10 @@ export const EpisodeBox = ({
|
||||
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({
|
||||
@ -174,12 +160,29 @@ export const EpisodeBox = ({
|
||||
>
|
||||
{overview}
|
||||
</SubP>
|
||||
)}
|
||||
</Skeleton>
|
||||
</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,29 +293,20 @@ export const EpisodeLine = ({
|
||||
justifyContent: "space-between",
|
||||
})}
|
||||
>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
|
||||
{/* 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>
|
||||
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
<SubP>
|
||||
{/* Source https://www.i18next.com/translation-function/formatting#datetime */}
|
||||
{[
|
||||
// @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>
|
||||
)}
|
||||
</Skeleton>
|
||||
{slug && watchedStatus !== undefined && (
|
||||
<EpisodesContext
|
||||
slug={slug}
|
||||
showSlug={showSlug}
|
||||
@ -328,13 +319,10 @@ export const EpisodeLine = ({
|
||||
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,
|
||||
|
@ -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 />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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)}
|
||||
|
@ -195,6 +195,7 @@ const downloadIcon = (status: State["status"]) => {
|
||||
return Downloading;
|
||||
case "FAILED":
|
||||
return ErrorIcon;
|
||||
case "PENDING":
|
||||
case "PAUSED":
|
||||
case "STOPPED":
|
||||
return NotStarted;
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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) ? (
|
||||
<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}
|
||||
type={"movie"}
|
||||
/>
|
||||
) : (
|
||||
Render={({ item }) => {
|
||||
if (item.kind === "episode") {
|
||||
return (
|
||||
<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}
|
||||
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" })}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
</InfiniteFetch>
|
||||
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={null}
|
||||
type={"movie"}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -39,55 +39,10 @@ export const WatchlistList = () => {
|
||||
const { css } = useYoshiki();
|
||||
const account = useAccount();
|
||||
|
||||
if (!account) {
|
||||
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>
|
||||
) : (
|
||||
<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 />)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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}}",
|
||||
|
Loading…
x
Reference in New Issue
Block a user