wip: Series details

This commit is contained in:
Zoe Roux 2025-07-14 00:33:40 +02:00
parent e1059aceed
commit 8f80d6c96d
No known key found for this signature in database
8 changed files with 211 additions and 341 deletions

View File

@ -1,35 +0,0 @@
import { z } from "zod";
import { ImagesP, ResourceP } from "../traits";
import { zdate } from "../utils";
export const SeasonP = ResourceP("season").merge(ImagesP).extend({
/**
* The name of this season.
*/
name: z.string(),
/**
* The number of this season. This can be set to 0 to indicate specials.
*/
seasonNumber: z.number(),
/**
* A quick overview of this season.
*/
overview: z.string().nullable(),
/**
* The starting air date of this season.
*/
startDate: zdate().nullable(),
/**
* The ending date of this season.
*/
endDate: zdate().nullable(),
/**
* The number of episodes available on kyoo of this season.
*/
episodesCount: z.number(),
});
/**
* A season of a Show.
*/
export type Season = z.infer<typeof SeasonP>;

View File

@ -0,0 +1,31 @@
import { z } from "zod/v4";
import { KImage } from "./utils/images";
import { zdate } from "./utils/utils";
export const Season = z.object({
id: z.string(),
slug: z.string(),
seasonNumber: z.number().gte(0),
name: z.string().nullable(),
description: z.string().nullable(),
entryCount: z.number(),
startAir: zdate().nullable(),
endAir: zdate().nullable(),
externalId: z.record(
z.string(),
z.object({
serieId: z.string(),
season: z.number(),
link: z.string().nullable(),
}),
),
poster: KImage.nullable(),
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
createdAt: zdate(),
updatedAt: zdate(),
});
export type Season = z.infer<typeof Season>;

View File

@ -1 +1,2 @@
export { MovieDetails } from "./movie";
export { SerieDetails } from "./serie";

View File

@ -1,51 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { Avatar, Link, P, Skeleton, SubP } from "@kyoo/primitives";
import { type Stylable, useYoshiki } from "yoshiki/native";
export const PersonAvatar = ({
slug,
name,
role,
poster,
isLoading,
...props
}: {
isLoading: boolean;
slug?: string;
name?: string;
role?: string;
poster?: string | null;
} & Stylable) => {
const { css } = useYoshiki();
return (
<Link href={slug ? `/person/${slug}` : ""} {...props}>
<Avatar src={poster} alt={name} size={PersonAvatar.width} fill />
<Skeleton>{isLoading || <P {...css({ textAlign: "center" })}>{name}</P>}</Skeleton>
{(isLoading || role) && (
<Skeleton>{isLoading || <SubP {...css({ textAlign: "center" })}>{role}</SubP>}</Skeleton>
)}
</Link>
);
};
PersonAvatar.width = 300;

View File

@ -1,38 +1,22 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
type Episode,
EpisodeP,
type QueryIdentifier,
type Season,
SeasonP,
useInfiniteFetch,
} from "@kyoo/models";
import { H2, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
import { InfiniteFetch } from "../fetch-infinite";
import { type Episode, type Season, useInfiniteFetch } from "~/models";
import {
H2,
HR,
IconButton,
Menu,
P,
Skeleton,
tooltip,
ts,
usePageStyle,
} from "~/primitives";
import type { QueryIdentifier } from "~/query";
import { InfiniteFetch } from "~/query/fetch-infinite";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string };
@ -63,10 +47,21 @@ export const SeasonHeader = ({
>
{seasonNumber}
</P>
<H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
<H2
{...css({
marginX: ts(1),
fontSize: rem(1.5),
flexGrow: 1,
flexShrink: 1,
})}
>
{name ?? t("show.season", { number: seasonNumber })}
</H2>
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
<Menu
Trigger={IconButton}
icon={MenuIcon}
{...tooltip(t("show.jumpToSeason"))}
>
{seasons
?.filter((x) => x.episodesCount > 0)
.map((x) => (
@ -90,7 +85,13 @@ SeasonHeader.Loader = () => {
return (
<View>
<View {...css({ flexDirection: "row", marginX: ts(1), justifyContent: "space-between" })}>
<View
{...css({
flexDirection: "row",
marginX: ts(1),
justifyContent: "space-between",
})}
>
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Skeleton
variant="custom"
@ -101,7 +102,9 @@ SeasonHeader.Loader = () => {
height: rem(1.5),
})}
/>
<Skeleton {...css({ marginX: ts(1), width: rem(12), height: rem(2) })} />
<Skeleton
{...css({ marginX: ts(1), width: rem(12), height: rem(2) })}
/>
</View>
<IconButton icon={MenuIcon} disabled />
</View>
@ -110,18 +113,20 @@ SeasonHeader.Loader = () => {
);
};
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
parser: SeasonP,
path: ["show", slug, "seasons"],
SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
parser: Season,
path: ["series", slug, "seasons"],
params: {
// Fetch all seasons at one, there won't be hundred of thems anyways.
limit: 0,
fields: ["episodesCount"],
},
infinite: {
value: true,
map: (seasons) =>
seasons.map((x) => ({ ...x, href: `/show/${slug}?season=${x.seasonNumber}` })),
seasons.map((x) => ({
...x,
href: `/show/${slug}?season=${x.seasonNumber}`,
})),
},
});
@ -150,7 +155,9 @@ export const EpisodeList = <Props,>({
divider
Header={Header}
headerProps={headerProps}
getItemType={(item) => (!item || item.firstOfSeason ? "withHeader" : "normal")}
getItemType={(item) =>
!item || item.firstOfSeason ? "withHeader" : "normal"
}
contentContainerStyle={pageStyle}
placeholderCount={5}
Render={({ item }) => {
@ -161,7 +168,11 @@ export const EpisodeList = <Props,>({
<>
{item.firstOfSeason &&
(sea ? (
<SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
<SeasonHeader
name={sea.name}
seasonNumber={sea.seasonNumber}
seasons={seasons}
/>
) : (
<SeasonHeader.Loader />
))}

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg";
import { percent, useYoshiki } from "yoshiki/native";
import { Container, focusReset, H2, SwitchVariant, ts } from "~/primitives";
import { useQueryState } from "~/utils";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
import { Header } from "./header";
import { EpisodeList } from "./season";
export const SvgWave = (props: SvgProps) => {
const { css } = useYoshiki();
const width = 612;
const height = 52.771;
return (
<View {...css({ width: percent(100), aspectRatio: width / height })}>
<Svg
width="100%"
height="100%"
viewBox="0 372.979 612 52.771"
fill="black"
{...props}
>
<Path d="M0,375.175c68,-5.1,136,-0.85,204,7.948c68,9.052,136,22.652,204,24.777s136,-8.075,170,-12.878l34,-4.973v35.7h-612" />
</Svg>
</View>
);
};
export const ShowWatchStatusCard = ({
watchedPercent,
status,
nextEpisode,
}: ShowWatchStatus) => {
const { t } = useTranslation();
const [focused, setFocus] = useState(false);
if (!nextEpisode) return null;
return (
<SwitchVariant>
{({ css }) => (
<Container
{...css([
{
marginY: ts(2),
borderRadius: 16,
overflow: "hidden",
borderWidth: ts(0.5),
borderStyle: "solid",
borderColor: (theme) => theme.background,
backgroundColor: (theme) => theme.background,
},
focused && {
...focusReset,
borderColor: (theme) => theme.accent,
},
])}
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine
{...nextEpisode}
showSlug={null}
watchedPercent={watchedPercent || null}
watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</Container>
)}
</SwitchVariant>
);
};
const ShowHeader = ({ children, slug, ...props }) => {
const { css, theme } = useYoshiki();
return (
<View
{...css(
[
{ bg: (theme) => theme.background },
Platform.OS === "web" && {
flexGrow: 1,
flexShrink: 1,
// @ts-ignore Web only property
overflowY: "auto" as any,
},
],
props,
)}
>
<Header kind="serie" slug={slug} />
{/* <DetailsCollections type="serie" slug={slug} /> */}
{/* <Staff slug={slug} /> */}
<SvgWave
fill={theme.variant.background}
{...css({ flexShrink: 0, flexGrow: 1, display: "flex" })}
/>
<View {...css({ bg: theme.variant.background })}>
<Container>{children}</Container>
</View>
</View>
);
},
)
export const ShowDetails = () => {
const { css, theme } = useYoshiki();
const [slug] = useQueryState("slug", undefined!);
return (
<View {...css({ bg: theme.variant.background, flex: 1 })}>
<EpisodeList
slug={slug}
season={season}
Header={ShowHeader}
headerProps={{ slug }}
/>
</View>
);
};

View File

@ -1,159 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
type QueryIdentifier,
type QueryPage,
type Show,
ShowP,
type ShowWatchStatus,
} from "@kyoo/models";
import { Container, H2, SwitchVariant, focusReset, ts } from "@kyoo/primitives";
import { forwardRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg";
import { percent, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../../../packages/ui/src/layoutpackages/ui/src/layout";
import { DetailsCollections } from "./collection";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
import { Header } from "./header";
import { EpisodeList, SeasonHeader } from "./season";
export const SvgWave = (props: SvgProps) => {
const { css } = useYoshiki();
const width = 612;
const height = 52.771;
return (
<View {...css({ width: percent(100), aspectRatio: width / height })}>
<Svg width="100%" height="100%" viewBox="0 372.979 612 52.771" fill="black" {...props}>
<Path d="M0,375.175c68,-5.1,136,-0.85,204,7.948c68,9.052,136,22.652,204,24.777s136,-8.075,170,-12.878l34,-4.973v35.7h-612" />
</Svg>
</View>
);
};
export const ShowWatchStatusCard = ({ watchedPercent, status, nextEpisode }: ShowWatchStatus) => {
const { t } = useTranslation();
const [focused, setFocus] = useState(false);
if (!nextEpisode) return null;
return (
<SwitchVariant>
{({ css }) => (
<Container
{...css([
{
marginY: ts(2),
borderRadius: 16,
overflow: "hidden",
borderWidth: ts(0.5),
borderStyle: "solid",
borderColor: (theme) => theme.background,
backgroundColor: (theme) => theme.background,
},
focused && {
...focusReset,
borderColor: (theme) => theme.accent,
},
])}
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine
{...nextEpisode}
showSlug={null}
watchedPercent={watchedPercent || null}
watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</Container>
)}
</SwitchVariant>
);
};
const ShowHeader = forwardRef<View, ViewProps & { slug: string }>(function ShowHeader(
{ children, slug, ...props },
ref,
) {
const { css, theme } = useYoshiki();
return (
<View
ref={ref}
{...css(
[
{ bg: (theme) => theme.background },
Platform.OS === "web" && {
flexGrow: 1,
flexShrink: 1,
// @ts-ignore Web only property
overflowY: "auto" as any,
},
],
props,
)}
>
<Header type="show" query={query(slug)} />
<DetailsCollections type="show" slug={slug} />
{/* <Staff slug={slug} /> */}
<SvgWave
fill={theme.variant.background}
{...css({ flexShrink: 0, flexGrow: 1, display: "flex" })}
/>
<View {...css({ bg: theme.variant.background })}>
<Container>{children}</Container>
</View>
</View>
);
});
const query = (slug: string): QueryIdentifier<Show> => ({
parser: ShowP,
path: ["show", slug],
params: {
fields: ["studio", "firstEpisode", "watchStatus"],
},
});
export const ShowDetails: QueryPage<{ slug: string; season: string }> = ({ slug, season }) => {
const { css, theme } = useYoshiki();
return (
<View {...css({ bg: theme.variant.background, flex: 1 })}>
<EpisodeList slug={slug} season={season} Header={ShowHeader} headerProps={{ slug }} />
</View>
);
};
ShowDetails.getFetchUrls = ({ slug, season }) => [
query(slug),
DetailsCollections.query("show", slug),
// ShowStaff.query(slug),
EpisodeList.query(slug, season),
SeasonHeader.query(slug),
];
ShowDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } };

View File

@ -1,55 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
// import { Person, PersonP, QueryIdentifier } from "@kyoo/models";
// import { useTranslation } from "react-i18next";
// import { InfiniteFetch } from "../fetch-infinite";
// import { PersonAvatar } from "./person";
// export const Staff = ({ slug }: { slug: string }) => {
// const { t } = useTranslation();
//
// return (
// <InfiniteFetch
// query={Staff.query(slug)}
// horizontal
// layout={{ numColumns: 1, size: PersonAvatar.width }}
// empty={t("show.staff-none")}
// >
// {(item, key) => (
// <PersonAvatar
// key={key}
// isLoading={item.isLoading}
// slug={item?.slug}
// name={item?.name}
// role={item?.type ? `${item?.type} (${item?.role})` : item?.role}
// poster={item?.poster}
// // sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }}
// />
// )}
// </InfiniteFetch>
// );
// };
//
// Staff.query = (slug: string): QueryIdentifier<Person> => ({
// parser: PersonP,
// path: ["show", slug, "people"],
// infinite: true,
// });