mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-11-03 19:17:16 -05: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