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) {
 | 
						if (airDate) {
 | 
				
			||||||
		return airDate.getFullYear().toString();
 | 
							return airDate.getFullYear().toString();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						return null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useLocalSetting = (setting: string, def: string) => {
 | 
					export const useLocalSetting = (setting: string, def: string) => {
 | 
				
			||||||
 | 
				
			|||||||
@ -23,6 +23,7 @@ import { type ComponentType, type RefAttributes, forwardRef } from "react";
 | 
				
			|||||||
import { Image, type ImageProps, View, type ViewStyle } from "react-native";
 | 
					import { Image, type ImageProps, View, type ViewStyle } from "react-native";
 | 
				
			||||||
import { type Stylable, px, useYoshiki } from "yoshiki/native";
 | 
					import { type Stylable, px, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { Icon } from "./icons";
 | 
					import { Icon } from "./icons";
 | 
				
			||||||
 | 
					import { Skeleton } from "./skeleton";
 | 
				
			||||||
import { P } from "./text";
 | 
					import { P } from "./text";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const stringToColor = (string: string) => {
 | 
					const stringToColor = (string: string) => {
 | 
				
			||||||
@ -40,7 +41,7 @@ const stringToColor = (string: string) => {
 | 
				
			|||||||
	return color;
 | 
						return color;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Avatar = forwardRef<
 | 
					const AvatarC = forwardRef<
 | 
				
			||||||
	View,
 | 
						View,
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		src?: string;
 | 
							src?: string;
 | 
				
			||||||
@ -48,12 +49,11 @@ export const Avatar = forwardRef<
 | 
				
			|||||||
		size?: number;
 | 
							size?: number;
 | 
				
			||||||
		placeholder?: string;
 | 
							placeholder?: string;
 | 
				
			||||||
		color?: string;
 | 
							color?: string;
 | 
				
			||||||
		isLoading?: boolean;
 | 
					 | 
				
			||||||
		fill?: boolean;
 | 
							fill?: boolean;
 | 
				
			||||||
		as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
 | 
							as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
 | 
				
			||||||
	} & Stylable
 | 
						} & Stylable
 | 
				
			||||||
>(function Avatar(
 | 
					>(function AvatarI(
 | 
				
			||||||
	{ src, alt, size = px(24), color, placeholder, isLoading = false, fill = false, as, ...props },
 | 
						{ src, alt, size = px(24), color, placeholder, fill = false, as, ...props },
 | 
				
			||||||
	ref,
 | 
						ref,
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
	const { css, theme } = useYoshiki();
 | 
						const { css, theme } = useYoshiki();
 | 
				
			||||||
@ -106,3 +106,22 @@ export const Avatar = forwardRef<
 | 
				
			|||||||
		</Container>
 | 
							</Container>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const AvatarLoader = ({ size = px(24), ...props }: { size?: number }) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<Skeleton
 | 
				
			||||||
 | 
								variant="round"
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										height: size,
 | 
				
			||||||
 | 
										width: size,
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							/>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Avatar = Object.assign(AvatarC, { Loader: AvatarLoader });
 | 
				
			||||||
 | 
				
			|||||||
@ -18,7 +18,7 @@
 | 
				
			|||||||
 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
					 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { TextProps } from "react-native";
 | 
					import { type TextProps, View } from "react-native";
 | 
				
			||||||
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { Link } from "./links";
 | 
					import { Link } from "./links";
 | 
				
			||||||
import { Skeleton } from "./skeleton";
 | 
					import { Skeleton } from "./skeleton";
 | 
				
			||||||
@ -63,6 +63,7 @@ export const Chip = ({
 | 
				
			|||||||
						pX: ts(2.5 * sizeMult),
 | 
											pX: ts(2.5 * sizeMult),
 | 
				
			||||||
						borderRadius: ts(3),
 | 
											borderRadius: ts(3),
 | 
				
			||||||
						overflow: "hidden",
 | 
											overflow: "hidden",
 | 
				
			||||||
 | 
											justifyContent: "center",
 | 
				
			||||||
					},
 | 
										},
 | 
				
			||||||
					outline && {
 | 
										outline && {
 | 
				
			||||||
						borderColor: color ?? ((theme: Theme) => theme.accent),
 | 
											borderColor: color ?? ((theme: Theme) => theme.accent),
 | 
				
			||||||
@ -102,3 +103,40 @@ export const Chip = ({
 | 
				
			|||||||
		</Link>
 | 
							</Link>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Chip.Loader = ({
 | 
				
			||||||
 | 
						color,
 | 
				
			||||||
 | 
						size = "medium",
 | 
				
			||||||
 | 
						outline = false,
 | 
				
			||||||
 | 
						...props
 | 
				
			||||||
 | 
					}: { color?: string; size?: "small" | "medium" | "large"; outline?: boolean }) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
						const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									[
 | 
				
			||||||
 | 
										{
 | 
				
			||||||
 | 
											pY: ts(1 * sizeMult),
 | 
				
			||||||
 | 
											pX: ts(2.5 * sizeMult),
 | 
				
			||||||
 | 
											borderRadius: ts(3),
 | 
				
			||||||
 | 
											overflow: "hidden",
 | 
				
			||||||
 | 
											justifyContent: "center",
 | 
				
			||||||
 | 
										},
 | 
				
			||||||
 | 
										outline && {
 | 
				
			||||||
 | 
											borderColor: color ?? ((theme: Theme) => theme.accent),
 | 
				
			||||||
 | 
											borderStyle: "solid",
 | 
				
			||||||
 | 
											borderWidth: px(1),
 | 
				
			||||||
 | 
										},
 | 
				
			||||||
 | 
										!outline && {
 | 
				
			||||||
 | 
											bg: color ?? ((theme: Theme) => theme.accent),
 | 
				
			||||||
 | 
										},
 | 
				
			||||||
 | 
									],
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<Skeleton {...css({ width: rem(3) })} />
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -45,7 +45,7 @@ type IconProps = {
 | 
				
			|||||||
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
 | 
					export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
 | 
				
			||||||
	const { css, theme } = useYoshiki();
 | 
						const { css, theme } = useYoshiki();
 | 
				
			||||||
	const computed = css(
 | 
						const computed = css(
 | 
				
			||||||
		{ width: size, height: size, fill: color ?? theme.contrast } as any,
 | 
							{ width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
 | 
				
			||||||
		props,
 | 
							props,
 | 
				
			||||||
	) as any;
 | 
						) as any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -19,7 +19,7 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { getCurrentToken } from "@kyoo/models";
 | 
					import { getCurrentToken } from "@kyoo/models";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { type ReactElement, useState } from "react";
 | 
				
			||||||
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
 | 
					import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
 | 
				
			||||||
import { Blurhash } from "react-native-blurhash";
 | 
					import { Blurhash } from "react-native-blurhash";
 | 
				
			||||||
import FastImage from "react-native-fast-image";
 | 
					import FastImage from "react-native-fast-image";
 | 
				
			||||||
@ -93,3 +93,10 @@ export const Image = ({
 | 
				
			|||||||
		</View>
 | 
							</View>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
						const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return <Skeleton variant="custom" show {...css([layout, border], props)} />;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -19,7 +19,7 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import NextImage from "next/image";
 | 
					import NextImage from "next/image";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { type ReactElement, useState } from "react";
 | 
				
			||||||
import { type ImageStyle, View, type ViewStyle } from "react-native";
 | 
					import { type ImageStyle, View, type ViewStyle } from "react-native";
 | 
				
			||||||
import { useYoshiki } from "yoshiki/native";
 | 
					import { useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { imageBorderRadius } from "../constants";
 | 
					import { imageBorderRadius } from "../constants";
 | 
				
			||||||
@ -73,3 +73,10 @@ export const Image = ({
 | 
				
			|||||||
		</BlurhashContainer>
 | 
							</BlurhashContainer>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
						const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return <Skeleton variant="custom" show {...css([layout, border], props)} />;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -19,7 +19,7 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
 | 
					import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
 | 
				
			||||||
import type { ComponentProps, ComponentType, ReactNode } from "react";
 | 
					import type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
 | 
				
			||||||
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
 | 
					import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
 | 
				
			||||||
import { percent } from "yoshiki/native";
 | 
					import { percent } from "yoshiki/native";
 | 
				
			||||||
import { imageBorderRadius } from "../constants";
 | 
					import { imageBorderRadius } from "../constants";
 | 
				
			||||||
@ -39,6 +39,14 @@ export const Poster = ({
 | 
				
			|||||||
	layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
 | 
						layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
 | 
				
			||||||
}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
 | 
					}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Poster.Loader = ({
 | 
				
			||||||
 | 
						layout,
 | 
				
			||||||
 | 
						...props
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
						children?: ReactElement;
 | 
				
			||||||
 | 
						layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
 | 
				
			||||||
 | 
					}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const PosterBackground = ({
 | 
					export const PosterBackground = ({
 | 
				
			||||||
	alt,
 | 
						alt,
 | 
				
			||||||
	layout,
 | 
						layout,
 | 
				
			||||||
@ -86,7 +94,7 @@ export const ImageBackground = <AsProps = ViewProps>({
 | 
				
			|||||||
			{({ css, theme }) => (
 | 
								{({ css, theme }) => (
 | 
				
			||||||
				<Container
 | 
									<Container
 | 
				
			||||||
					{...(css(
 | 
										{...(css(
 | 
				
			||||||
						[layout, !hideLoad && { borderRadius: imageBorderRadius, overflow: "hidden" }],
 | 
											[layout, { borderRadius: imageBorderRadius, overflow: "hidden" }],
 | 
				
			||||||
						asProps,
 | 
											asProps,
 | 
				
			||||||
					) as AsProps)}
 | 
										) as AsProps)}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
 | 
				
			|||||||
@ -19,11 +19,10 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { LinearGradient as LG } from "expo-linear-gradient";
 | 
					import { LinearGradient as LG } from "expo-linear-gradient";
 | 
				
			||||||
import { AnimatePresence, MotiView, motify } from "moti";
 | 
					import { MotiView, motify } from "moti";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { useState } from "react";
 | 
				
			||||||
import { Platform, View, type ViewProps } from "react-native";
 | 
					import { Platform, View, type ViewProps } from "react-native";
 | 
				
			||||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { hiddenIfNoJs } from "./utils/nojs";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const LinearGradient = motify(LG)();
 | 
					const LinearGradient = motify(LG)();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -99,71 +98,59 @@ export const Skeleton = ({
 | 
				
			|||||||
				props,
 | 
									props,
 | 
				
			||||||
			)}
 | 
								)}
 | 
				
			||||||
		>
 | 
							>
 | 
				
			||||||
			<AnimatePresence>
 | 
								{(forcedShow || !children || children === true) &&
 | 
				
			||||||
				{children}
 | 
									[...Array(lines)].map((_, i) => (
 | 
				
			||||||
				{(forcedShow || !children || children === true) &&
 | 
										<MotiView
 | 
				
			||||||
					[...Array(lines)].map((_, i) => (
 | 
											key={`skeleton_${i}`}
 | 
				
			||||||
						<MotiView
 | 
											// No clue why it is a number on mobile and a string on web but /shrug
 | 
				
			||||||
							key={`skeleton_${i}`}
 | 
											animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
 | 
				
			||||||
							// No clue why it is a number on mobile and a string on web but /shrug
 | 
											exit={{ opacity: 0 }}
 | 
				
			||||||
							animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
 | 
											transition={{ type: "timing" }}
 | 
				
			||||||
							exit={{ opacity: 0 }}
 | 
											onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
 | 
				
			||||||
							transition={{ type: "timing" }}
 | 
											{...css([
 | 
				
			||||||
							onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
 | 
												{
 | 
				
			||||||
							{...css(
 | 
													bg: (theme) => theme.overlay0,
 | 
				
			||||||
								[
 | 
												},
 | 
				
			||||||
									{
 | 
												lines === 1 && {
 | 
				
			||||||
										bg: (theme) => theme.overlay0,
 | 
													position: "absolute",
 | 
				
			||||||
									},
 | 
													top: 0,
 | 
				
			||||||
									lines === 1 && {
 | 
													bottom: 0,
 | 
				
			||||||
										position: "absolute",
 | 
													left: 0,
 | 
				
			||||||
										top: 0,
 | 
													right: 0,
 | 
				
			||||||
										bottom: 0,
 | 
												},
 | 
				
			||||||
										left: 0,
 | 
												lines !== 1 && {
 | 
				
			||||||
										right: 0,
 | 
													width: i === lines - 1 ? percent(40) : percent(100),
 | 
				
			||||||
									},
 | 
													height: rem(1.2),
 | 
				
			||||||
									lines !== 1 && {
 | 
													marginBottom: rem(0.5),
 | 
				
			||||||
										width: i === lines - 1 ? percent(40) : percent(100),
 | 
													overflow: "hidden",
 | 
				
			||||||
										height: rem(1.2),
 | 
													borderRadius: px(6),
 | 
				
			||||||
										marginBottom: rem(0.5),
 | 
												},
 | 
				
			||||||
										overflow: "hidden",
 | 
											])}
 | 
				
			||||||
										borderRadius: px(6),
 | 
										>
 | 
				
			||||||
									},
 | 
											<LinearGradient
 | 
				
			||||||
								],
 | 
												start={{ x: 0, y: 0.5 }}
 | 
				
			||||||
								hiddenIfNoJs,
 | 
												end={{ x: 1, y: 0.5 }}
 | 
				
			||||||
							)}
 | 
												colors={["transparent", theme.overlay1, "transparent"]}
 | 
				
			||||||
						>
 | 
												transition={{
 | 
				
			||||||
							<LinearGradient
 | 
													loop: true,
 | 
				
			||||||
								start={{ x: 0, y: 0.5 }}
 | 
													repeatReverse: false,
 | 
				
			||||||
								end={{ x: 1, y: 0.5 }}
 | 
												}}
 | 
				
			||||||
								colors={["transparent", theme.overlay1, "transparent"]}
 | 
												animate={{
 | 
				
			||||||
								transition={{
 | 
													translateX: width
 | 
				
			||||||
									loop: true,
 | 
														? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
 | 
				
			||||||
									repeatReverse: false,
 | 
														: undefined,
 | 
				
			||||||
								}}
 | 
												}}
 | 
				
			||||||
								animate={{
 | 
												{...css({
 | 
				
			||||||
									translateX: width
 | 
													position: "absolute",
 | 
				
			||||||
										? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
 | 
													top: 0,
 | 
				
			||||||
										: undefined,
 | 
													bottom: 0,
 | 
				
			||||||
								}}
 | 
													left: 0,
 | 
				
			||||||
								{...css([
 | 
													right: 0,
 | 
				
			||||||
									{
 | 
												})}
 | 
				
			||||||
										position: "absolute",
 | 
											/>
 | 
				
			||||||
										top: 0,
 | 
										</MotiView>
 | 
				
			||||||
										bottom: 0,
 | 
									))}
 | 
				
			||||||
										left: 0,
 | 
								{children}
 | 
				
			||||||
										right: 0,
 | 
					 | 
				
			||||||
									},
 | 
					 | 
				
			||||||
									Platform.OS === "web" && {
 | 
					 | 
				
			||||||
										// @ts-ignore Web only properties
 | 
					 | 
				
			||||||
										animation: "skeleton 1.6s linear 0.5s infinite",
 | 
					 | 
				
			||||||
										transform: "translateX(-100%)",
 | 
					 | 
				
			||||||
									},
 | 
					 | 
				
			||||||
								])}
 | 
					 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
						</MotiView>
 | 
					 | 
				
			||||||
					))}
 | 
					 | 
				
			||||||
			</AnimatePresence>
 | 
					 | 
				
			||||||
		</View>
 | 
							</View>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -21,7 +21,6 @@
 | 
				
			|||||||
import { LinearGradient } from "expo-linear-gradient";
 | 
					import { LinearGradient } from "expo-linear-gradient";
 | 
				
			||||||
import { View, type ViewProps } from "react-native";
 | 
					import { View, type ViewProps } from "react-native";
 | 
				
			||||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { hiddenIfNoJs } from "./utils/nojs";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SkeletonCss = () => (
 | 
					export const SkeletonCss = () => (
 | 
				
			||||||
	<style jsx global>{`
 | 
						<style jsx global>{`
 | 
				
			||||||
@ -90,33 +89,29 @@ export const Skeleton = ({
 | 
				
			|||||||
				props,
 | 
									props,
 | 
				
			||||||
			)}
 | 
								)}
 | 
				
			||||||
		>
 | 
							>
 | 
				
			||||||
			{children}
 | 
					 | 
				
			||||||
			{(forcedShow || !children || children === true) &&
 | 
								{(forcedShow || !children || children === true) &&
 | 
				
			||||||
				[...Array(lines)].map((_, i) => (
 | 
									[...Array(lines)].map((_, i) => (
 | 
				
			||||||
					<View
 | 
										<View
 | 
				
			||||||
						key={`skeleton_${i}`}
 | 
											key={`skeleton_${i}`}
 | 
				
			||||||
						{...css(
 | 
											{...css([
 | 
				
			||||||
							[
 | 
												{
 | 
				
			||||||
								{
 | 
													bg: (theme) => theme.overlay0,
 | 
				
			||||||
									bg: (theme) => theme.overlay0,
 | 
												},
 | 
				
			||||||
								},
 | 
												lines === 1 && {
 | 
				
			||||||
								lines === 1 && {
 | 
													position: "absolute",
 | 
				
			||||||
									position: "absolute",
 | 
													top: 0,
 | 
				
			||||||
									top: 0,
 | 
													bottom: 0,
 | 
				
			||||||
									bottom: 0,
 | 
													left: 0,
 | 
				
			||||||
									left: 0,
 | 
													right: 0,
 | 
				
			||||||
									right: 0,
 | 
												},
 | 
				
			||||||
								},
 | 
												lines !== 1 && {
 | 
				
			||||||
								lines !== 1 && {
 | 
													width: i === lines - 1 ? percent(40) : percent(100),
 | 
				
			||||||
									width: i === lines - 1 ? percent(40) : percent(100),
 | 
													height: rem(1.2),
 | 
				
			||||||
									height: rem(1.2),
 | 
													marginBottom: rem(0.5),
 | 
				
			||||||
									marginBottom: rem(0.5),
 | 
													overflow: "hidden",
 | 
				
			||||||
									overflow: "hidden",
 | 
													borderRadius: px(6),
 | 
				
			||||||
									borderRadius: px(6),
 | 
												},
 | 
				
			||||||
								},
 | 
											])}
 | 
				
			||||||
							],
 | 
					 | 
				
			||||||
							hiddenIfNoJs,
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					>
 | 
										>
 | 
				
			||||||
						<LinearGradient
 | 
											<LinearGradient
 | 
				
			||||||
							start={{ x: 0, y: 0.5 }}
 | 
												start={{ x: 0, y: 0.5 }}
 | 
				
			||||||
@ -137,6 +132,7 @@ export const Skeleton = ({
 | 
				
			|||||||
						/>
 | 
											/>
 | 
				
			||||||
					</View>
 | 
										</View>
 | 
				
			||||||
				))}
 | 
									))}
 | 
				
			||||||
 | 
								{children}
 | 
				
			||||||
		</View>
 | 
							</View>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -23,7 +23,7 @@ import { Alert, Avatar, Icon, IconButton, Menu, P, Skeleton, tooltip, ts } from
 | 
				
			|||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
import { View } from "react-native";
 | 
					import { View } from "react-native";
 | 
				
			||||||
import { px, useYoshiki } from "yoshiki/native";
 | 
					import { px, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import type { Layout, WithLoading } from "../fetch";
 | 
					import type { Layout } from "../fetch";
 | 
				
			||||||
import { InfiniteFetch } from "../fetch-infinite";
 | 
					import { InfiniteFetch } from "../fetch-infinite";
 | 
				
			||||||
import { SettingsContainer } from "../settings/base";
 | 
					import { SettingsContainer } from "../settings/base";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -36,20 +36,19 @@ import Verifed from "@material-symbols/svg-400/rounded/verified_user.svg";
 | 
				
			|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
 | 
					import { useMutation, useQueryClient } from "@tanstack/react-query";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const UserGrid = ({
 | 
					export const UserGrid = ({
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	id,
 | 
						id,
 | 
				
			||||||
	username,
 | 
						username,
 | 
				
			||||||
	avatar,
 | 
						avatar,
 | 
				
			||||||
	isAdmin,
 | 
						isAdmin,
 | 
				
			||||||
	isVerified,
 | 
						isVerified,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: WithLoading<{
 | 
					}: {
 | 
				
			||||||
	id: string;
 | 
						id: string;
 | 
				
			||||||
	username: string;
 | 
						username: string;
 | 
				
			||||||
	avatar: string;
 | 
						avatar: string;
 | 
				
			||||||
	isAdmin: boolean;
 | 
						isAdmin: boolean;
 | 
				
			||||||
	isVerified: boolean;
 | 
						isVerified: boolean;
 | 
				
			||||||
}>) => {
 | 
					}) => {
 | 
				
			||||||
	const { css } = useYoshiki();
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
	const { t } = useTranslation();
 | 
						const { t } = useTranslation();
 | 
				
			||||||
	const queryClient = useQueryClient();
 | 
						const queryClient = useQueryClient();
 | 
				
			||||||
@ -66,11 +65,10 @@ export const UserGrid = ({
 | 
				
			|||||||
	return (
 | 
						return (
 | 
				
			||||||
		<View {...css({ alignItems: "center" }, props)}>
 | 
							<View {...css({ alignItems: "center" }, props)}>
 | 
				
			||||||
			<Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill />
 | 
								<Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill />
 | 
				
			||||||
			<View {...css({ flexDirection: "row" })}>
 | 
								<View {...css({ flexDirection: "row", alignItems: "center" })}>
 | 
				
			||||||
				<Icon
 | 
									<Icon
 | 
				
			||||||
					icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI}
 | 
										icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI}
 | 
				
			||||||
					{...css({
 | 
										{...css({
 | 
				
			||||||
						alignSelf: "center",
 | 
					 | 
				
			||||||
						m: ts(1),
 | 
											m: ts(1),
 | 
				
			||||||
					})}
 | 
										})}
 | 
				
			||||||
					{...tooltip(
 | 
										{...tooltip(
 | 
				
			||||||
@ -83,9 +81,7 @@ export const UserGrid = ({
 | 
				
			|||||||
						),
 | 
											),
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				/>
 | 
									/>
 | 
				
			||||||
				<Skeleton>
 | 
									<P>{username}</P>
 | 
				
			||||||
					<P>{username}</P>
 | 
					 | 
				
			||||||
				</Skeleton>
 | 
					 | 
				
			||||||
				<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}>
 | 
									<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}>
 | 
				
			||||||
					{!isVerified && (
 | 
										{!isVerified && (
 | 
				
			||||||
						<Menu.Item
 | 
											<Menu.Item
 | 
				
			||||||
@ -159,6 +155,21 @@ export const UserGrid = ({
 | 
				
			|||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					UserGrid.Loader = (props: object) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View {...css({ alignItems: "center" }, props)}>
 | 
				
			||||||
 | 
								<Avatar.Loader size={UserGrid.layout.size} />
 | 
				
			||||||
 | 
								<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1, flexGrow: 1 })}>
 | 
				
			||||||
 | 
									<Icon icon={UserI} {...css({ m: ts(1) })} />
 | 
				
			||||||
 | 
									<Skeleton {...css({ flexGrow: 1, width: ts(8) })} />
 | 
				
			||||||
 | 
									<IconButton icon={MoreVert} disabled />
 | 
				
			||||||
 | 
								</View>
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
UserGrid.layout = {
 | 
					UserGrid.layout = {
 | 
				
			||||||
	size: px(150),
 | 
						size: px(150),
 | 
				
			||||||
	numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 },
 | 
						numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 },
 | 
				
			||||||
@ -171,18 +182,20 @@ export const UserList = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
		<SettingsContainer title={t("admin.users.label")}>
 | 
							<SettingsContainer title={t("admin.users.label")}>
 | 
				
			||||||
			<InfiniteFetch query={UserList.query()} layout={UserGrid.layout}>
 | 
								<InfiniteFetch
 | 
				
			||||||
				{(user) => (
 | 
									query={UserList.query()}
 | 
				
			||||||
 | 
									layout={UserGrid.layout}
 | 
				
			||||||
 | 
									Render={({ item }) => (
 | 
				
			||||||
					<UserGrid
 | 
										<UserGrid
 | 
				
			||||||
						isLoading={user.isLoading as any}
 | 
											id={item.id}
 | 
				
			||||||
						id={user.id}
 | 
											username={item.username}
 | 
				
			||||||
						username={user.username}
 | 
											avatar={item.logo}
 | 
				
			||||||
						avatar={user.logo}
 | 
											isAdmin={item.isAdmin}
 | 
				
			||||||
						isAdmin={user.isAdmin}
 | 
											isVerified={item.isVerified}
 | 
				
			||||||
						isVerified={user.isVerified}
 | 
					 | 
				
			||||||
					/>
 | 
										/>
 | 
				
			||||||
				)}
 | 
									)}
 | 
				
			||||||
			</InfiniteFetch>
 | 
									Loader={UserGrid.Loader}
 | 
				
			||||||
 | 
								/>
 | 
				
			||||||
		</SettingsContainer>
 | 
							</SettingsContainer>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -23,6 +23,7 @@ import {
 | 
				
			|||||||
	Icon,
 | 
						Icon,
 | 
				
			||||||
	Link,
 | 
						Link,
 | 
				
			||||||
	P,
 | 
						P,
 | 
				
			||||||
 | 
						Poster,
 | 
				
			||||||
	PosterBackground,
 | 
						PosterBackground,
 | 
				
			||||||
	Skeleton,
 | 
						Skeleton,
 | 
				
			||||||
	SubP,
 | 
						SubP,
 | 
				
			||||||
@ -35,7 +36,7 @@ import { useState } from "react";
 | 
				
			|||||||
import { type ImageStyle, Platform, View } from "react-native";
 | 
					import { type ImageStyle, Platform, View } from "react-native";
 | 
				
			||||||
import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { ItemContext } from "../components/context-menus";
 | 
					import { ItemContext } from "../components/context-menus";
 | 
				
			||||||
import type { Layout, WithLoading } from "../fetch";
 | 
					import type { Layout } from "../fetch";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ItemWatchStatus = ({
 | 
					export const ItemWatchStatus = ({
 | 
				
			||||||
	watchStatus,
 | 
						watchStatus,
 | 
				
			||||||
@ -113,23 +114,21 @@ export const ItemGrid = ({
 | 
				
			|||||||
	type,
 | 
						type,
 | 
				
			||||||
	subtitle,
 | 
						subtitle,
 | 
				
			||||||
	poster,
 | 
						poster,
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	watchStatus,
 | 
						watchStatus,
 | 
				
			||||||
	watchPercent,
 | 
						watchPercent,
 | 
				
			||||||
	unseenEpisodesCount,
 | 
						unseenEpisodesCount,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: WithLoading<{
 | 
					}: {
 | 
				
			||||||
	href: string;
 | 
						href: string;
 | 
				
			||||||
	slug: string;
 | 
						slug: string;
 | 
				
			||||||
	name: string;
 | 
						name: string;
 | 
				
			||||||
	subtitle?: string;
 | 
						subtitle: string | null;
 | 
				
			||||||
	poster?: KyooImage | null;
 | 
						poster: KyooImage | null;
 | 
				
			||||||
	watchStatus: WatchStatusV | null;
 | 
						watchStatus: WatchStatusV | null;
 | 
				
			||||||
	watchPercent: number | null;
 | 
						watchPercent: number | null;
 | 
				
			||||||
	type: "movie" | "show" | "collection";
 | 
						type: "movie" | "show" | "collection";
 | 
				
			||||||
	unseenEpisodesCount: number | null;
 | 
						unseenEpisodesCount: number | null;
 | 
				
			||||||
}> &
 | 
					} & Stylable<"text">) => {
 | 
				
			||||||
	Stylable<"text">) => {
 | 
					 | 
				
			||||||
	const [moreOpened, setMoreOpened] = useState(false);
 | 
						const [moreOpened, setMoreOpened] = useState(false);
 | 
				
			||||||
	const { css } = useYoshiki("grid");
 | 
						const { css } = useYoshiki("grid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -172,13 +171,12 @@ export const ItemGrid = ({
 | 
				
			|||||||
				src={poster}
 | 
									src={poster}
 | 
				
			||||||
				alt={name}
 | 
									alt={name}
 | 
				
			||||||
				quality="low"
 | 
									quality="low"
 | 
				
			||||||
				forcedLoading={isLoading}
 | 
					 | 
				
			||||||
				layout={{ width: percent(100) }}
 | 
									layout={{ width: percent(100) }}
 | 
				
			||||||
				{...(css("poster") as { style: ImageStyle })}
 | 
									{...(css("poster") as { style: ImageStyle })}
 | 
				
			||||||
			>
 | 
								>
 | 
				
			||||||
				<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
									<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
				
			||||||
				{type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />}
 | 
									{type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />}
 | 
				
			||||||
				{slug && watchStatus !== undefined && type && type !== "collection" && (
 | 
									{type !== "collection" && (
 | 
				
			||||||
					<ItemContext
 | 
										<ItemContext
 | 
				
			||||||
						type={type}
 | 
											type={type}
 | 
				
			||||||
						slug={slug}
 | 
											slug={slug}
 | 
				
			||||||
@ -198,34 +196,44 @@ export const ItemGrid = ({
 | 
				
			|||||||
					/>
 | 
										/>
 | 
				
			||||||
				)}
 | 
									)}
 | 
				
			||||||
			</PosterBackground>
 | 
								</PosterBackground>
 | 
				
			||||||
			<Skeleton>
 | 
								<P numberOfLines={subtitle ? 1 : 2} {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
 | 
				
			||||||
				{isLoading || (
 | 
									{name}
 | 
				
			||||||
					<P
 | 
								</P>
 | 
				
			||||||
						numberOfLines={subtitle ? 1 : 2}
 | 
								{subtitle && (
 | 
				
			||||||
						{...css([{ marginY: 0, textAlign: "center" }, "title"])}
 | 
									<SubP
 | 
				
			||||||
					>
 | 
										{...css({
 | 
				
			||||||
						{name}
 | 
											marginTop: 0,
 | 
				
			||||||
					</P>
 | 
											textAlign: "center",
 | 
				
			||||||
				)}
 | 
										})}
 | 
				
			||||||
			</Skeleton>
 | 
									>
 | 
				
			||||||
			{(isLoading || subtitle) && (
 | 
										{subtitle}
 | 
				
			||||||
				<Skeleton {...css({ width: percent(50) })}>
 | 
									</SubP>
 | 
				
			||||||
					{isLoading || (
 | 
					 | 
				
			||||||
						<SubP
 | 
					 | 
				
			||||||
							{...css({
 | 
					 | 
				
			||||||
								marginTop: 0,
 | 
					 | 
				
			||||||
								textAlign: "center",
 | 
					 | 
				
			||||||
							})}
 | 
					 | 
				
			||||||
						>
 | 
					 | 
				
			||||||
							{subtitle}
 | 
					 | 
				
			||||||
						</SubP>
 | 
					 | 
				
			||||||
					)}
 | 
					 | 
				
			||||||
				</Skeleton>
 | 
					 | 
				
			||||||
			)}
 | 
								)}
 | 
				
			||||||
		</Link>
 | 
							</Link>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ItemGrid.Loader = (props: object) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										flexDirection: "column",
 | 
				
			||||||
 | 
										alignItems: "center",
 | 
				
			||||||
 | 
										width: percent(100),
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<Poster.Loader layout={{ width: percent(100) }} />
 | 
				
			||||||
 | 
								<Skeleton />
 | 
				
			||||||
 | 
								<Skeleton {...css({ width: percent(50) })} />
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ItemGrid.layout = {
 | 
					ItemGrid.layout = {
 | 
				
			||||||
	size: px(150),
 | 
						size: px(150),
 | 
				
			||||||
	numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 },
 | 
						numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 },
 | 
				
			||||||
 | 
				
			|||||||
@ -27,7 +27,6 @@ import {
 | 
				
			|||||||
} from "@kyoo/models";
 | 
					} from "@kyoo/models";
 | 
				
			||||||
import { type ComponentProps, useState } from "react";
 | 
					import { type ComponentProps, useState } from "react";
 | 
				
			||||||
import { createParam } from "solito";
 | 
					import { createParam } from "solito";
 | 
				
			||||||
import type { WithLoading } from "../fetch";
 | 
					 | 
				
			||||||
import { InfiniteFetch } from "../fetch-infinite";
 | 
					import { InfiniteFetch } from "../fetch-infinite";
 | 
				
			||||||
import { DefaultLayout } from "../layout";
 | 
					import { DefaultLayout } from "../layout";
 | 
				
			||||||
import { ItemGrid } from "./grid";
 | 
					import { ItemGrid } from "./grid";
 | 
				
			||||||
@ -38,25 +37,20 @@ import { Layout, SortBy, SortOrd } from "./types";
 | 
				
			|||||||
const { useParam } = createParam<{ sortBy?: string }>();
 | 
					const { useParam } = createParam<{ sortBy?: string }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const itemMap = (
 | 
					export const itemMap = (
 | 
				
			||||||
	item: WithLoading<LibraryItem>,
 | 
						item: LibraryItem,
 | 
				
			||||||
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => {
 | 
					): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
 | 
				
			||||||
	if (item.isLoading) return item as any;
 | 
						slug: item.slug,
 | 
				
			||||||
 | 
						name: item.name,
 | 
				
			||||||
	return {
 | 
						subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
 | 
				
			||||||
		isLoading: item.isLoading,
 | 
						href: item.href,
 | 
				
			||||||
		slug: item.slug,
 | 
						poster: item.poster,
 | 
				
			||||||
		name: item.name,
 | 
						thumbnail: item.thumbnail,
 | 
				
			||||||
		subtitle: item.kind !== "collection" ? getDisplayDate(item) : undefined,
 | 
						watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
 | 
				
			||||||
		href: item.href,
 | 
						type: item.kind,
 | 
				
			||||||
		poster: item.poster,
 | 
						watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
 | 
				
			||||||
		thumbnail: item.thumbnail,
 | 
						unseenEpisodesCount:
 | 
				
			||||||
		watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
 | 
							item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
 | 
				
			||||||
		type: item.kind,
 | 
					});
 | 
				
			||||||
		watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
 | 
					 | 
				
			||||||
		unseenEpisodesCount:
 | 
					 | 
				
			||||||
			item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
 | 
					 | 
				
			||||||
	};
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({
 | 
					const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({
 | 
				
			||||||
	parser: LibraryItemP,
 | 
						parser: LibraryItemP,
 | 
				
			||||||
@ -92,9 +86,9 @@ export const BrowsePage: QueryPage = () => {
 | 
				
			|||||||
					setLayout={setLayout}
 | 
										setLayout={setLayout}
 | 
				
			||||||
				/>
 | 
									/>
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		>
 | 
								Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
 | 
				
			||||||
			{(item) => <LayoutComponent {...itemMap(item)} />}
 | 
								Loader={LayoutComponent.Loader}
 | 
				
			||||||
		</InfiniteFetch>
 | 
							/>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -24,6 +24,7 @@ import {
 | 
				
			|||||||
	ImageBackground,
 | 
						ImageBackground,
 | 
				
			||||||
	Link,
 | 
						Link,
 | 
				
			||||||
	P,
 | 
						P,
 | 
				
			||||||
 | 
						Poster,
 | 
				
			||||||
	PosterBackground,
 | 
						PosterBackground,
 | 
				
			||||||
	Skeleton,
 | 
						Skeleton,
 | 
				
			||||||
	imageBorderRadius,
 | 
						imageBorderRadius,
 | 
				
			||||||
@ -32,9 +33,10 @@ import {
 | 
				
			|||||||
} from "@kyoo/primitives";
 | 
					} from "@kyoo/primitives";
 | 
				
			||||||
import { useState } from "react";
 | 
					import { useState } from "react";
 | 
				
			||||||
import { Platform, View } from "react-native";
 | 
					import { Platform, View } from "react-native";
 | 
				
			||||||
 | 
					import type { Stylable } from "yoshiki";
 | 
				
			||||||
import { percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { ItemContext } from "../components/context-menus";
 | 
					import { ItemContext } from "../components/context-menus";
 | 
				
			||||||
import type { Layout, WithLoading } from "../fetch";
 | 
					import type { Layout } from "../fetch";
 | 
				
			||||||
import { ItemWatchStatus } from "./grid";
 | 
					import { ItemWatchStatus } from "./grid";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ItemList = ({
 | 
					export const ItemList = ({
 | 
				
			||||||
@ -45,22 +47,21 @@ export const ItemList = ({
 | 
				
			|||||||
	subtitle,
 | 
						subtitle,
 | 
				
			||||||
	thumbnail,
 | 
						thumbnail,
 | 
				
			||||||
	poster,
 | 
						poster,
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	watchStatus,
 | 
						watchStatus,
 | 
				
			||||||
	unseenEpisodesCount,
 | 
						unseenEpisodesCount,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: WithLoading<{
 | 
					}: {
 | 
				
			||||||
	href: string;
 | 
						href: string;
 | 
				
			||||||
	slug: string;
 | 
						slug: string;
 | 
				
			||||||
	type: "movie" | "show" | "collection";
 | 
						type: "movie" | "show" | "collection";
 | 
				
			||||||
	name: string;
 | 
						name: string;
 | 
				
			||||||
	subtitle?: string;
 | 
						subtitle: string | null;
 | 
				
			||||||
	poster?: KyooImage | null;
 | 
						poster: KyooImage | null;
 | 
				
			||||||
	thumbnail?: KyooImage | null;
 | 
						thumbnail: KyooImage | null;
 | 
				
			||||||
	watchStatus: WatchStatusV | null;
 | 
						watchStatus: WatchStatusV | null;
 | 
				
			||||||
	unseenEpisodesCount: number | null;
 | 
						unseenEpisodesCount: number | null;
 | 
				
			||||||
}>) => {
 | 
					}) => {
 | 
				
			||||||
	const { css } = useYoshiki();
 | 
						const { css } = useYoshiki("line");
 | 
				
			||||||
	const [moreOpened, setMoreOpened] = useState(false);
 | 
						const [moreOpened, setMoreOpened] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return (
 | 
						return (
 | 
				
			||||||
@ -114,25 +115,21 @@ export const ItemList = ({
 | 
				
			|||||||
						justifyContent: "center",
 | 
											justifyContent: "center",
 | 
				
			||||||
					})}
 | 
										})}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
					<Skeleton {...css({ height: rem(2), alignSelf: "center" })}>
 | 
										<Heading
 | 
				
			||||||
						{isLoading || (
 | 
											{...css([
 | 
				
			||||||
							<Heading
 | 
												"title",
 | 
				
			||||||
								{...css([
 | 
												{
 | 
				
			||||||
									"title",
 | 
													textAlign: "center",
 | 
				
			||||||
									{
 | 
													fontSize: rem(2),
 | 
				
			||||||
										textAlign: "center",
 | 
													letterSpacing: rem(0.002),
 | 
				
			||||||
										fontSize: rem(2),
 | 
													fontWeight: "900",
 | 
				
			||||||
										letterSpacing: rem(0.002),
 | 
													textTransform: "uppercase",
 | 
				
			||||||
										fontWeight: "900",
 | 
												},
 | 
				
			||||||
										textTransform: "uppercase",
 | 
											])}
 | 
				
			||||||
									},
 | 
										>
 | 
				
			||||||
								])}
 | 
											{name}
 | 
				
			||||||
							>
 | 
										</Heading>
 | 
				
			||||||
								{name}
 | 
										{type !== "collection" && (
 | 
				
			||||||
							</Heading>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</Skeleton>
 | 
					 | 
				
			||||||
					{slug && watchStatus !== undefined && type && type !== "collection" && (
 | 
					 | 
				
			||||||
						<ItemContext
 | 
											<ItemContext
 | 
				
			||||||
							type={type}
 | 
												type={type}
 | 
				
			||||||
							slug={slug}
 | 
												slug={slug}
 | 
				
			||||||
@ -151,32 +148,56 @@ export const ItemList = ({
 | 
				
			|||||||
						/>
 | 
											/>
 | 
				
			||||||
					)}
 | 
										)}
 | 
				
			||||||
				</View>
 | 
									</View>
 | 
				
			||||||
				{(isLoading || subtitle) && (
 | 
									{subtitle && (
 | 
				
			||||||
					<Skeleton {...css({ width: rem(5), alignSelf: "center" })}>
 | 
										<P
 | 
				
			||||||
						{isLoading || (
 | 
											{...css({
 | 
				
			||||||
							<P
 | 
												textAlign: "center",
 | 
				
			||||||
								{...css({
 | 
												marginRight: ts(4),
 | 
				
			||||||
									textAlign: "center",
 | 
											})}
 | 
				
			||||||
									marginRight: ts(4),
 | 
										>
 | 
				
			||||||
								})}
 | 
											{subtitle}
 | 
				
			||||||
							>
 | 
										</P>
 | 
				
			||||||
								{subtitle}
 | 
					 | 
				
			||||||
							</P>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</Skeleton>
 | 
					 | 
				
			||||||
				)}
 | 
									)}
 | 
				
			||||||
			</View>
 | 
								</View>
 | 
				
			||||||
			<PosterBackground
 | 
								<PosterBackground src={poster} alt="" quality="low" layout={{ height: percent(80) }}>
 | 
				
			||||||
				src={poster}
 | 
					 | 
				
			||||||
				alt=""
 | 
					 | 
				
			||||||
				quality="low"
 | 
					 | 
				
			||||||
				forcedLoading={isLoading}
 | 
					 | 
				
			||||||
				layout={{ height: percent(80) }}
 | 
					 | 
				
			||||||
			>
 | 
					 | 
				
			||||||
				<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
									<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
				
			||||||
			</PosterBackground>
 | 
								</PosterBackground>
 | 
				
			||||||
		</ImageBackground>
 | 
							</ImageBackground>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ItemList.Loader = (props: object) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										alignItems: "center",
 | 
				
			||||||
 | 
										justifyContent: "space-evenly",
 | 
				
			||||||
 | 
										flexDirection: "row",
 | 
				
			||||||
 | 
										height: ItemList.layout.size,
 | 
				
			||||||
 | 
										borderRadius: px(imageBorderRadius),
 | 
				
			||||||
 | 
										overflow: "hidden",
 | 
				
			||||||
 | 
										bg: (theme) => theme.dark.background,
 | 
				
			||||||
 | 
										marginX: ItemList.layout.gap,
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<View
 | 
				
			||||||
 | 
									{...css({
 | 
				
			||||||
 | 
										width: { xs: "50%", lg: "30%" },
 | 
				
			||||||
 | 
										flexDirection: "column",
 | 
				
			||||||
 | 
										justifyContent: "center",
 | 
				
			||||||
 | 
									})}
 | 
				
			||||||
 | 
								>
 | 
				
			||||||
 | 
									<Skeleton {...css({ height: rem(2), alignSelf: "center" })} />
 | 
				
			||||||
 | 
									<Skeleton {...css({ width: rem(5), alignSelf: "center" })} />
 | 
				
			||||||
 | 
								</View>
 | 
				
			||||||
 | 
								<Poster.Loader layout={{ height: percent(80) }} />
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;
 | 
					ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;
 | 
				
			||||||
 | 
				
			|||||||
@ -155,30 +155,29 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
 | 
				
			|||||||
			Header={CollectionHeader}
 | 
								Header={CollectionHeader}
 | 
				
			||||||
			headerProps={{ slug }}
 | 
								headerProps={{ slug }}
 | 
				
			||||||
			contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }}
 | 
								contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }}
 | 
				
			||||||
		>
 | 
								Render={({ item }) => (
 | 
				
			||||||
			{(x) => (
 | 
					 | 
				
			||||||
				<ItemDetails
 | 
									<ItemDetails
 | 
				
			||||||
					isLoading={x.isLoading as any}
 | 
										slug={item.slug}
 | 
				
			||||||
					slug={x.slug}
 | 
										type={item.kind}
 | 
				
			||||||
					type={x.kind}
 | 
										name={item.name}
 | 
				
			||||||
					name={x.name}
 | 
										tagline={"tagline" in item ? item.tagline : null}
 | 
				
			||||||
					tagline={"tagline" in x ? x.tagline : null}
 | 
										overview={item.overview}
 | 
				
			||||||
					overview={x.overview}
 | 
										poster={item.poster}
 | 
				
			||||||
					poster={x.poster}
 | 
										subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
 | 
				
			||||||
					subtitle={x.kind !== "collection" && !x.isLoading ? getDisplayDate(x) : undefined}
 | 
										genres={"genres" in item ? item.genres : null}
 | 
				
			||||||
					genres={"genres" in x ? x.genres : null}
 | 
										href={item.href}
 | 
				
			||||||
					href={x.href}
 | 
										playHref={item.kind !== "collection" ? item.playHref : null}
 | 
				
			||||||
					playHref={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
 | 
										watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
 | 
				
			||||||
					watchStatus={
 | 
					 | 
				
			||||||
						!x.isLoading && x.kind !== "collection" ? x.watchStatus?.status ?? null : null
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
					unseenEpisodesCount={
 | 
										unseenEpisodesCount={
 | 
				
			||||||
						x.kind === "show" ? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount! : null
 | 
											item.kind === "show"
 | 
				
			||||||
 | 
												? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
 | 
				
			||||||
 | 
												: null
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
					{...css({ marginX: ItemGrid.layout.gap })}
 | 
										{...css({ marginX: ItemGrid.layout.gap })}
 | 
				
			||||||
				/>
 | 
									/>
 | 
				
			||||||
			)}
 | 
								)}
 | 
				
			||||||
		</InfiniteFetch>
 | 
								Loader={ItemDetails.Loader}
 | 
				
			||||||
 | 
							/>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -22,6 +22,7 @@ import { type KyooImage, WatchStatusV } from "@kyoo/models";
 | 
				
			|||||||
import {
 | 
					import {
 | 
				
			||||||
	H6,
 | 
						H6,
 | 
				
			||||||
	IconButton,
 | 
						IconButton,
 | 
				
			||||||
 | 
						Image,
 | 
				
			||||||
	ImageBackground,
 | 
						ImageBackground,
 | 
				
			||||||
	type ImageProps,
 | 
						type ImageProps,
 | 
				
			||||||
	Link,
 | 
						Link,
 | 
				
			||||||
@ -41,20 +42,17 @@ import { type ImageStyle, Platform, type PressableProps, View } from "react-nati
 | 
				
			|||||||
import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native";
 | 
					import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { ItemProgress } from "../browse/grid";
 | 
					import { ItemProgress } from "../browse/grid";
 | 
				
			||||||
import { EpisodesContext } from "../components/context-menus";
 | 
					import { EpisodesContext } from "../components/context-menus";
 | 
				
			||||||
import type { Layout, WithLoading } from "../fetch";
 | 
					import type { Layout } from "../fetch";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const episodeDisplayNumber = (
 | 
					export const episodeDisplayNumber = (episode: {
 | 
				
			||||||
	episode: {
 | 
						seasonNumber?: number | null;
 | 
				
			||||||
		seasonNumber?: number | null;
 | 
						episodeNumber?: number | null;
 | 
				
			||||||
		episodeNumber?: number | null;
 | 
						absoluteNumber?: number | null;
 | 
				
			||||||
		absoluteNumber?: number | null;
 | 
					}) => {
 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
	def?: string,
 | 
					 | 
				
			||||||
) => {
 | 
					 | 
				
			||||||
	if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
 | 
						if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
 | 
				
			||||||
		return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
 | 
							return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
 | 
				
			||||||
	if (episode.absoluteNumber) return episode.absoluteNumber.toString();
 | 
						if (episode.absoluteNumber) return episode.absoluteNumber.toString();
 | 
				
			||||||
	return def;
 | 
						return "??";
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const displayRuntime = (runtime: number | null) => {
 | 
					export const displayRuntime = (runtime: number | null) => {
 | 
				
			||||||
@ -69,23 +67,21 @@ export const EpisodeBox = ({
 | 
				
			|||||||
	name,
 | 
						name,
 | 
				
			||||||
	overview,
 | 
						overview,
 | 
				
			||||||
	thumbnail,
 | 
						thumbnail,
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	href,
 | 
						href,
 | 
				
			||||||
	watchedPercent,
 | 
						watchedPercent,
 | 
				
			||||||
	watchedStatus,
 | 
						watchedStatus,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: Stylable &
 | 
					}: Stylable & {
 | 
				
			||||||
	WithLoading<{
 | 
						slug: string;
 | 
				
			||||||
		slug: string;
 | 
						// if show slug is null, disable "Go to show" in the context menu
 | 
				
			||||||
		// if show slug is null, disable "Go to show" in the context menu
 | 
						showSlug: string | null;
 | 
				
			||||||
		showSlug: string | null;
 | 
						name: string | null;
 | 
				
			||||||
		name: string | null;
 | 
						overview: string | null;
 | 
				
			||||||
		overview: string | null;
 | 
						href: string;
 | 
				
			||||||
		href: string;
 | 
						thumbnail?: ImageProps["src"] | null;
 | 
				
			||||||
		thumbnail?: ImageProps["src"] | null;
 | 
						watchedPercent: number | null;
 | 
				
			||||||
		watchedPercent: number | null;
 | 
						watchedStatus: WatchStatusV | null;
 | 
				
			||||||
		watchedStatus: WatchStatusV | null;
 | 
					}) => {
 | 
				
			||||||
	}>) => {
 | 
					 | 
				
			||||||
	const [moreOpened, setMoreOpened] = useState(false);
 | 
						const [moreOpened, setMoreOpened] = useState(false);
 | 
				
			||||||
	const { css } = useYoshiki("episodebox");
 | 
						const { css } = useYoshiki("episodebox");
 | 
				
			||||||
	const { t } = useTranslation();
 | 
						const { t } = useTranslation();
 | 
				
			||||||
@ -128,58 +124,65 @@ export const EpisodeBox = ({
 | 
				
			|||||||
				quality="low"
 | 
									quality="low"
 | 
				
			||||||
				alt=""
 | 
									alt=""
 | 
				
			||||||
				gradient={false}
 | 
									gradient={false}
 | 
				
			||||||
				hideLoad={false}
 | 
					 | 
				
			||||||
				forcedLoading={isLoading}
 | 
					 | 
				
			||||||
				layout={{ width: percent(100), aspectRatio: 16 / 9 }}
 | 
									layout={{ width: percent(100), aspectRatio: 16 / 9 }}
 | 
				
			||||||
				{...(css("poster") as any)}
 | 
									{...(css("poster") as any)}
 | 
				
			||||||
			>
 | 
								>
 | 
				
			||||||
				{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
 | 
									{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
 | 
				
			||||||
					<ItemProgress watchPercent={watchedPercent ?? 100} />
 | 
										<ItemProgress watchPercent={watchedPercent ?? 100} />
 | 
				
			||||||
				)}
 | 
									)}
 | 
				
			||||||
				{slug && watchedStatus !== undefined && (
 | 
									<EpisodesContext
 | 
				
			||||||
					<EpisodesContext
 | 
										slug={slug}
 | 
				
			||||||
						slug={slug}
 | 
										showSlug={showSlug}
 | 
				
			||||||
						showSlug={showSlug}
 | 
										status={watchedStatus}
 | 
				
			||||||
						status={watchedStatus}
 | 
										isOpen={moreOpened}
 | 
				
			||||||
						isOpen={moreOpened}
 | 
										setOpen={(v) => setMoreOpened(v)}
 | 
				
			||||||
						setOpen={(v) => setMoreOpened(v)}
 | 
										{...css([
 | 
				
			||||||
						{...css([
 | 
											{
 | 
				
			||||||
							{
 | 
												position: "absolute",
 | 
				
			||||||
								position: "absolute",
 | 
												top: 0,
 | 
				
			||||||
								top: 0,
 | 
												right: 0,
 | 
				
			||||||
								right: 0,
 | 
												bg: (theme) => theme.darkOverlay,
 | 
				
			||||||
								bg: (theme) => theme.darkOverlay,
 | 
											},
 | 
				
			||||||
							},
 | 
											"more",
 | 
				
			||||||
							"more",
 | 
											Platform.OS === "web" && moreOpened && { display: important("flex") },
 | 
				
			||||||
							Platform.OS === "web" && moreOpened && { display: important("flex") },
 | 
										])}
 | 
				
			||||||
						])}
 | 
									/>
 | 
				
			||||||
					/>
 | 
					 | 
				
			||||||
				)}
 | 
					 | 
				
			||||||
			</ImageBackground>
 | 
								</ImageBackground>
 | 
				
			||||||
			<Skeleton {...css({ width: percent(50) })}>
 | 
								<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
 | 
				
			||||||
				{isLoading || (
 | 
									{name ?? t("show.episodeNoMetadata")}
 | 
				
			||||||
					<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
 | 
								</P>
 | 
				
			||||||
						{name ?? t("show.episodeNoMetadata")}
 | 
								<SubP
 | 
				
			||||||
					</P>
 | 
									numberOfLines={3}
 | 
				
			||||||
				)}
 | 
									{...css({
 | 
				
			||||||
			</Skeleton>
 | 
										marginTop: 0,
 | 
				
			||||||
			<Skeleton {...css({ width: percent(75), height: rem(0.8) })}>
 | 
										textAlign: "center",
 | 
				
			||||||
				{isLoading || (
 | 
									})}
 | 
				
			||||||
					<SubP
 | 
								>
 | 
				
			||||||
						numberOfLines={3}
 | 
									{overview}
 | 
				
			||||||
						{...css({
 | 
								</SubP>
 | 
				
			||||||
							marginTop: 0,
 | 
					 | 
				
			||||||
							textAlign: "center",
 | 
					 | 
				
			||||||
						})}
 | 
					 | 
				
			||||||
					>
 | 
					 | 
				
			||||||
						{overview}
 | 
					 | 
				
			||||||
					</SubP>
 | 
					 | 
				
			||||||
				)}
 | 
					 | 
				
			||||||
			</Skeleton>
 | 
					 | 
				
			||||||
		</Link>
 | 
							</Link>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					EpisodeBox.Loader = (props: Stylable) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										alignItems: "center",
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<Image.Loader layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
 | 
				
			||||||
 | 
								<Skeleton {...css({ width: percent(50) })} />
 | 
				
			||||||
 | 
								<Skeleton {...css({ width: percent(75), height: rem(0.8) })} />
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const EpisodeLine = ({
 | 
					export const EpisodeLine = ({
 | 
				
			||||||
	slug,
 | 
						slug,
 | 
				
			||||||
	showSlug,
 | 
						showSlug,
 | 
				
			||||||
@ -187,7 +190,6 @@ export const EpisodeLine = ({
 | 
				
			|||||||
	name,
 | 
						name,
 | 
				
			||||||
	thumbnail,
 | 
						thumbnail,
 | 
				
			||||||
	overview,
 | 
						overview,
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	id,
 | 
						id,
 | 
				
			||||||
	absoluteNumber,
 | 
						absoluteNumber,
 | 
				
			||||||
	episodeNumber,
 | 
						episodeNumber,
 | 
				
			||||||
@ -198,7 +200,7 @@ export const EpisodeLine = ({
 | 
				
			|||||||
	watchedStatus,
 | 
						watchedStatus,
 | 
				
			||||||
	href,
 | 
						href,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: WithLoading<{
 | 
					}: {
 | 
				
			||||||
	id: string;
 | 
						id: string;
 | 
				
			||||||
	slug: string;
 | 
						slug: string;
 | 
				
			||||||
	// if show slug is null, disable "Go to show" in the context menu
 | 
						// if show slug is null, disable "Go to show" in the context menu
 | 
				
			||||||
@ -215,8 +217,7 @@ export const EpisodeLine = ({
 | 
				
			|||||||
	watchedPercent: number | null;
 | 
						watchedPercent: number | null;
 | 
				
			||||||
	watchedStatus: WatchStatusV | null;
 | 
						watchedStatus: WatchStatusV | null;
 | 
				
			||||||
	href: string;
 | 
						href: string;
 | 
				
			||||||
}> &
 | 
					} & PressableProps &
 | 
				
			||||||
	PressableProps &
 | 
					 | 
				
			||||||
	Stylable) => {
 | 
						Stylable) => {
 | 
				
			||||||
	const [moreOpened, setMoreOpened] = useState(false);
 | 
						const [moreOpened, setMoreOpened] = useState(false);
 | 
				
			||||||
	const [descriptionExpanded, setDescriptionExpanded] = useState(false);
 | 
						const [descriptionExpanded, setDescriptionExpanded] = useState(false);
 | 
				
			||||||
@ -254,7 +255,6 @@ export const EpisodeLine = ({
 | 
				
			|||||||
				quality="low"
 | 
									quality="low"
 | 
				
			||||||
				alt=""
 | 
									alt=""
 | 
				
			||||||
				gradient={false}
 | 
									gradient={false}
 | 
				
			||||||
				hideLoad={false}
 | 
					 | 
				
			||||||
				layout={{
 | 
									layout={{
 | 
				
			||||||
					width: percent(18),
 | 
										width: percent(18),
 | 
				
			||||||
					aspectRatio: 16 / 9,
 | 
										aspectRatio: 16 / 9,
 | 
				
			||||||
@ -293,48 +293,36 @@ export const EpisodeLine = ({
 | 
				
			|||||||
						justifyContent: "space-between",
 | 
											justifyContent: "space-between",
 | 
				
			||||||
					})}
 | 
										})}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
					<Skeleton>
 | 
										{/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
 | 
				
			||||||
						{isLoading || (
 | 
										<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
 | 
				
			||||||
							// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
 | 
											{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
 | 
				
			||||||
							<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
 | 
										</H6>
 | 
				
			||||||
								{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
 | 
					 | 
				
			||||||
							</H6>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</Skeleton>
 | 
					 | 
				
			||||||
					<View {...css({ flexDirection: "row", alignItems: "center" })}>
 | 
										<View {...css({ flexDirection: "row", alignItems: "center" })}>
 | 
				
			||||||
						<Skeleton>
 | 
											<SubP>
 | 
				
			||||||
							{isLoading || (
 | 
												{[
 | 
				
			||||||
								<SubP>
 | 
													// @ts-ignore Source https://www.i18next.com/translation-function/formatting#datetime
 | 
				
			||||||
									{/* Source https://www.i18next.com/translation-function/formatting#datetime */}
 | 
													releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
 | 
				
			||||||
									{[
 | 
													displayRuntime(runtime),
 | 
				
			||||||
										releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
 | 
												]
 | 
				
			||||||
										displayRuntime(runtime),
 | 
													.filter((item) => item != null)
 | 
				
			||||||
									]
 | 
													.join(" · ")}
 | 
				
			||||||
										.filter((item) => item != null)
 | 
											</SubP>
 | 
				
			||||||
										.join(" · ")}
 | 
											<EpisodesContext
 | 
				
			||||||
								</SubP>
 | 
												slug={slug}
 | 
				
			||||||
							)}
 | 
												showSlug={showSlug}
 | 
				
			||||||
						</Skeleton>
 | 
												status={watchedStatus}
 | 
				
			||||||
						{slug && watchedStatus !== undefined && (
 | 
												isOpen={moreOpened}
 | 
				
			||||||
							<EpisodesContext
 | 
												setOpen={(v) => setMoreOpened(v)}
 | 
				
			||||||
								slug={slug}
 | 
												{...css([
 | 
				
			||||||
								showSlug={showSlug}
 | 
													"more",
 | 
				
			||||||
								status={watchedStatus}
 | 
													{ display: "flex", marginLeft: ts(3) },
 | 
				
			||||||
								isOpen={moreOpened}
 | 
													Platform.OS === "web" && moreOpened && { display: important("flex") },
 | 
				
			||||||
								setOpen={(v) => setMoreOpened(v)}
 | 
												])}
 | 
				
			||||||
								{...css([
 | 
											/>
 | 
				
			||||||
									"more",
 | 
					 | 
				
			||||||
									{ display: "flex", marginLeft: ts(3) },
 | 
					 | 
				
			||||||
									Platform.OS === "web" && moreOpened && { display: important("flex") },
 | 
					 | 
				
			||||||
								])}
 | 
					 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</View>
 | 
										</View>
 | 
				
			||||||
				</View>
 | 
									</View>
 | 
				
			||||||
				<View {...css({ flexDirection: "row" })}>
 | 
									<View {...css({ flexDirection: "row", justifyContent: "space-between" })}>
 | 
				
			||||||
					<Skeleton>
 | 
										<P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>
 | 
				
			||||||
						{isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>}
 | 
					 | 
				
			||||||
					</Skeleton>
 | 
					 | 
				
			||||||
					<IconButton
 | 
										<IconButton
 | 
				
			||||||
						{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
 | 
											{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
 | 
				
			||||||
						icon={descriptionExpanded ? ExpandLess : ExpandMore}
 | 
											icon={descriptionExpanded ? ExpandLess : ExpandMore}
 | 
				
			||||||
@ -349,6 +337,45 @@ export const EpisodeLine = ({
 | 
				
			|||||||
		</Link>
 | 
							</Link>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					EpisodeLine.Loader = (props: Stylable) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										alignItems: "center",
 | 
				
			||||||
 | 
										flexDirection: "row",
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<Image.Loader
 | 
				
			||||||
 | 
									layout={{
 | 
				
			||||||
 | 
										width: percent(18),
 | 
				
			||||||
 | 
										aspectRatio: 16 / 9,
 | 
				
			||||||
 | 
									}}
 | 
				
			||||||
 | 
									{...css({ flexShrink: 0, m: ts(1) })}
 | 
				
			||||||
 | 
								/>
 | 
				
			||||||
 | 
								<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
 | 
				
			||||||
 | 
									<View
 | 
				
			||||||
 | 
										{...css({
 | 
				
			||||||
 | 
											flexGrow: 1,
 | 
				
			||||||
 | 
											flexShrink: 1,
 | 
				
			||||||
 | 
											flexDirection: "row",
 | 
				
			||||||
 | 
											justifyContent: "space-between",
 | 
				
			||||||
 | 
										})}
 | 
				
			||||||
 | 
									>
 | 
				
			||||||
 | 
										<Skeleton {...css({ width: percent(30) })} />
 | 
				
			||||||
 | 
										<Skeleton {...css({ width: percent(15) })} />
 | 
				
			||||||
 | 
									</View>
 | 
				
			||||||
 | 
									<Skeleton />
 | 
				
			||||||
 | 
								</View>
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EpisodeLine.layout = {
 | 
					EpisodeLine.layout = {
 | 
				
			||||||
	numColumns: 1,
 | 
						numColumns: 1,
 | 
				
			||||||
	size: 100,
 | 
						size: 100,
 | 
				
			||||||
 | 
				
			|||||||
@ -26,7 +26,7 @@ import {
 | 
				
			|||||||
	SeasonP,
 | 
						SeasonP,
 | 
				
			||||||
	useInfiniteFetch,
 | 
						useInfiniteFetch,
 | 
				
			||||||
} from "@kyoo/models";
 | 
					} from "@kyoo/models";
 | 
				
			||||||
import { H6, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
 | 
					import { H2, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
 | 
				
			||||||
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
 | 
					import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
 | 
				
			||||||
import type { ComponentType } from "react";
 | 
					import type { ComponentType } from "react";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
@ -38,14 +38,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
 | 
				
			|||||||
type SeasonProcessed = Season & { href: string };
 | 
					type SeasonProcessed = Season & { href: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SeasonHeader = ({
 | 
					export const SeasonHeader = ({
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	seasonNumber,
 | 
						seasonNumber,
 | 
				
			||||||
	name,
 | 
						name,
 | 
				
			||||||
	seasons,
 | 
						seasons,
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
	isLoading: boolean;
 | 
						seasonNumber: number;
 | 
				
			||||||
	seasonNumber?: number;
 | 
						name: string | null;
 | 
				
			||||||
	name?: string;
 | 
					 | 
				
			||||||
	seasons?: SeasonProcessed[];
 | 
						seasons?: SeasonProcessed[];
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
	const { css } = useYoshiki();
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
@ -63,21 +61,20 @@ export const SeasonHeader = ({
 | 
				
			|||||||
						fontSize: rem(1.5),
 | 
											fontSize: rem(1.5),
 | 
				
			||||||
					})}
 | 
										})}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
					{isLoading ? <Skeleton variant="filltext" /> : seasonNumber}
 | 
										{seasonNumber}
 | 
				
			||||||
				</P>
 | 
									</P>
 | 
				
			||||||
				<H6
 | 
									<H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
 | 
				
			||||||
					aria-level={2}
 | 
										{name ?? t("show.season", { number: seasonNumber })}
 | 
				
			||||||
					{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}
 | 
									</H2>
 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					{isLoading ? <Skeleton /> : name}
 | 
					 | 
				
			||||||
				</H6>
 | 
					 | 
				
			||||||
				<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
 | 
									<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
 | 
				
			||||||
					{seasons
 | 
										{seasons
 | 
				
			||||||
						?.filter((x) => x.episodesCount > 0)
 | 
											?.filter((x) => x.episodesCount > 0)
 | 
				
			||||||
						.map((x) => (
 | 
											.map((x) => (
 | 
				
			||||||
							<Menu.Item
 | 
												<Menu.Item
 | 
				
			||||||
								key={x.seasonNumber}
 | 
													key={x.seasonNumber}
 | 
				
			||||||
								label={`${x.seasonNumber}: ${x.name} (${x.episodesCount})`}
 | 
													label={`${x.seasonNumber}: ${
 | 
				
			||||||
 | 
														x.name ?? t("show.season", { number: x.seasonNumber })
 | 
				
			||||||
 | 
													} (${x.episodesCount})`}
 | 
				
			||||||
								href={x.href}
 | 
													href={x.href}
 | 
				
			||||||
							/>
 | 
												/>
 | 
				
			||||||
						))}
 | 
											))}
 | 
				
			||||||
@ -88,6 +85,31 @@ export const SeasonHeader = ({
 | 
				
			|||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SeasonHeader.Loader = () => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View>
 | 
				
			||||||
 | 
								<View {...css({ flexDirection: "row", marginX: ts(1), justifyContent: "space-between" })}>
 | 
				
			||||||
 | 
									<View {...css({ flexDirection: "row", alignItems: "center" })}>
 | 
				
			||||||
 | 
										<Skeleton
 | 
				
			||||||
 | 
											variant="custom"
 | 
				
			||||||
 | 
											{...css({
 | 
				
			||||||
 | 
												width: rem(4),
 | 
				
			||||||
 | 
												flexShrink: 0,
 | 
				
			||||||
 | 
												marginX: ts(1),
 | 
				
			||||||
 | 
												height: rem(1.5),
 | 
				
			||||||
 | 
											})}
 | 
				
			||||||
 | 
										/>
 | 
				
			||||||
 | 
										<Skeleton {...css({ marginX: ts(1), width: rem(12), height: rem(2) })} />
 | 
				
			||||||
 | 
									</View>
 | 
				
			||||||
 | 
									<IconButton icon={MenuIcon} disabled />
 | 
				
			||||||
 | 
								</View>
 | 
				
			||||||
 | 
								<HR />
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
 | 
					SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
 | 
				
			||||||
	parser: SeasonP,
 | 
						parser: SeasonP,
 | 
				
			||||||
	path: ["show", slug, "seasons"],
 | 
						path: ["show", slug, "seasons"],
 | 
				
			||||||
@ -128,34 +150,39 @@ export const EpisodeList = <Props,>({
 | 
				
			|||||||
			divider
 | 
								divider
 | 
				
			||||||
			Header={Header}
 | 
								Header={Header}
 | 
				
			||||||
			headerProps={headerProps}
 | 
								headerProps={headerProps}
 | 
				
			||||||
			getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")}
 | 
								getItemType={(item) => (!item || item.firstOfSeason ? "withHeader" : "normal")}
 | 
				
			||||||
			contentContainerStyle={pageStyle}
 | 
								contentContainerStyle={pageStyle}
 | 
				
			||||||
		>
 | 
								placeholderCount={5}
 | 
				
			||||||
			{(item) => {
 | 
								Render={({ item }) => {
 | 
				
			||||||
				const sea = item?.firstOfSeason
 | 
									const sea = item?.firstOfSeason
 | 
				
			||||||
					? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
 | 
										? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
 | 
				
			||||||
					: null;
 | 
										: null;
 | 
				
			||||||
				return (
 | 
									return (
 | 
				
			||||||
					<>
 | 
										<>
 | 
				
			||||||
						{item.firstOfSeason && (
 | 
											{item.firstOfSeason &&
 | 
				
			||||||
							<SeasonHeader
 | 
												(sea ? (
 | 
				
			||||||
								isLoading={!sea}
 | 
													<SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
 | 
				
			||||||
								name={sea?.name}
 | 
												) : (
 | 
				
			||||||
								seasonNumber={sea?.seasonNumber}
 | 
													<SeasonHeader.Loader />
 | 
				
			||||||
								seasons={seasons}
 | 
												))}
 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
						<EpisodeLine
 | 
											<EpisodeLine
 | 
				
			||||||
							{...item}
 | 
												{...item}
 | 
				
			||||||
 | 
												// Don't display "Go to show"
 | 
				
			||||||
							showSlug={null}
 | 
												showSlug={null}
 | 
				
			||||||
							displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
 | 
												displayNumber={episodeDisplayNumber(item)}
 | 
				
			||||||
							watchedPercent={item.watchStatus?.watchedPercent ?? null}
 | 
												watchedPercent={item.watchStatus?.watchedPercent ?? null}
 | 
				
			||||||
							watchedStatus={item.watchStatus?.status ?? null}
 | 
												watchedStatus={item.watchStatus?.status ?? null}
 | 
				
			||||||
						/>
 | 
											/>
 | 
				
			||||||
					</>
 | 
										</>
 | 
				
			||||||
				);
 | 
									);
 | 
				
			||||||
			}}
 | 
								}}
 | 
				
			||||||
		</InfiniteFetch>
 | 
								Loader={({ index }) => (
 | 
				
			||||||
 | 
									<>
 | 
				
			||||||
 | 
										{index === 0 && <SeasonHeader.Loader />}
 | 
				
			||||||
 | 
										<EpisodeLine.Loader />
 | 
				
			||||||
 | 
									</>
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							/>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -79,12 +79,11 @@ export const ShowWatchStatusCard = ({ watchedPercent, status, nextEpisode }: Sho
 | 
				
			|||||||
				>
 | 
									>
 | 
				
			||||||
					<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
 | 
										<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
 | 
				
			||||||
					<EpisodeLine
 | 
										<EpisodeLine
 | 
				
			||||||
						isLoading={false}
 | 
					 | 
				
			||||||
						{...nextEpisode}
 | 
											{...nextEpisode}
 | 
				
			||||||
						showSlug={null}
 | 
											showSlug={null}
 | 
				
			||||||
						watchedPercent={watchedPercent || null}
 | 
											watchedPercent={watchedPercent || null}
 | 
				
			||||||
						watchedStatus={status || null}
 | 
											watchedStatus={status || null}
 | 
				
			||||||
						displayNumber={episodeDisplayNumber(nextEpisode, "???")!}
 | 
											displayNumber={episodeDisplayNumber(nextEpisode)}
 | 
				
			||||||
						onHoverIn={() => setFocus(true)}
 | 
											onHoverIn={() => setFocus(true)}
 | 
				
			||||||
						onHoverOut={() => setFocus(false)}
 | 
											onHoverOut={() => setFocus(false)}
 | 
				
			||||||
						onFocus={() => setFocus(true)}
 | 
											onFocus={() => setFocus(true)}
 | 
				
			||||||
 | 
				
			|||||||
@ -195,6 +195,7 @@ const downloadIcon = (status: State["status"]) => {
 | 
				
			|||||||
			return Downloading;
 | 
								return Downloading;
 | 
				
			||||||
		case "FAILED":
 | 
							case "FAILED":
 | 
				
			||||||
			return ErrorIcon;
 | 
								return ErrorIcon;
 | 
				
			||||||
 | 
							case "PENDING":
 | 
				
			||||||
		case "PAUSED":
 | 
							case "PAUSED":
 | 
				
			||||||
		case "STOPPED":
 | 
							case "STOPPED":
 | 
				
			||||||
			return NotStarted;
 | 
								return NotStarted;
 | 
				
			||||||
 | 
				
			|||||||
@ -18,7 +18,7 @@
 | 
				
			|||||||
 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
					 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { type Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
 | 
					import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
 | 
				
			||||||
import { HR, useBreakpointMap } from "@kyoo/primitives";
 | 
					import { HR, useBreakpointMap } from "@kyoo/primitives";
 | 
				
			||||||
import { type ContentStyle, FlashList } from "@shopify/flash-list";
 | 
					import { type ContentStyle, FlashList } from "@shopify/flash-list";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -30,7 +30,7 @@ import {
 | 
				
			|||||||
} from "react";
 | 
					} from "react";
 | 
				
			||||||
import { FlatList, View, type ViewStyle } from "react-native";
 | 
					import { FlatList, View, type ViewStyle } from "react-native";
 | 
				
			||||||
import { ErrorView } from "./errors";
 | 
					import { ErrorView } from "./errors";
 | 
				
			||||||
import { EmptyView, type Layout, OfflineView, type WithLoading, addHeader } from "./fetch";
 | 
					import { EmptyView, type Layout, OfflineView, addHeader } from "./fetch";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emulateGap = (
 | 
					const emulateGap = (
 | 
				
			||||||
	layout: "grid" | "vertical" | "horizontal",
 | 
						layout: "grid" | "vertical" | "horizontal",
 | 
				
			||||||
@ -65,7 +65,8 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
 | 
				
			|||||||
	query,
 | 
						query,
 | 
				
			||||||
	placeholderCount = 2,
 | 
						placeholderCount = 2,
 | 
				
			||||||
	incremental = false,
 | 
						incremental = false,
 | 
				
			||||||
	children,
 | 
						Render,
 | 
				
			||||||
 | 
						Loader,
 | 
				
			||||||
	layout,
 | 
						layout,
 | 
				
			||||||
	empty,
 | 
						empty,
 | 
				
			||||||
	divider = false,
 | 
						divider = false,
 | 
				
			||||||
@ -82,16 +83,14 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
 | 
				
			|||||||
	placeholderCount?: number;
 | 
						placeholderCount?: number;
 | 
				
			||||||
	layout: Layout;
 | 
						layout: Layout;
 | 
				
			||||||
	horizontal?: boolean;
 | 
						horizontal?: boolean;
 | 
				
			||||||
	children: (
 | 
						Render: (props: { item: Data; index: number }) => ReactElement | null;
 | 
				
			||||||
		item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
 | 
						Loader: (props: { index: number }) => ReactElement | null;
 | 
				
			||||||
		i: number,
 | 
					 | 
				
			||||||
	) => ReactElement | null;
 | 
					 | 
				
			||||||
	empty?: string | JSX.Element;
 | 
						empty?: string | JSX.Element;
 | 
				
			||||||
	incremental?: boolean;
 | 
						incremental?: boolean;
 | 
				
			||||||
	divider?: boolean | ComponentType;
 | 
						divider?: boolean | ComponentType;
 | 
				
			||||||
	Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
 | 
						Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
 | 
				
			||||||
	headerProps?: Props;
 | 
						headerProps?: Props;
 | 
				
			||||||
	getItemType?: (item: WithLoading<Data>, index: number) => Kind;
 | 
						getItemType?: (item: Data | null, index: number) => Kind;
 | 
				
			||||||
	getItemSize?: (kind: Kind) => number;
 | 
						getItemSize?: (kind: Kind) => number;
 | 
				
			||||||
	fetchMore?: boolean;
 | 
						fetchMore?: boolean;
 | 
				
			||||||
	nested?: boolean;
 | 
						nested?: boolean;
 | 
				
			||||||
@ -111,9 +110,7 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	if (incremental) items ??= oldItems.current;
 | 
						if (incremental) items ??= oldItems.current;
 | 
				
			||||||
	const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
 | 
						const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
 | 
				
			||||||
	const placeholders = [...Array(count === 0 ? numColumns : count)].map(
 | 
						const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
 | 
				
			||||||
		(_, i) => ({ id: `gen${i}`, isLoading: true }) as Data,
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
	const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
 | 
						const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
 | 
						const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
 | 
				
			||||||
@ -137,12 +134,12 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
 | 
				
			|||||||
						},
 | 
											},
 | 
				
			||||||
					]}
 | 
										]}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
					{children({ isLoading: false, ...item } as any, index)}
 | 
										{item ? <Render index={index} item={item} /> : <Loader index={index} />}
 | 
				
			||||||
				</View>
 | 
									</View>
 | 
				
			||||||
			)}
 | 
								)}
 | 
				
			||||||
			data={data}
 | 
								data={data}
 | 
				
			||||||
			horizontal={layout.layout === "horizontal"}
 | 
								horizontal={layout.layout === "horizontal"}
 | 
				
			||||||
			keyExtractor={(item: any) => item.id}
 | 
								keyExtractor={(item: any, index) => (item ? item.id : index)}
 | 
				
			||||||
			numColumns={layout.layout === "horizontal" ? 1 : numColumns}
 | 
								numColumns={layout.layout === "horizontal" ? 1 : numColumns}
 | 
				
			||||||
			estimatedItemSize={size}
 | 
								estimatedItemSize={size}
 | 
				
			||||||
			onEndReached={fetchMore ? fetchNextPage : undefined}
 | 
								onEndReached={fetchMore ? fetchNextPage : undefined}
 | 
				
			||||||
 | 
				
			|||||||
@ -18,7 +18,7 @@
 | 
				
			|||||||
 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
					 * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { type Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
 | 
					import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
 | 
				
			||||||
import { HR } from "@kyoo/primitives";
 | 
					import { HR } from "@kyoo/primitives";
 | 
				
			||||||
import type { ContentStyle } from "@shopify/flash-list";
 | 
					import type { ContentStyle } from "@shopify/flash-list";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -33,7 +33,7 @@ import {
 | 
				
			|||||||
} from "react";
 | 
					} from "react";
 | 
				
			||||||
import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
 | 
					import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
 | 
				
			||||||
import { ErrorView } from "./errors";
 | 
					import { ErrorView } from "./errors";
 | 
				
			||||||
import { EmptyView, type Layout, type WithLoading, addHeader } from "./fetch";
 | 
					import { EmptyView, type Layout, addHeader } from "./fetch";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const InfiniteScroll = <Props,>({
 | 
					const InfiniteScroll = <Props,>({
 | 
				
			||||||
	children,
 | 
						children,
 | 
				
			||||||
@ -145,7 +145,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
 | 
				
			|||||||
	query,
 | 
						query,
 | 
				
			||||||
	incremental = false,
 | 
						incremental = false,
 | 
				
			||||||
	placeholderCount = 2,
 | 
						placeholderCount = 2,
 | 
				
			||||||
	children,
 | 
						Render,
 | 
				
			||||||
	layout,
 | 
						layout,
 | 
				
			||||||
	empty,
 | 
						empty,
 | 
				
			||||||
	divider: Divider = false,
 | 
						divider: Divider = false,
 | 
				
			||||||
@ -154,21 +154,20 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
 | 
				
			|||||||
	getItemType,
 | 
						getItemType,
 | 
				
			||||||
	getItemSize,
 | 
						getItemSize,
 | 
				
			||||||
	nested,
 | 
						nested,
 | 
				
			||||||
 | 
						Loader,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
	query: ReturnType<typeof useInfiniteFetch<_, Data>>;
 | 
						query: ReturnType<typeof useInfiniteFetch<_, Data>>;
 | 
				
			||||||
	incremental?: boolean;
 | 
						incremental?: boolean;
 | 
				
			||||||
	placeholderCount?: number;
 | 
						placeholderCount?: number;
 | 
				
			||||||
	layout: Layout;
 | 
						layout: Layout;
 | 
				
			||||||
	children: (
 | 
						Render: (props: { item: Data; index: number }) => ReactElement | null;
 | 
				
			||||||
		item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
 | 
						Loader: (props: { index: number }) => ReactElement | null;
 | 
				
			||||||
		i: number,
 | 
					 | 
				
			||||||
	) => ReactElement | null;
 | 
					 | 
				
			||||||
	empty?: string | JSX.Element;
 | 
						empty?: string | JSX.Element;
 | 
				
			||||||
	divider?: boolean | ComponentType;
 | 
						divider?: boolean | ComponentType;
 | 
				
			||||||
	Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
 | 
						Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
 | 
				
			||||||
	headerProps: HeaderProps;
 | 
						headerProps: HeaderProps;
 | 
				
			||||||
	getItemType?: (item: WithLoading<Data>, index: number) => Kind;
 | 
						getItemType?: (item: Data | null, index: number) => Kind;
 | 
				
			||||||
	getItemSize?: (kind: Kind) => number;
 | 
						getItemSize?: (kind: Kind) => number;
 | 
				
			||||||
	fetchMore?: boolean;
 | 
						fetchMore?: boolean;
 | 
				
			||||||
	contentContainerStyle?: ContentStyle;
 | 
						contentContainerStyle?: ContentStyle;
 | 
				
			||||||
@ -193,7 +192,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
 | 
				
			|||||||
			loader={[...Array(placeholderCount)].map((_, i) => (
 | 
								loader={[...Array(placeholderCount)].map((_, i) => (
 | 
				
			||||||
				<Fragment key={i.toString()}>
 | 
									<Fragment key={i.toString()}>
 | 
				
			||||||
					{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
 | 
										{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
 | 
				
			||||||
					{children({ isLoading: true } as any, i)}
 | 
										<Loader index={i} />
 | 
				
			||||||
				</Fragment>
 | 
									</Fragment>
 | 
				
			||||||
			))}
 | 
								))}
 | 
				
			||||||
			Header={Header}
 | 
								Header={Header}
 | 
				
			||||||
@ -203,7 +202,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
 | 
				
			|||||||
			{(items ?? oldItems.current)?.map((item, i) => (
 | 
								{(items ?? oldItems.current)?.map((item, i) => (
 | 
				
			||||||
				<Fragment key={(item as any).id}>
 | 
									<Fragment key={(item as any).id}>
 | 
				
			||||||
					{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
 | 
										{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
 | 
				
			||||||
					{children({ ...item, isLoading: false } as any, i)}
 | 
										<Render item={item} index={i} />
 | 
				
			||||||
				</Fragment>
 | 
									</Fragment>
 | 
				
			||||||
			))}
 | 
								))}
 | 
				
			||||||
		</InfiniteScroll>
 | 
							</InfiniteScroll>
 | 
				
			||||||
 | 
				
			|||||||
@ -75,13 +75,9 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
 | 
				
			|||||||
				layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
									layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
				
			||||||
				placeholderCount={2}
 | 
									placeholderCount={2}
 | 
				
			||||||
				empty={displayEmpty.current ? t("home.none") : undefined}
 | 
									empty={displayEmpty.current ? t("home.none") : undefined}
 | 
				
			||||||
			>
 | 
									Render={({ item }) => <ItemGrid {...itemMap(item)} />}
 | 
				
			||||||
				{(x, i) => {
 | 
									Loader={ItemGrid.Loader}
 | 
				
			||||||
					// only display empty list if a loading as been displayed (not durring ssr)
 | 
								/>
 | 
				
			||||||
					if (x.isLoading) displayEmpty.current = true;
 | 
					 | 
				
			||||||
					return <ItemGrid key={x.id ?? i} {...itemMap(x)} />;
 | 
					 | 
				
			||||||
				}}
 | 
					 | 
				
			||||||
			</InfiniteFetchList>
 | 
					 | 
				
			||||||
		</>
 | 
							</>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -36,41 +36,43 @@ export const NewsList = () => {
 | 
				
			|||||||
			<InfiniteFetch
 | 
								<InfiniteFetch
 | 
				
			||||||
				query={NewsList.query()}
 | 
									query={NewsList.query()}
 | 
				
			||||||
				layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
									layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
				
			||||||
				getItemType={(x, i) => (x.kind === "movie" || (x.isLoading && i % 2) ? "movie" : "episode")}
 | 
									getItemType={(x, i) => (x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode")}
 | 
				
			||||||
				getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
 | 
									getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
 | 
				
			||||||
				empty={t("home.none")}
 | 
									empty={t("home.none")}
 | 
				
			||||||
			>
 | 
									Render={({ item }) => {
 | 
				
			||||||
				{(x, i) =>
 | 
										if (item.kind === "episode") {
 | 
				
			||||||
					x.kind === "movie" || (x.isLoading && i % 2) ? (
 | 
											return (
 | 
				
			||||||
 | 
												<EpisodeBox
 | 
				
			||||||
 | 
													slug={item.slug}
 | 
				
			||||||
 | 
													showSlug={item.show!.slug}
 | 
				
			||||||
 | 
													name={`${item.show!.name} ${episodeDisplayNumber(item)}`}
 | 
				
			||||||
 | 
													overview={item.name}
 | 
				
			||||||
 | 
													thumbnail={item.thumbnail}
 | 
				
			||||||
 | 
													href={item.href}
 | 
				
			||||||
 | 
													watchedPercent={item.watchStatus?.watchedPercent || null}
 | 
				
			||||||
 | 
													watchedStatus={item.watchStatus?.status || null}
 | 
				
			||||||
 | 
													// TODO: Move this into the ItemList (using getItemSize)
 | 
				
			||||||
 | 
													// @ts-expect-error This is a web only property
 | 
				
			||||||
 | 
													{...css({ gridColumnEnd: "span 2" })}
 | 
				
			||||||
 | 
												/>
 | 
				
			||||||
 | 
											);
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return (
 | 
				
			||||||
						<ItemGrid
 | 
											<ItemGrid
 | 
				
			||||||
							isLoading={x.isLoading as any}
 | 
												href={item.href}
 | 
				
			||||||
							href={x.href}
 | 
												slug={item.slug}
 | 
				
			||||||
							slug={x.slug}
 | 
												name={item.name!}
 | 
				
			||||||
							name={x.name!}
 | 
												subtitle={getDisplayDate(item)}
 | 
				
			||||||
							subtitle={!x.isLoading ? getDisplayDate(x) : undefined}
 | 
												poster={item.poster}
 | 
				
			||||||
							poster={x.poster}
 | 
												watchStatus={item.watchStatus?.status || null}
 | 
				
			||||||
							watchStatus={x.watchStatus?.status || null}
 | 
												watchPercent={item.watchStatus?.watchedPercent || null}
 | 
				
			||||||
							watchPercent={x.watchStatus?.watchedPercent || null}
 | 
												unseenEpisodesCount={null}
 | 
				
			||||||
							type={"movie"}
 | 
												type={"movie"}
 | 
				
			||||||
						/>
 | 
											/>
 | 
				
			||||||
					) : (
 | 
										);
 | 
				
			||||||
						<EpisodeBox
 | 
									}}
 | 
				
			||||||
							isLoading={x.isLoading as any}
 | 
									Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
 | 
				
			||||||
							slug={x.slug}
 | 
								/>
 | 
				
			||||||
							showSlug={x.kind === "episode" ? x.show!.slug : null}
 | 
					 | 
				
			||||||
							name={x.kind === "episode" ? `${x.show!.name} ${episodeDisplayNumber(x)}` : undefined}
 | 
					 | 
				
			||||||
							overview={x.name}
 | 
					 | 
				
			||||||
							thumbnail={x.thumbnail}
 | 
					 | 
				
			||||||
							href={x.href}
 | 
					 | 
				
			||||||
							watchedPercent={x.watchStatus?.watchedPercent || null}
 | 
					 | 
				
			||||||
							watchedStatus={x.watchStatus?.status || null}
 | 
					 | 
				
			||||||
							// TODO: Move this into the ItemList (using getItemSize)
 | 
					 | 
				
			||||||
							// @ts-expect-error This is a web only property
 | 
					 | 
				
			||||||
							{...css({ gridColumnEnd: "span 2" })}
 | 
					 | 
				
			||||||
						/>
 | 
					 | 
				
			||||||
					)
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			</InfiniteFetch>
 | 
					 | 
				
			||||||
		</>
 | 
							</>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -33,6 +33,7 @@ import {
 | 
				
			|||||||
	IconFab,
 | 
						IconFab,
 | 
				
			||||||
	Link,
 | 
						Link,
 | 
				
			||||||
	P,
 | 
						P,
 | 
				
			||||||
 | 
						Poster,
 | 
				
			||||||
	PosterBackground,
 | 
						PosterBackground,
 | 
				
			||||||
	Skeleton,
 | 
						Skeleton,
 | 
				
			||||||
	SubP,
 | 
						SubP,
 | 
				
			||||||
@ -48,11 +49,10 @@ import { ScrollView, View } from "react-native";
 | 
				
			|||||||
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
					import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
 | 
				
			||||||
import { ItemGrid, ItemWatchStatus } from "../browse/grid";
 | 
					import { ItemGrid, ItemWatchStatus } from "../browse/grid";
 | 
				
			||||||
import { ItemContext } from "../components/context-menus";
 | 
					import { ItemContext } from "../components/context-menus";
 | 
				
			||||||
import type { Layout, WithLoading } from "../fetch";
 | 
					import type { Layout } from "../fetch";
 | 
				
			||||||
import { InfiniteFetch } from "../fetch-infinite";
 | 
					import { InfiniteFetch } from "../fetch-infinite";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ItemDetails = ({
 | 
					export const ItemDetails = ({
 | 
				
			||||||
	isLoading,
 | 
					 | 
				
			||||||
	slug,
 | 
						slug,
 | 
				
			||||||
	type,
 | 
						type,
 | 
				
			||||||
	name,
 | 
						name,
 | 
				
			||||||
@ -66,12 +66,12 @@ export const ItemDetails = ({
 | 
				
			|||||||
	watchStatus,
 | 
						watchStatus,
 | 
				
			||||||
	unseenEpisodesCount,
 | 
						unseenEpisodesCount,
 | 
				
			||||||
	...props
 | 
						...props
 | 
				
			||||||
}: WithLoading<{
 | 
					}: {
 | 
				
			||||||
	slug: string;
 | 
						slug: string;
 | 
				
			||||||
	type: "movie" | "show" | "collection";
 | 
						type: "movie" | "show" | "collection";
 | 
				
			||||||
	name: string;
 | 
						name: string;
 | 
				
			||||||
	tagline: string | null;
 | 
						tagline: string | null;
 | 
				
			||||||
	subtitle: string;
 | 
						subtitle: string | null;
 | 
				
			||||||
	poster: KyooImage | null;
 | 
						poster: KyooImage | null;
 | 
				
			||||||
	genres: Genre[] | null;
 | 
						genres: Genre[] | null;
 | 
				
			||||||
	overview: string | null;
 | 
						overview: string | null;
 | 
				
			||||||
@ -79,7 +79,7 @@ export const ItemDetails = ({
 | 
				
			|||||||
	playHref: string | null;
 | 
						playHref: string | null;
 | 
				
			||||||
	watchStatus: WatchStatusV | null;
 | 
						watchStatus: WatchStatusV | null;
 | 
				
			||||||
	unseenEpisodesCount: number | null;
 | 
						unseenEpisodesCount: number | null;
 | 
				
			||||||
}>) => {
 | 
					}) => {
 | 
				
			||||||
	const [moreOpened, setMoreOpened] = useState(false);
 | 
						const [moreOpened, setMoreOpened] = useState(false);
 | 
				
			||||||
	const { css } = useYoshiki("recommended-card");
 | 
						const { css } = useYoshiki("recommended-card");
 | 
				
			||||||
	const { t } = useTranslation();
 | 
						const { t } = useTranslation();
 | 
				
			||||||
@ -124,7 +124,6 @@ export const ItemDetails = ({
 | 
				
			|||||||
					src={poster}
 | 
										src={poster}
 | 
				
			||||||
					alt=""
 | 
										alt=""
 | 
				
			||||||
					quality="low"
 | 
										quality="low"
 | 
				
			||||||
					forcedLoading={isLoading}
 | 
					 | 
				
			||||||
					layout={{ height: percent(100) }}
 | 
										layout={{ height: percent(100) }}
 | 
				
			||||||
					style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
 | 
										style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
@ -138,18 +137,8 @@ export const ItemDetails = ({
 | 
				
			|||||||
							p: ts(1),
 | 
												p: ts(1),
 | 
				
			||||||
						})}
 | 
											})}
 | 
				
			||||||
					>
 | 
										>
 | 
				
			||||||
						<Skeleton {...css({ width: percent(100) })}>
 | 
											<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>{name}</P>
 | 
				
			||||||
							{isLoading || (
 | 
											{subtitle && <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
 | 
				
			||||||
								<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>
 | 
					 | 
				
			||||||
									{name}
 | 
					 | 
				
			||||||
								</P>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
						</Skeleton>
 | 
					 | 
				
			||||||
						{(subtitle || isLoading) && (
 | 
					 | 
				
			||||||
							<Skeleton {...css({ height: rem(0.8) })}>
 | 
					 | 
				
			||||||
								{isLoading || <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
 | 
					 | 
				
			||||||
							</Skeleton>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</View>
 | 
										</View>
 | 
				
			||||||
					<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
										<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
 | 
				
			||||||
				</PosterBackground>
 | 
									</PosterBackground>
 | 
				
			||||||
@ -163,7 +152,7 @@ export const ItemDetails = ({
 | 
				
			|||||||
							alignContent: "flex-start",
 | 
												alignContent: "flex-start",
 | 
				
			||||||
						})}
 | 
											})}
 | 
				
			||||||
					>
 | 
										>
 | 
				
			||||||
						{slug && type && type !== "collection" && watchStatus !== undefined && (
 | 
											{type !== "collection" && (
 | 
				
			||||||
							<ItemContext
 | 
												<ItemContext
 | 
				
			||||||
								type={type}
 | 
													type={type}
 | 
				
			||||||
								slug={slug}
 | 
													slug={slug}
 | 
				
			||||||
@ -173,18 +162,10 @@ export const ItemDetails = ({
 | 
				
			|||||||
								force
 | 
													force
 | 
				
			||||||
							/>
 | 
												/>
 | 
				
			||||||
						)}
 | 
											)}
 | 
				
			||||||
						{(isLoading || tagline) && (
 | 
											{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
 | 
				
			||||||
							<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}>
 | 
					 | 
				
			||||||
								{isLoading || <P {...css({ p: ts(1) })}>{tagline}</P>}
 | 
					 | 
				
			||||||
							</Skeleton>
 | 
					 | 
				
			||||||
						)}
 | 
					 | 
				
			||||||
					</View>
 | 
										</View>
 | 
				
			||||||
					<ScrollView {...css({ pX: ts(1) })}>
 | 
										<ScrollView {...css({ pX: ts(1) })}>
 | 
				
			||||||
						<Skeleton lines={5} {...css({ height: rem(0.8) })}>
 | 
											<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
 | 
				
			||||||
							{isLoading || (
 | 
					 | 
				
			||||||
								<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
 | 
					 | 
				
			||||||
							)}
 | 
					 | 
				
			||||||
						</Skeleton>
 | 
					 | 
				
			||||||
					</ScrollView>
 | 
										</ScrollView>
 | 
				
			||||||
				</View>
 | 
									</View>
 | 
				
			||||||
			</Link>
 | 
								</Link>
 | 
				
			||||||
@ -209,9 +190,9 @@ export const ItemDetails = ({
 | 
				
			|||||||
					height: px(50),
 | 
										height: px(50),
 | 
				
			||||||
				})}
 | 
									})}
 | 
				
			||||||
			>
 | 
								>
 | 
				
			||||||
				{(isLoading || genres) && (
 | 
									{genres && (
 | 
				
			||||||
					<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
 | 
										<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
 | 
				
			||||||
						{(genres || [...Array(3)])?.map((x, i) => (
 | 
											{genres.map((x, i) => (
 | 
				
			||||||
							<Chip key={x ?? i} label={x} size="small" {...css({ mX: ts(0.5) })} />
 | 
												<Chip key={x ?? i} label={x} size="small" {...css({ mX: ts(0.5) })} />
 | 
				
			||||||
						))}
 | 
											))}
 | 
				
			||||||
					</ScrollView>
 | 
										</ScrollView>
 | 
				
			||||||
@ -231,6 +212,65 @@ export const ItemDetails = ({
 | 
				
			|||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ItemDetails.Loader = (props: object) => {
 | 
				
			||||||
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<View
 | 
				
			||||||
 | 
								{...css(
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										height: ItemDetails.layout.size,
 | 
				
			||||||
 | 
										flexDirection: "row",
 | 
				
			||||||
 | 
										bg: (theme) => theme.variant.background,
 | 
				
			||||||
 | 
										borderRadius: calc(px(imageBorderRadius), "+", ts(0.25)),
 | 
				
			||||||
 | 
										overflow: "hidden",
 | 
				
			||||||
 | 
										borderColor: (theme) => theme.background,
 | 
				
			||||||
 | 
										borderWidth: ts(0.25),
 | 
				
			||||||
 | 
										borderStyle: "solid",
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									props,
 | 
				
			||||||
 | 
								)}
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<Poster.Loader
 | 
				
			||||||
 | 
									layout={{ height: percent(100) }}
 | 
				
			||||||
 | 
									{...css({ borderTopRightRadius: 0, borderBottomRightRadius: 0 })}
 | 
				
			||||||
 | 
								>
 | 
				
			||||||
 | 
									<View
 | 
				
			||||||
 | 
										{...css({
 | 
				
			||||||
 | 
											bg: (theme) => theme.darkOverlay,
 | 
				
			||||||
 | 
											position: "absolute",
 | 
				
			||||||
 | 
											left: 0,
 | 
				
			||||||
 | 
											right: 0,
 | 
				
			||||||
 | 
											bottom: 0,
 | 
				
			||||||
 | 
											p: ts(1),
 | 
				
			||||||
 | 
										})}
 | 
				
			||||||
 | 
									>
 | 
				
			||||||
 | 
										<Skeleton {...css({ width: percent(100) })} />
 | 
				
			||||||
 | 
										<Skeleton {...css({ height: rem(0.8) })} />
 | 
				
			||||||
 | 
									</View>
 | 
				
			||||||
 | 
								</Poster.Loader>
 | 
				
			||||||
 | 
								<View {...css({ flexShrink: 1, flexGrow: 1 })}>
 | 
				
			||||||
 | 
									<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
 | 
				
			||||||
 | 
										<Skeleton {...css({ marginVertical: ts(2) })} />
 | 
				
			||||||
 | 
										<Skeleton lines={5} {...css({ height: rem(0.8) })} />
 | 
				
			||||||
 | 
									</View>
 | 
				
			||||||
 | 
									<View
 | 
				
			||||||
 | 
										{...css({
 | 
				
			||||||
 | 
											bg: (theme) => theme.themeOverlay,
 | 
				
			||||||
 | 
											pX: 4,
 | 
				
			||||||
 | 
											height: px(50),
 | 
				
			||||||
 | 
											flexDirection: "row",
 | 
				
			||||||
 | 
											alignItems: "center",
 | 
				
			||||||
 | 
										})}
 | 
				
			||||||
 | 
									>
 | 
				
			||||||
 | 
										<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
 | 
				
			||||||
 | 
										<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
 | 
				
			||||||
 | 
									</View>
 | 
				
			||||||
 | 
								</View>
 | 
				
			||||||
 | 
							</View>
 | 
				
			||||||
 | 
						);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ItemDetails.layout = {
 | 
					ItemDetails.layout = {
 | 
				
			||||||
	size: ts(36),
 | 
						size: ts(36),
 | 
				
			||||||
	numColumns: { xs: 1, md: 2, xl: 3 },
 | 
						numColumns: { xs: 1, md: 2, xl: 3 },
 | 
				
			||||||
@ -252,29 +292,28 @@ export const Recommended = () => {
 | 
				
			|||||||
				fetchMore={false}
 | 
									fetchMore={false}
 | 
				
			||||||
				nested
 | 
									nested
 | 
				
			||||||
				contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
 | 
									contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
 | 
				
			||||||
			>
 | 
									Render={({ item }) => (
 | 
				
			||||||
				{(x) => (
 | 
					 | 
				
			||||||
					<ItemDetails
 | 
										<ItemDetails
 | 
				
			||||||
						isLoading={x.isLoading as any}
 | 
											slug={item.slug}
 | 
				
			||||||
						slug={x.slug}
 | 
											type={item.kind}
 | 
				
			||||||
						type={x.kind}
 | 
											name={item.name}
 | 
				
			||||||
						name={x.name}
 | 
											tagline={"tagline" in item ? item.tagline : null}
 | 
				
			||||||
						tagline={"tagline" in x ? x.tagline : null}
 | 
											overview={item.overview}
 | 
				
			||||||
						overview={x.overview}
 | 
											poster={item.poster}
 | 
				
			||||||
						poster={x.poster}
 | 
											subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
 | 
				
			||||||
						subtitle={x.kind !== "collection" && !x.isLoading ? getDisplayDate(x) : undefined}
 | 
											genres={"genres" in item ? item.genres : null}
 | 
				
			||||||
						genres={"genres" in x ? x.genres : null}
 | 
											href={item.href}
 | 
				
			||||||
						href={x.href}
 | 
											playHref={item.kind !== "collection" ? item.playHref : null}
 | 
				
			||||||
						playHref={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
 | 
											watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
 | 
				
			||||||
						watchStatus={
 | 
					 | 
				
			||||||
							!x.isLoading && x.kind !== "collection" ? x.watchStatus?.status ?? null : null
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						unseenEpisodesCount={
 | 
											unseenEpisodesCount={
 | 
				
			||||||
							x.kind === "show" ? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount! : null
 | 
												item.kind === "show"
 | 
				
			||||||
 | 
													? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
 | 
				
			||||||
 | 
													: null
 | 
				
			||||||
						}
 | 
											}
 | 
				
			||||||
					/>
 | 
										/>
 | 
				
			||||||
				)}
 | 
									)}
 | 
				
			||||||
			</InfiniteFetch>
 | 
									Loader={ItemDetails.Loader}
 | 
				
			||||||
 | 
								/>
 | 
				
			||||||
		</View>
 | 
							</View>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -41,9 +41,9 @@ export const VerticalRecommended = () => {
 | 
				
			|||||||
				layout={{ ...ItemList.layout, layout: "vertical" }}
 | 
									layout={{ ...ItemList.layout, layout: "vertical" }}
 | 
				
			||||||
				fetchMore={false}
 | 
									fetchMore={false}
 | 
				
			||||||
				nested
 | 
									nested
 | 
				
			||||||
			>
 | 
									Render={({ item }) => <ItemList {...itemMap(item)} />}
 | 
				
			||||||
				{(x, i) => <ItemList key={x.id ?? i} {...itemMap(x)} />}
 | 
									Loader={() => <ItemList.Loader />}
 | 
				
			||||||
			</InfiniteFetch>
 | 
								/>
 | 
				
			||||||
		</View>
 | 
							</View>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -39,55 +39,10 @@ export const WatchlistList = () => {
 | 
				
			|||||||
	const { css } = useYoshiki();
 | 
						const { css } = useYoshiki();
 | 
				
			||||||
	const account = useAccount();
 | 
						const account = useAccount();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return (
 | 
						if (!account) {
 | 
				
			||||||
		<>
 | 
							return (
 | 
				
			||||||
			<Header title={t("home.watchlist")} />
 | 
								<>
 | 
				
			||||||
			{account ? (
 | 
									<Header title={t("home.watchlist")} />
 | 
				
			||||||
				<InfiniteFetch
 | 
					 | 
				
			||||||
					query={WatchlistList.query()}
 | 
					 | 
				
			||||||
					layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
					 | 
				
			||||||
					getItemType={(x, i) =>
 | 
					 | 
				
			||||||
						(x.kind === "show" && x.watchStatus?.nextEpisode) || (x.isLoading && i % 2)
 | 
					 | 
				
			||||||
							? "episode"
 | 
					 | 
				
			||||||
							: "item"
 | 
					 | 
				
			||||||
					}
 | 
					 | 
				
			||||||
					getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
 | 
					 | 
				
			||||||
					empty={t("home.none")}
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					{(x, i) => {
 | 
					 | 
				
			||||||
						const episode = x.kind === "show" ? x.watchStatus?.nextEpisode : null;
 | 
					 | 
				
			||||||
						return (x.kind === "show" && x.watchStatus?.nextEpisode) || (x.isLoading && i % 2) ? (
 | 
					 | 
				
			||||||
							<EpisodeBox
 | 
					 | 
				
			||||||
								isLoading={x.isLoading as any}
 | 
					 | 
				
			||||||
								slug={episode?.slug}
 | 
					 | 
				
			||||||
								showSlug={x.slug}
 | 
					 | 
				
			||||||
								name={episode ? `${x.name} ${episodeDisplayNumber(episode)}` : undefined}
 | 
					 | 
				
			||||||
								overview={episode?.name}
 | 
					 | 
				
			||||||
								thumbnail={episode?.thumbnail ?? x.thumbnail}
 | 
					 | 
				
			||||||
								href={episode?.href}
 | 
					 | 
				
			||||||
								watchedPercent={x.watchStatus?.watchedPercent || null}
 | 
					 | 
				
			||||||
								watchedStatus={x.watchStatus?.status || null}
 | 
					 | 
				
			||||||
								// TODO: Move this into the ItemList (using getItemSize)
 | 
					 | 
				
			||||||
								// @ts-expect-error This is a web only property
 | 
					 | 
				
			||||||
								{...css({ gridColumnEnd: "span 2" })}
 | 
					 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
						) : (
 | 
					 | 
				
			||||||
							<ItemGrid
 | 
					 | 
				
			||||||
								isLoading={x.isLoading as any}
 | 
					 | 
				
			||||||
								href={x.href}
 | 
					 | 
				
			||||||
								slug={x.slug}
 | 
					 | 
				
			||||||
								name={x.name!}
 | 
					 | 
				
			||||||
								subtitle={!x.isLoading ? getDisplayDate(x) : undefined}
 | 
					 | 
				
			||||||
								poster={x.poster}
 | 
					 | 
				
			||||||
								watchStatus={x.watchStatus?.status || null}
 | 
					 | 
				
			||||||
								watchPercent={x.watchStatus?.watchedPercent || null}
 | 
					 | 
				
			||||||
								unseenEpisodesCount={x.kind === "show" ? x.watchStatus?.unseenEpisodesCount : null}
 | 
					 | 
				
			||||||
								type={x.kind}
 | 
					 | 
				
			||||||
							/>
 | 
					 | 
				
			||||||
						);
 | 
					 | 
				
			||||||
					}}
 | 
					 | 
				
			||||||
				</InfiniteFetch>
 | 
					 | 
				
			||||||
			) : (
 | 
					 | 
				
			||||||
				<View {...css({ justifyContent: "center", alignItems: "center" })}>
 | 
									<View {...css({ justifyContent: "center", alignItems: "center" })}>
 | 
				
			||||||
					<P>{t("home.watchlistLogin")}</P>
 | 
										<P>{t("home.watchlistLogin")}</P>
 | 
				
			||||||
					<Button
 | 
										<Button
 | 
				
			||||||
@ -96,7 +51,58 @@ export const WatchlistList = () => {
 | 
				
			|||||||
						{...css({ minWidth: ts(24), margin: ts(2) })}
 | 
											{...css({ minWidth: ts(24), margin: ts(2) })}
 | 
				
			||||||
					/>
 | 
										/>
 | 
				
			||||||
				</View>
 | 
									</View>
 | 
				
			||||||
			)}
 | 
								</>
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return (
 | 
				
			||||||
 | 
							<>
 | 
				
			||||||
 | 
								<Header title={t("home.watchlist")} />
 | 
				
			||||||
 | 
								<InfiniteFetch
 | 
				
			||||||
 | 
									query={WatchlistList.query()}
 | 
				
			||||||
 | 
									layout={{ ...ItemGrid.layout, layout: "horizontal" }}
 | 
				
			||||||
 | 
									getItemType={(x, i) =>
 | 
				
			||||||
 | 
										(x?.kind === "show" && x.watchStatus?.nextEpisode) || (!x && i % 2) ? "episode" : "item"
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
 | 
				
			||||||
 | 
									empty={t("home.none")}
 | 
				
			||||||
 | 
									Render={({ item }) => {
 | 
				
			||||||
 | 
										const episode = item.kind === "show" ? item.watchStatus?.nextEpisode : null;
 | 
				
			||||||
 | 
										if (episode) {
 | 
				
			||||||
 | 
											return (
 | 
				
			||||||
 | 
												<EpisodeBox
 | 
				
			||||||
 | 
													slug={episode.slug}
 | 
				
			||||||
 | 
													showSlug={item.slug}
 | 
				
			||||||
 | 
													name={`${item.name} ${episodeDisplayNumber(episode)}`}
 | 
				
			||||||
 | 
													overview={episode.name}
 | 
				
			||||||
 | 
													thumbnail={episode.thumbnail ?? item.thumbnail}
 | 
				
			||||||
 | 
													href={episode.href}
 | 
				
			||||||
 | 
													watchedPercent={item.watchStatus?.watchedPercent || null}
 | 
				
			||||||
 | 
													watchedStatus={item.watchStatus?.status || null}
 | 
				
			||||||
 | 
													// TODO: Move this into the ItemList (using getItemSize)
 | 
				
			||||||
 | 
													// @ts-expect-error This is a web only property
 | 
				
			||||||
 | 
													{...css({ gridColumnEnd: "span 2" })}
 | 
				
			||||||
 | 
												/>
 | 
				
			||||||
 | 
											);
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return (
 | 
				
			||||||
 | 
											<ItemGrid
 | 
				
			||||||
 | 
												href={item.href}
 | 
				
			||||||
 | 
												slug={item.slug}
 | 
				
			||||||
 | 
												name={item.name!}
 | 
				
			||||||
 | 
												subtitle={getDisplayDate(item)}
 | 
				
			||||||
 | 
												poster={item.poster}
 | 
				
			||||||
 | 
												watchStatus={item.watchStatus?.status || null}
 | 
				
			||||||
 | 
												watchPercent={item.watchStatus?.watchedPercent || null}
 | 
				
			||||||
 | 
												unseenEpisodesCount={
 | 
				
			||||||
 | 
													(item.kind === "show" && item.watchStatus?.unseenEpisodesCount) || null
 | 
				
			||||||
 | 
												}
 | 
				
			||||||
 | 
												type={item.kind}
 | 
				
			||||||
 | 
											/>
 | 
				
			||||||
 | 
										);
 | 
				
			||||||
 | 
									}}
 | 
				
			||||||
 | 
									Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
 | 
				
			||||||
 | 
								/>
 | 
				
			||||||
		</>
 | 
							</>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -53,7 +53,7 @@ const mapData = (
 | 
				
			|||||||
	if (!data) return { isLoading: true };
 | 
						if (!data) return { isLoading: true };
 | 
				
			||||||
	return {
 | 
						return {
 | 
				
			||||||
		isLoading: false,
 | 
							isLoading: false,
 | 
				
			||||||
		name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
 | 
							name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`,
 | 
				
			||||||
		showName: data.type === "movie" ? data.name! : data.show!.name,
 | 
							showName: data.type === "movie" ? data.name! : data.show!.name,
 | 
				
			||||||
		poster: data.type === "movie" ? data.poster : data.show!.poster,
 | 
							poster: data.type === "movie" ? data.poster : data.show!.poster,
 | 
				
			||||||
		subtitles: info?.subtitles,
 | 
							subtitles: info?.subtitles,
 | 
				
			||||||
 | 
				
			|||||||
@ -77,9 +77,9 @@ export const SearchPage: QueryPage<{ q?: string }> = ({ q }) => {
 | 
				
			|||||||
				/>
 | 
									/>
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			contentContainerStyle={pageStyle}
 | 
								contentContainerStyle={pageStyle}
 | 
				
			||||||
		>
 | 
								Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
 | 
				
			||||||
			{(item) => <LayoutComponent {...itemMap(item)} />}
 | 
								Loader={LayoutComponent.Loader}
 | 
				
			||||||
		</InfiniteFetch>
 | 
							/>
 | 
				
			||||||
	);
 | 
						);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -39,7 +39,8 @@
 | 
				
			|||||||
			"droped": "Mark as dropped",
 | 
								"droped": "Mark as dropped",
 | 
				
			||||||
			"null": "Mark as not seen"
 | 
								"null": "Mark as not seen"
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		"nextUp": "Next up"
 | 
							"nextUp": "Next up",
 | 
				
			||||||
 | 
							"season": "Season {{number}}"
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"browse": {
 | 
						"browse": {
 | 
				
			||||||
		"sortby": "Sort by {{key}}",
 | 
							"sortby": "Sort by {{key}}",
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user