(null);
- const { css } = useYoshiki();
-
- const onScroll = useCallback(() => {
- if (!ref.current || !hasMore || isFetching || !fetchMore) return;
- const scroll =
- layout.layout === "horizontal"
- ? ref.current.scrollWidth - ref.current.scrollLeft
- : ref.current.scrollHeight - ref.current.scrollTop;
- const offset =
- layout.layout === "horizontal" ? ref.current.offsetWidth : ref.current.offsetHeight;
-
- // Load more if less than 3 element's worth of scroll is left
- if (scroll <= offset * 3) loadMore();
- }, [hasMore, isFetching, layout, loadMore, fetchMore]);
- const scrollProps = { ref, onScroll };
-
- // Automatically trigger a scroll check on start and after a fetch end in case the user is already
- // at the bottom of the page or if there is no scroll bar (ultrawide or something like that)
- // biome-ignore lint/correctness/useExhaustiveDependencies: Check for scroll pause after fetch ends
- useEffect(() => {
- onScroll();
- }, [isFetching, onScroll]);
-
- const list = (props: object) => (
- `${100 / x}%`),
- gridTemplateRows: "max-content",
- paddingX: layout.gap as any,
- },
- layout.layout === "grid" && {
- gridTemplateColumns: ysMap(layout.numColumns, (x) => `repeat(${x}, 1fr)`),
- justifyContent: "center",
- alignItems: "flex-start",
- overflowY: "auto",
- padding: layout.gap as any,
- },
- contentContainerStyle as any,
- ],
- nativeStyleToCss(props),
- )}
- >
- {children}
- {isFetching && loader}
-
- );
-
- if (!Header) return list({ ...scrollProps, ...props });
- if (!isValidElement(Header))
- return (
- // @ts-ignore
-
- );
- return (
- <>
- {Header}
- {list({ ...scrollProps, ...props })}
- >
- );
-};
-
-export const InfiniteFetchList = ({
- query,
- incremental = false,
- placeholderCount = 2,
- Render,
- layout,
- empty,
- divider: Divider = false,
- Header,
- headerProps,
- getItemType,
- getItemSize,
- nested,
- Loader,
- ...props
-}: {
- query: ReturnType>;
- incremental?: boolean;
- placeholderCount?: number;
- layout: Layout;
- Render: (props: { item: Data; index: number }) => ReactElement | null;
- Loader: (props: { index: number }) => ReactElement | null;
- empty?: string | JSX.Element;
- divider?: boolean | ComponentType;
- Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
- headerProps: HeaderProps;
- getItemType?: (item: Data | null, index: number) => Kind;
- getItemSize?: (kind: Kind) => number;
- fetchMore?: boolean;
- contentContainerStyle?: ContentStyle;
- nested?: boolean;
-}): JSX.Element | null => {
- const oldItems = useRef();
- const { items, error, fetchNextPage, hasNextPage, isFetching } = query;
- if (incremental && items) oldItems.current = items;
-
- if (error) return addHeader(Header, , headerProps);
- if (empty && items && items.length === 0) {
- if (typeof empty !== "string") return addHeader(Header, empty, headerProps);
- return addHeader(Header, , headerProps);
- }
-
- return (
- (
-
- {Divider && i !== 0 && (Divider === true ?
: )}
-
-
- ))}
- Header={Header}
- headerProps={headerProps}
- {...props}
- >
- {(items ?? oldItems.current)?.map((item, i) => (
-
- {Divider && i !== 0 && (Divider === true ?
: )}
-
-
- ))}
-
- );
-};
-
-export const InfiniteFetch = ({
- query,
- ...props
-}: {
- query: QueryIdentifier<_, Data>;
-} & Omit>, "query">) => {
- if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
-
- const ret = useInfiniteFetch(query);
- return ;
-};
diff --git a/front/src/components/context-menus.tsx b/front/src/components/context-menus.tsx
new file mode 100644
index 00000000..ca89af3e
--- /dev/null
+++ b/front/src/components/context-menus.tsx
@@ -0,0 +1,160 @@
+/*
+ * Kyoo - A portable and vast media library solution.
+ * Copyright (c) Kyoo.
+ *
+ * See AUTHORS.md and LICENSE file in the project root for full license information.
+ *
+ * Kyoo is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * any later version.
+ *
+ * Kyoo is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Kyoo. If not, see .
+ */
+
+import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
+// import Download from "@material-symbols/svg-400/rounded/download.svg";
+import Info from "@material-symbols/svg-400/rounded/info.svg";
+import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
+import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { ComponentProps } from "react";
+import { useTranslation } from "react-i18next";
+import { Platform } from "react-native";
+import { useYoshiki } from "yoshiki/native";
+import { WatchStatusV } from "~/models";
+import { HR, IconButton, Menu, tooltip } from "~/primitives";
+import { useAccount } from "~/providers/account-context";
+import { queryFn } from "~/query";
+// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
+
+export const EpisodesContext = ({
+ type = "episode",
+ slug,
+ showSlug,
+ status,
+ force,
+ ...props
+}: {
+ type?: "serie" | "movie" | "episode";
+ showSlug?: string | null;
+ slug: string;
+ status: WatchStatusV | null;
+ force?: boolean;
+} & Partial>>) => {
+ const account = useAccount();
+ // const downloader = useDownloader();
+ const { css } = useYoshiki();
+ const { t } = useTranslation();
+
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: (newStatus: WatchStatusV | null) =>
+ queryFn({
+ path: [type, slug, "watchStatus", newStatus && `?status=${newStatus}`],
+ method: newStatus ? "POST" : "DELETE",
+ }),
+ onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }),
+ });
+
+ const metadataRefreshMutation = useMutation({
+ mutationFn: () =>
+ queryFn({
+ path: [type, slug, "refresh"],
+ method: "POST",
+ }),
+ });
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export const ItemContext = ({
+ type,
+ slug,
+ status,
+ force,
+ ...props
+}: {
+ type: "movie" | "serie";
+ slug: string;
+ status: WatchStatusV | null;
+ force?: boolean;
+} & Partial>>) => {
+ return (
+
+ );
+};
diff --git a/front/src/components/index.ts b/front/src/components/index.ts
new file mode 100644
index 00000000..d41506ac
--- /dev/null
+++ b/front/src/components/index.ts
@@ -0,0 +1,2 @@
+export * from "./item-grid";
+export * from "./item-list";
diff --git a/front/src/ui/browse/grid.tsx b/front/src/components/item-grid.tsx
similarity index 72%
rename from front/src/ui/browse/grid.tsx
rename to front/src/components/item-grid.tsx
index f0259f30..fe9fc966 100644
--- a/front/src/ui/browse/grid.tsx
+++ b/front/src/components/item-grid.tsx
@@ -1,6 +1,8 @@
-import { type KyooImage, WatchStatusV } from "@kyoo/models";
+import { useState } from "react";
+import { type ImageStyle, Platform, View } from "react-native";
+import { type Stylable, type Theme, percent, px, useYoshiki } from "yoshiki/native";
+import type { KyooImage, WatchStatusV } from "~/models";
import {
- Icon,
Link,
P,
Poster,
@@ -10,55 +12,9 @@ import {
focusReset,
important,
ts,
-} from "@kyoo/primitives";
-import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
-import { useState } from "react";
-import { type ImageStyle, Platform, View } from "react-native";
-import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
-import { ItemContext } from "../../../packages/ui/src/components/context-menus";
-import type { Layout } from "../fetch";
-
-export const ItemWatchStatus = ({
- watchStatus,
- unseenEpisodesCount,
- ...props
-}: {
- watchStatus?: WatchStatusV | null;
- unseenEpisodesCount?: number | null;
-}) => {
- const { css } = useYoshiki();
-
- if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) return null;
-
- return (
- theme.darkOverlay,
- borderRadius: 999999,
- },
- props,
- )}
- >
- {watchStatus === WatchStatusV.Completed ? (
-
- ) : (
-
- {unseenEpisodesCount}
-
- )}
-
- );
-};
+} from "~/primitives";
+import type { Layout } from "~/query";
+import { ItemWatchStatus } from "./item-helpers";
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
const { css } = useYoshiki("episodebox");
@@ -106,7 +62,7 @@ export const ItemGrid = ({
poster: KyooImage | null;
watchStatus: WatchStatusV | null;
watchPercent: number | null;
- type: "movie" | "show" | "collection";
+ type: "movie" | "serie" | "collection";
unseenEpisodesCount: number | null;
} & Stylable<"text">) => {
const [moreOpened, setMoreOpened] = useState(false);
diff --git a/front/src/components/item-helpers.tsx b/front/src/components/item-helpers.tsx
new file mode 100644
index 00000000..e0c0d0f0
--- /dev/null
+++ b/front/src/components/item-helpers.tsx
@@ -0,0 +1,47 @@
+import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
+import { View } from "react-native";
+import { max, rem, useYoshiki } from "yoshiki/native";
+import { WatchStatusV } from "~/models";
+import { Icon, P, ts } from "~/primitives";
+
+export const ItemWatchStatus = ({
+ watchStatus,
+ unseenEpisodesCount,
+ ...props
+}: {
+ watchStatus?: WatchStatusV | null;
+ unseenEpisodesCount?: number | null;
+}) => {
+ const { css } = useYoshiki();
+
+ if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) return null;
+
+ return (
+ theme.darkOverlay,
+ borderRadius: 999999,
+ },
+ props,
+ )}
+ >
+ {watchStatus === WatchStatusV.Completed ? (
+
+ ) : (
+
+ {unseenEpisodesCount}
+
+ )}
+
+ );
+};
diff --git a/front/src/components/item-list.tsx b/front/src/components/item-list.tsx
new file mode 100644
index 00000000..e8012011
--- /dev/null
+++ b/front/src/components/item-list.tsx
@@ -0,0 +1,180 @@
+import { useState } from "react";
+import { Platform, View } from "react-native";
+import { percent, px, rem, useYoshiki } from "yoshiki/native";
+import type { KyooImage, WatchStatusV } from "~/models";
+import {
+ GradientImageBackground,
+ Heading,
+ Link,
+ P,
+ Poster,
+ PosterBackground,
+ Skeleton,
+ imageBorderRadius,
+ important,
+ ts,
+} from "~/primitives";
+import type { Layout } from "~/query";
+import { ItemWatchStatus } from "./item-helpers";
+
+export const ItemList = ({
+ href,
+ slug,
+ type,
+ name,
+ subtitle,
+ thumbnail,
+ poster,
+ watchStatus,
+ unseenEpisodesCount,
+ ...props
+}: {
+ href: string;
+ slug: string;
+ type: "movie" | "show" | "collection";
+ name: string;
+ subtitle: string | null;
+ poster: KyooImage | null;
+ thumbnail: KyooImage | null;
+ watchStatus: WatchStatusV | null;
+ unseenEpisodesCount: number | null;
+}) => {
+ const { css } = useYoshiki("line");
+ const [moreOpened, setMoreOpened] = useState(false);
+
+ return (
+ setMoreOpened(true)}
+ {...css(
+ {
+ alignItems: "center",
+ justifyContent: "space-evenly",
+ flexDirection: "row",
+ height: ItemList.layout.size,
+ borderRadius: px(imageBorderRadius),
+ overflow: "hidden",
+ marginX: ItemList.layout.gap,
+ child: {
+ more: {
+ opacity: 0,
+ },
+ },
+ fover: {
+ title: {
+ textDecorationLine: "underline",
+ },
+ more: {
+ opacity: 100,
+ },
+ },
+ },
+ props,
+ )}
+ >
+
+
+
+ {name}
+
+ {type !== "collection" && (
+ setMoreOpened(v)}
+ {...css([
+ {
+ // I dont know why marginLeft gets overwritten by the margin: px(2) so we important
+ marginLeft: important(ts(2)),
+ bg: (theme) => theme.darkOverlay,
+ },
+ "more",
+ Platform.OS === "web" && moreOpened && { opacity: important(100) },
+ ])}
+ />
+ )}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+
+
+
+ );
+};
+
+ItemList.Loader = (props: object) => {
+ const { css } = useYoshiki();
+
+ return (
+ theme.dark.background,
+ marginX: ItemList.layout.gap,
+ },
+ props,
+ )}
+ >
+
+
+
+
+
+
+ );
+};
+
+ItemList.layout = {
+ numColumns: 1,
+ size: 300,
+ layout: "vertical",
+ gap: ts(2),
+} satisfies Layout;
diff --git a/front/src/primitives/utils/breakpoint.ts b/front/src/primitives/utils/breakpoint.ts
new file mode 100644
index 00000000..49768ecc
--- /dev/null
+++ b/front/src/primitives/utils/breakpoint.ts
@@ -0,0 +1,41 @@
+import { useWindowDimensions } from "react-native";
+import { type Breakpoints as YoshikiBreakpoint, breakpoints, isBreakpoints } from "yoshiki/native";
+
+type AtLeastOne }> = Partial & U[keyof U];
+export type Breakpoint = T | AtLeastOne>;
+
+// copied from yoshiki.
+const useBreakpoint = () => {
+ const { width } = useWindowDimensions();
+ const idx = Object.values(breakpoints).findIndex((x) => width <= x);
+ if (idx === -1) return 0;
+ return idx - 1;
+};
+
+const getBreakpointValue = (value: Breakpoint, breakpoint: number): T => {
+ if (!isBreakpoints(value)) return value;
+ const bpKeys = Object.keys(breakpoints) as Array>;
+ for (let i = breakpoint; i >= 0; i--) {
+ if (bpKeys[i] in value) {
+ const val = value[bpKeys[i]];
+ if (val) return val;
+ }
+ }
+ // This should never be reached.
+ return undefined!;
+};
+
+export const useBreakpointValue = (value: Breakpoint): T => {
+ const breakpoint = useBreakpoint();
+ return getBreakpointValue(value, breakpoint);
+};
+
+export const useBreakpointMap = >(
+ value: T,
+): { [key in keyof T]: T[key] extends Breakpoint ? V : T } => {
+ const breakpoint = useBreakpoint();
+ // @ts-ignore
+ return Object.fromEntries(
+ Object.entries(value).map(([key, val]) => [key, getBreakpointValue(val, breakpoint)]),
+ );
+};
diff --git a/front/src/primitives/utils/index.tsx b/front/src/primitives/utils/index.tsx
index 9e3bea8e..5d553a93 100644
--- a/front/src/primitives/utils/index.tsx
+++ b/front/src/primitives/utils/index.tsx
@@ -4,3 +4,4 @@ export * from "./spacing";
export * from "./capitalize";
export * from "./touchonly";
export * from "./page-style";
+export * from "./breakpoint";
diff --git a/front/src/providers/error-provider.tsx b/front/src/providers/error-provider.tsx
index f4e4e753..132ffdb7 100644
--- a/front/src/providers/error-provider.tsx
+++ b/front/src/providers/error-provider.tsx
@@ -46,7 +46,7 @@ export const ErrorProvider = ({ children }: { children: ReactNode }) => {
export const useSetError = (key: string) => {
const { setError, clearError } = useContext(ErrorSetterContext);
- const set = ({ key: nKey, ...obj }: Omit & { key?: Error["key"] }) =>
+ const set = ({ key: nKey, ...obj }: Omit & { key?: Error["key"] } = {}) =>
setError({ key: nKey ?? key, ...obj });
const clear = () => clearError(key);
return [set, clear] as const;
diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx
new file mode 100644
index 00000000..ca5f6026
--- /dev/null
+++ b/front/src/query/fetch-infinite.tsx
@@ -0,0 +1,103 @@
+import { LegendList } from "@legendapp/list";
+import { type ComponentProps, type ComponentType, type ReactElement, useRef } from "react";
+import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
+import { useSetError } from "~/providers/error-provider";
+import { type QueryIdentifier, useInfiniteFetch } from "~/query";
+import { ErrorView } from "../ui/errors";
+
+export type Layout = {
+ numColumns: Breakpoint;
+ size: Breakpoint;
+ gap: Breakpoint;
+ layout: "grid" | "horizontal" | "vertical";
+};
+
+export const InfiniteFetchList = ({
+ query,
+ placeholderCount = 2,
+ incremental = false,
+ Render,
+ Loader,
+ layout,
+ Empty,
+ divider,
+ Header,
+ headerProps,
+ getItemType,
+ getItemSize,
+ fetchMore = true,
+ nested = false,
+ ...props
+}: {
+ query: ReturnType>;
+ placeholderCount?: number;
+ layout: Layout;
+ horizontal?: boolean;
+ Render: (props: { item: Data; index: number }) => ReactElement | null;
+ Loader: (props: { index: number }) => ReactElement | null;
+ Empty?: JSX.Element;
+ incremental?: boolean;
+ divider?: true | ComponentType;
+ Header?: ComponentType | ReactElement;
+ headerProps?: Props;
+ getItemType?: (item: Data | null, index: number) => Kind;
+ getItemSize?: (kind: Kind) => number;
+ fetchMore?: boolean;
+ nested?: boolean;
+}): JSX.Element | null => {
+ const { numColumns, size, gap } = useBreakpointMap(layout);
+ const [setOffline, clearOffline] = useSetError("offline");
+ const oldItems = useRef(undefined);
+ let { items, isPaused, error, fetchNextPage, isFetching, refetch, isRefetching } = query;
+ if (incremental && items) oldItems.current = items;
+
+ if (isPaused) setOffline();
+ else clearOffline();
+
+ if (error) return ;
+
+ if (incremental) items ??= oldItems.current;
+ const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
+ const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
+ const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
+
+ return (
+
+ item ? :
+ }
+ keyExtractor={(item: any, index) => (item ? item.id : index)}
+ estimatedItemSize={size}
+
+ horizontal={layout.layout === "horizontal"}
+ numColumns={layout.layout === "horizontal" ? 1 : numColumns}
+
+ onEndReached={fetchMore ? () => fetchNextPage() : undefined}
+ onEndReachedThreshold={0.5}
+ onRefresh={layout.layout !== "horizontal" ? refetch: undefined}
+ refreshing={isRefetching}
+
+ ListHeaderComponent={Header}
+ ItemSeparatorComponent={divider === true ? HR : divider as any || undefined}
+ ListEmptyComponent={Empty}
+
+ contentContainerStyle={{gap}}
+
+ {...props}
+ />
+ );
+};
+
+export const InfiniteFetch = ({
+ query,
+ ...props
+}: {
+ query: QueryIdentifier<_, Data>;
+} & Omit>, "query">) => {
+ if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
+
+ const ret = useInfiniteFetch(query);
+ return ;
+};
diff --git a/front/src/query/index.tsx b/front/src/query/index.tsx
index db4c7731..63bad09f 100644
--- a/front/src/query/index.tsx
+++ b/front/src/query/index.tsx
@@ -1,2 +1,3 @@
export * from "./query";
export * from "./fetch";
+export * from "./fetch-infinite";
diff --git a/front/src/ui/browse/index.tsx b/front/src/ui/browse/index.tsx
index 00ba0ca9..ab31056b 100644
--- a/front/src/ui/browse/index.tsx
+++ b/front/src/ui/browse/index.tsx
@@ -6,11 +6,12 @@ import {
type QueryPage,
getDisplayDate,
} from "~/models";
-import { InfiniteFetch } from "../../query/fetch-infinite"
+import { useQueryState } from "~/utils";
import { DefaultLayout } from "../../../packages/ui/src/layout";
-import { ItemGrid } from "./grid";
+import { InfiniteFetch } from "../../query/fetch-infinite";
+import { ItemGrid } from "~/components";
import { BrowseSettings } from "./header";
-import { ItemList } from "./list";
+import { ItemList } from "../../components/item-list";
import {
Layout,
type MediaType,
@@ -21,8 +22,6 @@ import {
SortOrd,
} from "./types";
-const { useParam } = createParam<{ sortBy?: string; mediaType?: string }>();
-
export const itemMap = (
item: LibraryItem,
): ComponentProps & ComponentProps => ({
@@ -66,8 +65,8 @@ export const getMediaTypeFromParam = (mediaTypeParam?: string): MediaType => {
};
export const BrowsePage: QueryPage = () => {
- const [sort, setSort] = useParam("sortBy");
- const [mediaTypeParam, setMediaTypeParam] = useParam("mediaType");
+ const [sort, setSort] = useQueryState("sortBy", "");
+ const [mediaTypeParam, setMediaTypeParam] = useQueryState("mediaType", "");
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name;
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
const [layout, setLayout] = useState(Layout.Grid);
diff --git a/front/src/ui/browse/list.tsx b/front/src/ui/browse/list.tsx
index 217d3930..7619d65a 100644
--- a/front/src/ui/browse/list.tsx
+++ b/front/src/ui/browse/list.tsx
@@ -14,9 +14,9 @@ import {
import { useState } from "react";
import { Platform, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
-import { ItemContext } from "../../../packages/ui/src/components/context-menus";
+import { ItemContext } from "../../packages/ui/src/components/context-menus";
import type { Layout } from "../fetch";
-import { ItemWatchStatus } from "./grid";
+import { ItemWatchStatus } from "../ui/browse/grid";
export const ItemList = ({
href,