5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
bdfef4c6bb Remove old home directory from packages/ui
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:03:13 +00:00
copilot-swe-agent[bot]
c7c3cd720b Fix null href handling and date formatting in home components
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:02:46 +00:00
copilot-swe-agent[bot]
e6859a1fff Fix imports and model field references in home components
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:00:54 +00:00
copilot-swe-agent[bot]
cc60dece54 Migrate home page components from v4 to new structure
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 16:56:35 +00:00
copilot-swe-agent[bot]
7d2e050ed6 Initial plan 2025-12-19 16:40:38 +00:00
9 changed files with 192 additions and 232 deletions

View File

@@ -1,34 +1,4 @@
import { useTranslation } from "react-i18next"; import { HomePage, loader } from "~/ui/home";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { Show } from "~/models";
import { P } from "~/primitives";
import { Fetch, prefetch, type QueryIdentifier } from "~/query";
export async function loader() { export { loader };
await prefetch(Header.query()); export default HomePage;
}
export default function Header() {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View>
<P>{t("home.recommended")}</P>
<Fetch
query={Header.query()}
Render={({ name }) => <P {...(css({ bg: "red" }) as any)}>{name}</P>}
Loader={() => <P>Loading</P>}
/>
</View>
);
}
Header.query = (): QueryIdentifier<Show> => ({
parser: Show,
path: ["shows", "random"],
params: {
fields: ["firstEntry"],
},
});

View File

@@ -33,6 +33,27 @@ const Base = z.object({
playedDate: zdate().nullable(), playedDate: zdate().nullable(),
videoId: z.string().nullable(), videoId: z.string().nullable(),
}), }),
// Optional fields for API responses
serie: z
.object({
id: z.string(),
slug: z.string(),
name: z.string(),
})
.optional(),
watchStatus: z
.object({
status: z.enum([
"completed",
"watching",
"rewatching",
"dropped",
"planned",
]),
percent: z.number().int().gte(0).lte(100),
})
.nullable()
.optional(),
}); });
export const Episode = Base.extend({ export const Episode = Base.extend({

View File

@@ -18,21 +18,14 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {
type Genre,
type LibraryItem,
LibraryItemP,
type QueryIdentifier,
useInfiniteFetch,
} from "@kyoo/models";
import { H3, ts } from "@kyoo/primitives";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { itemMap } from "../browse"; import { ItemGrid, itemMap } from "~/components/items";
import { ItemGrid } from "../browse/grid"; import type { Genre, Show } from "~/models";
import { InfiniteFetchList } from "../fetch-infinite"; import { H3, ts } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
export const Header = ({ title }: { title: string }) => { export const Header = ({ title }: { title: string }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@@ -48,32 +41,19 @@ export const Header = ({ title }: { title: string }) => {
})} })}
> >
<H3>{title}</H3> <H3>{title}</H3>
{/* <View {...css({ flexDirection: "row" })}> */}
{/* <IconButton */}
{/* icon={ChevronLeft} */}
{/* // onPress={() => ref.current?.scrollTo({ x: 0, animated: true })} */}
{/* /> */}
{/* <IconButton */}
{/* icon={ChevronRight} */}
{/* // onPress={() => ref.current?.scrollTo({ x: 0, animated: true })} */}
{/* /> */}
{/* </View> */}
</View> </View>
); );
}; };
export const GenreGrid = ({ genre }: { genre: Genre }) => { export const GenreGrid = ({ genre }: { genre: Genre }) => {
const query = useInfiniteFetch(GenreGrid.query(genre));
const displayEmpty = useRef(false); const displayEmpty = useRef(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
{(displayEmpty.current || query.items?.length !== 0) && ( <Header title={t(`genres.${genre}`)} />
<Header title={t(`genres.${genre}`)} /> <InfiniteFetch
)} query={GenreGrid.query(genre)}
<InfiniteFetchList
query={query}
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}
@@ -84,15 +64,15 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
); );
}; };
GenreGrid.query = (genre: Genre): QueryIdentifier<LibraryItem> => ({ GenreGrid.query = (genre: Genre): QueryIdentifier<Show> => ({
parser: LibraryItemP, parser: Show,
infinite: true, infinite: true,
path: ["items"], path: ["api", "shows"],
params: { params: {
fields: ["watchStatus", "episodesCount"], fields: ["watchStatus"],
filter: `genres has ${genre}`, filter: `genres has ${genre}`,
sortBy: "random", sort: "random",
// Limit the inital numbers of items // Limit the initial numbers of items
limit: 10, limit: 10,
}, },
}); });

View File

@@ -18,45 +18,45 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { type KyooImage, type LibraryItem, LibraryItemP, type QueryIdentifier } from "@kyoo/models"; import Info from "@material-symbols/svg-400/rounded/info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { KImage, Show } from "~/models";
import { import {
GradientImageBackground, GradientImageBackground,
H1, H1,
H2, H2,
IconButton, IconButton,
IconFab, IconFab,
ImageBackground,
Link, Link,
P, P,
Skeleton, Skeleton,
tooltip, tooltip,
ts, ts,
} from "@kyoo/primitives"; } from "~/primitives";
import Info from "@material-symbols/svg-400/rounded/info.svg"; import { type QueryIdentifier } from "~/query";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import { Header as DetailsHeader } from "../details/header";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import { Header as DetailsHeader } from "../../../../src/ui/details/header";
import type { WithLoading } from "../fetch";
export const Header = ({ export const Header = ({
isLoading, isLoading,
name, name,
thumbnail, thumbnail,
overview, description,
tagline, tagline,
link, link,
infoLink, infoLink,
...props ...props
}: WithLoading<{ }: {
isLoading?: boolean;
name: string; name: string;
thumbnail: KyooImage | null; thumbnail: KImage | null;
overview: string | null; description: string | null;
tagline: string | null; tagline: string | null;
link: string | null; link: string | null;
infoLink: string; infoLink: string;
}>) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -105,7 +105,7 @@ export const Header = ({
<Skeleton lines={4} {...css({ marginTop: ts(1) })}> <Skeleton lines={4} {...css({ marginTop: ts(1) })}>
{isLoading || ( {isLoading || (
<P numberOfLines={4} {...css({ display: { xs: "none", md: "flex" } })}> <P numberOfLines={4} {...css({ display: { xs: "none", md: "flex" } })}>
{overview} {description}
</P> </P>
)} )}
</Skeleton> </Skeleton>
@@ -114,10 +114,10 @@ export const Header = ({
); );
}; };
Header.query = (): QueryIdentifier<LibraryItem> => ({ Header.query = (): QueryIdentifier<Show> => ({
parser: LibraryItemP, parser: Show,
path: ["items", "random"], path: ["api", "shows", "random"],
params: { params: {
fields: ["firstEpisode"], fields: ["firstEntry"],
}, },
}); });

View File

@@ -18,12 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Genre, type QueryPage, toQueryKey } from "@kyoo/models";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { RefreshControl, ScrollView } from "react-native"; import { RefreshControl, ScrollView } from "react-native";
import { Fetch } from "../fetch"; import { Genre } from "~/models";
import { DefaultLayout } from "../layout"; import { Fetch, prefetch } from "~/query";
import { GenreGrid } from "./genre"; import { GenreGrid } from "./genre";
import { Header } from "./header"; import { Header } from "./header";
import { NewsList } from "./news"; import { NewsList } from "./news";
@@ -31,9 +29,21 @@ import { Recommended } from "./recommended";
import { VerticalRecommended } from "./vertical"; import { VerticalRecommended } from "./vertical";
import { WatchlistList } from "./watchlist"; import { WatchlistList } from "./watchlist";
export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => { export async function loader() {
const queryClient = useQueryClient(); const randomItems = [...Object.values(Genre)];
await Promise.all([
prefetch(Header.query()),
prefetch(WatchlistList.query()),
prefetch(NewsList.query()),
...randomItems.filter((_, i) => i < 6).map((x) => prefetch(GenreGrid.query(x))),
prefetch(Recommended.query()),
prefetch(VerticalRecommended.query()),
]);
}
export const HomePage = () => {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const randomItems = [...Object.values(Genre)];
return ( return (
<ScrollView <ScrollView
@@ -41,34 +51,38 @@ export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => {
<RefreshControl <RefreshControl
onRefresh={async () => { onRefresh={async () => {
setRefreshing(true); setRefreshing(true);
await Promise.all( await loader();
HomePage.getFetchUrls!({}, randomItems).map((query) =>
queryClient.refetchQueries({
queryKey: toQueryKey(query),
type: "active",
exact: true,
}),
),
);
setRefreshing(false); setRefreshing(false);
}} }}
refreshing={refreshing} refreshing={refreshing}
/> />
} }
> >
<Fetch query={Header.query()}> <Fetch
{(x) => ( query={Header.query()}
Render={(x) => (
<Header <Header
isLoading={x.isLoading as any} isLoading={false}
name={x.name} name={x.name}
tagline={"tagline" in x ? x.tagline : null} tagline={x.kind !== "collection" && "tagline" in x ? x.tagline : null}
overview={x.overview} description={x.description}
thumbnail={x.thumbnail} thumbnail={x.thumbnail}
link={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined} link={x.kind !== "collection" ? x.playHref : null}
infoLink={x.href} infoLink={x.href}
/> />
)} )}
</Fetch> Loader={() => (
<Header
isLoading={true}
name=""
tagline={null}
description={null}
thumbnail={null}
link={null}
infoLink="#"
/>
)}
/>
<WatchlistList /> <WatchlistList />
<NewsList /> <NewsList />
{randomItems {randomItems
@@ -90,16 +104,3 @@ export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => {
</ScrollView> </ScrollView>
); );
}; };
HomePage.randomItems = [...Object.values(Genre)];
HomePage.getLayout = { Layout: DefaultLayout, props: { transparent: true } };
HomePage.getFetchUrls = (_, randomItems) => [
Header.query(),
WatchlistList.query(),
NewsList.query(),
...randomItems.filter((_, i) => i < 6).map((x) => GenreGrid.query(x)),
Recommended.query(),
VerticalRecommended.query(),
];

View File

@@ -18,12 +18,12 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/models";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid"; import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { ItemGrid } from "~/components/items";
import { InfiniteFetch } from "../fetch-infinite"; import type { Entry } from "~/models";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { Header } from "./genre"; import { Header } from "./genre";
export const NewsList = () => { export const NewsList = () => {
@@ -40,17 +40,16 @@ export const NewsList = () => {
getItemSize={(kind) => (kind === "episode" ? 2 : 1)} getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")} empty={t("home.none")}
Render={({ item }) => { Render={({ item }) => {
if (item.kind === "episode") { if (item.kind === "episode" || item.kind === "special") {
return ( return (
<EpisodeBox <EntryBox
slug={item.slug} slug={item.slug}
showSlug={item.show!.slug} serieSlug={item.serie!.slug}
name={`${item.show!.name} ${episodeDisplayNumber(item)}`} name={`${item.serie!.name} ${entryDisplayNumber(item)}`}
overview={item.name} description={item.name}
thumbnail={item.thumbnail} thumbnail={item.thumbnail}
href={item.href} href={item.href ?? "#"}
watchedPercent={item.watchStatus?.watchedPercent || null} watchedPercent={item.watchStatus?.percent || null}
watchedStatus={item.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize) // TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property // @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })} {...css({ gridColumnEnd: "span 2" })}
@@ -59,31 +58,31 @@ export const NewsList = () => {
} }
return ( return (
<ItemGrid <ItemGrid
href={item.href} href={item.href ?? "#"}
slug={item.slug} slug={item.slug}
kind={"movie"}
name={item.name!} name={item.name!}
subtitle={getDisplayDate(item)} subtitle={item.airDate ? new Date(item.airDate).getFullYear().toString() : null}
poster={item.poster} poster={item.kind === "movie" ? item.poster : null}
watchStatus={item.watchStatus?.status || null} watchStatus={item.watchStatus?.status || null}
watchPercent={item.watchStatus?.watchedPercent || null} watchPercent={item.watchStatus?.percent || null}
unseenEpisodesCount={null} unseenEpisodesCount={null}
type={"movie"}
/> />
); );
}} }}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)} Loader={({ index }) => (index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />)}
/> />
</> </>
); );
}; };
NewsList.query = (): QueryIdentifier<News> => ({ NewsList.query = (): QueryIdentifier<Entry> => ({
parser: NewsP, parser: Entry,
infinite: true, infinite: true,
path: ["news"], path: ["api", "news"],
params: { params: {
// Limit the initial numbers of items // Limit the initial numbers of items
limit: 10, limit: 10,
fields: ["show", "watchStatus"], fields: ["serie", "watchStatus"],
}, },
}); });

View File

@@ -18,47 +18,42 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
type Genre, import { useState } from "react";
type KyooImage, import { useTranslation } from "react-i18next";
type LibraryItem, import { ScrollView, View } from "react-native";
LibraryItemP, import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
type QueryIdentifier, import { ItemGrid, ItemWatchStatus } from "~/components/items";
type WatchStatusV, import { ItemContext } from "~/components/items/context-menus";
getDisplayDate, import type { Genre, KImage, Show, WatchStatusV } from "~/models";
} from "@kyoo/models"; import { getDisplayDate } from "~/utils";
import { import {
Chip, Chip,
H3, H3,
IconFab, IconFab,
Link, Link,
P, P,
Poster,
PosterBackground, PosterBackground,
Skeleton, Skeleton,
SubP, SubP,
focusReset,
imageBorderRadius,
tooltip, tooltip,
ts, ts,
} from "@kyoo/primitives"; } from "~/primitives";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import { InfiniteFetch, type Layout, type QueryIdentifier } from "~/query";
import { useState } from "react";
import { useTranslation } from "react-i18next"; const imageBorderRadius = 6;
import { ScrollView, View } from "react-native"; const focusReset = {
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native"; boxShadow: "unset",
import { ItemGrid, ItemWatchStatus } from "../browse/grid"; outline: "none",
import { ItemContext } from "../../../../src/ui/info/components/context-menus"; };
import type { Layout } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite";
export const ItemDetails = ({ export const ItemDetails = ({
slug, slug,
type, kind,
name, name,
tagline, tagline,
subtitle, subtitle,
overview, description,
poster, poster,
genres, genres,
href, href,
@@ -68,13 +63,13 @@ export const ItemDetails = ({
...props ...props
}: { }: {
slug: string; slug: string;
type: "movie" | "show" | "collection"; kind: "movie" | "serie" | "collection";
name: string; name: string;
tagline: string | null; tagline: string | null;
subtitle: string | null; subtitle: string | null;
poster: KyooImage | null; poster: KImage | null;
genres: Genre[] | null; genres: Genre[] | null;
overview: string | null; description: string | null;
href: string; href: string;
playHref: string | null; playHref: string | null;
watchStatus: WatchStatusV | null; watchStatus: WatchStatusV | null;
@@ -152,9 +147,9 @@ export const ItemDetails = ({
alignContent: "flex-start", alignContent: "flex-start",
})} })}
> >
{type !== "collection" && ( {kind !== "collection" && (
<ItemContext <ItemContext
type={type} kind={kind}
slug={slug} slug={slug}
status={watchStatus} status={watchStatus}
isOpen={moreOpened} isOpen={moreOpened}
@@ -165,7 +160,7 @@ export const ItemDetails = ({
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>} {tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
</View> </View>
<ScrollView {...css({ pX: ts(1) })}> <ScrollView {...css({ pX: ts(1) })}>
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP> <SubP {...css({ textAlign: "justify" })}>{description ?? t("show.noOverview")}</SubP>
</ScrollView> </ScrollView>
</View> </View>
</Link> </Link>
@@ -231,9 +226,12 @@ ItemDetails.Loader = (props: object) => {
props, props,
)} )}
> >
<Poster.Loader <PosterBackground
src={null}
alt=""
quality="low"
layout={{ height: percent(100) }} layout={{ height: percent(100) }}
{...css({ borderTopRightRadius: 0, borderBottomRightRadius: 0 })} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
> >
<View <View
{...css({ {...css({
@@ -248,7 +246,7 @@ ItemDetails.Loader = (props: object) => {
<Skeleton {...css({ width: percent(100) })} /> <Skeleton {...css({ width: percent(100) })} />
<Skeleton {...css({ height: rem(0.8) })} /> <Skeleton {...css({ height: rem(0.8) })} />
</View> </View>
</Poster.Loader> </PosterBackground>
<View {...css({ flexShrink: 1, flexGrow: 1 })}> <View {...css({ flexShrink: 1, flexGrow: 1 })}>
<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}> <View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
<Skeleton {...css({ marginVertical: ts(2) })} /> <Skeleton {...css({ marginVertical: ts(2) })} />
@@ -295,19 +293,19 @@ export const Recommended = () => {
Render={({ item }) => ( Render={({ item }) => (
<ItemDetails <ItemDetails
slug={item.slug} slug={item.slug}
type={item.kind} kind={item.kind}
name={item.name} name={item.name}
tagline={"tagline" in item ? item.tagline : null} tagline={item.kind !== "collection" && "tagline" in item ? item.tagline : null}
overview={item.overview} description={item.description}
poster={item.poster} poster={item.poster}
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null} subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
genres={"genres" in item ? item.genres : null} genres={item.kind !== "collection" && "genres" in item ? item.genres : null}
href={item.href} href={item.href}
playHref={item.kind !== "collection" ? item.playHref : null} playHref={item.kind !== "collection" ? item.playHref : null}
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null} watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
unseenEpisodesCount={ unseenEpisodesCount={
item.kind === "show" item.kind === "serie"
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!) ? (item.availableCount - (item.watchStatus?.seenCount ?? 0))
: null : null
} }
/> />
@@ -318,13 +316,13 @@ export const Recommended = () => {
); );
}; };
Recommended.query = (): QueryIdentifier<LibraryItem> => ({ Recommended.query = (): QueryIdentifier<Show> => ({
parser: LibraryItemP, parser: Show,
infinite: true, infinite: true,
path: ["items"], path: ["api", "shows"],
params: { params: {
sortBy: "random", sort: "random",
limit: 6, limit: 6,
fields: ["firstEpisode", "episodesCount", "watchStatus"], fields: ["firstEntry", "watchStatus"],
}, },
}); });

View File

@@ -18,15 +18,13 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { type LibraryItem, LibraryItemP, type QueryIdentifier } from "@kyoo/models";
import { H3 } from "@kyoo/primitives";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { itemMap } from "../browse"; import { ItemGrid, ItemList, itemMap } from "~/components/items";
import { ItemGrid } from "../browse/grid"; import type { Show } from "~/models";
import { ItemList } from "../browse/list"; import { H3 } from "~/primitives";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch, type QueryIdentifier } from "~/query";
export const VerticalRecommended = () => { export const VerticalRecommended = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -48,13 +46,13 @@ export const VerticalRecommended = () => {
); );
}; };
VerticalRecommended.query = (): QueryIdentifier<LibraryItem> => ({ VerticalRecommended.query = (): QueryIdentifier<Show> => ({
parser: LibraryItemP, parser: Show,
infinite: true, infinite: true,
path: ["items"], path: ["api", "shows"],
params: { params: {
fields: ["episodesCount", "watchStatus"], fields: ["watchStatus"],
sortBy: "random", sort: "random",
limit: 3, limit: 3,
}, },
}); });

View File

@@ -18,20 +18,16 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {
type QueryIdentifier,
type Watchlist,
WatchlistP,
getDisplayDate,
useAccount,
} from "@kyoo/models";
import { Button, P, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid"; import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { ItemGrid } from "~/components/items";
import { InfiniteFetch } from "../fetch-infinite"; import type { Show } from "~/models";
import { getDisplayDate } from "~/utils";
import { Button, P, ts } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { Header } from "./genre"; import { Header } from "./genre";
export const WatchlistList = () => { export const WatchlistList = () => {
@@ -62,23 +58,22 @@ export const WatchlistList = () => {
query={WatchlistList.query()} query={WatchlistList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }} layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) => getItemType={(x, i) =>
(x?.kind === "show" && x.watchStatus?.nextEpisode) || (!x && i % 2) ? "episode" : "item" (x?.kind === "serie" && x.nextEntry) || (!x && i % 2) ? "episode" : "item"
} }
getItemSize={(kind) => (kind === "episode" ? 2 : 1)} getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")} empty={t("home.none")}
Render={({ item }) => { Render={({ item }) => {
const episode = item.kind === "show" ? item.watchStatus?.nextEpisode : null; const entry = item.kind === "serie" ? item.nextEntry : null;
if (episode) { if (entry) {
return ( return (
<EpisodeBox <EntryBox
slug={episode.slug} slug={entry.slug}
showSlug={item.slug} serieSlug={item.slug}
name={`${item.name} ${episodeDisplayNumber(episode)}`} name={`${item.name} ${entryDisplayNumber(entry)}`}
overview={episode.name} description={entry.name}
thumbnail={episode.thumbnail ?? item.thumbnail} thumbnail={entry.thumbnail ?? item.thumbnail}
href={episode.href} href={entry.href ?? "#"}
watchedPercent={item.watchStatus?.watchedPercent || null} watchedPercent={entry.watchStatus?.percent || null}
watchedStatus={item.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize) // TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property // @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })} {...css({ gridColumnEnd: "span 2" })}
@@ -89,31 +84,29 @@ export const WatchlistList = () => {
<ItemGrid <ItemGrid
href={item.href} href={item.href}
slug={item.slug} slug={item.slug}
kind={item.kind}
name={item.name!} name={item.name!}
subtitle={getDisplayDate(item)} subtitle={getDisplayDate(item)}
poster={item.poster} poster={item.poster}
watchStatus={item.watchStatus?.status || null} watchStatus={item.watchStatus?.status || null}
watchPercent={item.watchStatus?.watchedPercent || null} watchPercent={item.kind === "movie" && item.watchStatus ? item.watchStatus.percent : null}
unseenEpisodesCount={ unseenEpisodesCount={null}
(item.kind === "show" && item.watchStatus?.unseenEpisodesCount) || null
}
type={item.kind}
/> />
); );
}} }}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)} Loader={({ index }) => (index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />)}
/> />
</> </>
); );
}; };
WatchlistList.query = (): QueryIdentifier<Watchlist> => ({ WatchlistList.query = (): QueryIdentifier<Show> => ({
parser: WatchlistP, parser: Show,
infinite: true, infinite: true,
path: ["watchlist"], path: ["api", "watchlist"],
params: { params: {
// Limit the inital numbers of items // Limit the initial numbers of items
limit: 10, limit: 10,
fields: ["watchStatus"], fields: ["watchStatus", "nextEntry"],
}, },
}); });