Add episodes on show page

This commit is contained in:
Zoe Roux 2022-09-06 04:21:03 +02:00
parent 608bc15e1f
commit 6cd976e057
13 changed files with 360 additions and 30 deletions

View File

@ -4,6 +4,7 @@
"trailer": "Play Trailer", "trailer": "Play Trailer",
"studio": "Studio", "studio": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "No genres" "genre-none": "No genres",
"staff": "Staff"
} }
} }

View File

@ -4,6 +4,7 @@
"trailer": "Jouer le trailer", "trailer": "Jouer le trailer",
"studio": "Studio", "studio": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "Aucun genres" "genre-none": "Aucun genres",
"staff": "Staff"
} }
} }

View File

@ -0,0 +1,70 @@
/*
* 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 { Box, Divider, Skeleton, SxProps, Typography } from "@mui/material";
import { Episode } from "~/models";
import { Link } from "~/utils/link";
import { Image } from "./poster";
const displayNumber = (episode: Episode) => {
if (episode.seasonNumber && episode.episodeNumber)
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return "???";
};
export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps }) => {
return (
<Box sx={sx}>
<Image img={episode?.thumbnail} width="100%" aspectRatio="16/9" />
<Typography>{episode?.name ?? <Skeleton />}</Typography>
<Typography variant="body2">{episode?.overview ?? <Skeleton />}</Typography>
</Box>
);
};
export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }) => {
return (
<>
<Link
href={episode ? `/watch/${episode.slug}` : ""}
color="inherit"
underline="none"
sx={{
m: 2,
display: "flex",
alignItems: "center",
"& > *": { m: 1 },
...sx,
}}
>
<Typography variant="overline" align="center" sx={{ width: "4rem", flexShrink: 0 }}>
{episode ? displayNumber(episode) : <Skeleton />}
</Typography>
<Image img={episode?.thumbnail} width="18%" aspectRatio="16/9" sx={{ flexShrink: 0 }} />
<Box>
<Typography variant="h6">{episode?.name ?? <Skeleton />}</Typography>
<Typography variant="body2">{episode?.overview ?? <Skeleton />}</Typography>
</Box>
</Link>
<Divider />
</>
);
};

View File

@ -18,10 +18,23 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Alert, Snackbar, SnackbarCloseReason } from "@mui/material"; import { Alert, Snackbar, SnackbarCloseReason, Typography } from "@mui/material";
import { SyntheticEvent, useState } from "react"; import { SyntheticEvent, useState } from "react";
import { KyooErrors } from "~/models"; import { KyooErrors } from "~/models";
export const ErrorPage = ({ errors }: { errors: string[] }) => {
return (
<>
<Typography variant="h1">Error</Typography>
{errors.map((x, i) => (
<Typography variant="h2" key={i}>
{x}
</Typography>
))}
</>
);
};
export const ErrorSnackbar = ({ error }: { error: KyooErrors }) => { export const ErrorSnackbar = ({ error }: { error: KyooErrors }) => {
const [isOpen, setOpen] = useState(true); const [isOpen, setOpen] = useState(true);
const close = (_: Event | SyntheticEvent, reason?: SnackbarCloseReason) => { const close = (_: Event | SyntheticEvent, reason?: SnackbarCloseReason) => {

View File

@ -38,7 +38,7 @@ import Image from "next/image";
import { ButtonLink } from "~/utils/link"; import { ButtonLink } from "~/utils/link";
import { LibraryP, Paged } from "~/models"; import { LibraryP, Paged } from "~/models";
import { useFetch } from "~/utils/query"; import { useFetch } from "~/utils/query";
import { ErrorSnackbar } from "./error-snackbar"; import { ErrorSnackbar } from "./errors";
export const KyooTitle = (props: { sx: SxProps<Theme> }) => { export const KyooTitle = (props: { sx: SxProps<Theme> }) => {
const { t } = useTranslation("common"); const { t } = useTranslation("common");

View File

@ -32,7 +32,7 @@ type ImageOptions = {
type ImageProps = { type ImageProps = {
img?: string | null; img?: string | null;
alt: string; alt?: string;
} & ImageOptions; } & ImageOptions;
type ImagePropsWithLoading = type ImagePropsWithLoading =

View File

@ -0,0 +1,67 @@
/*
* 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 { z } from "zod";
import { zdate } from "~/utils/zod";
import { ImagesP } from "../traits";
import { ResourceP } from "../traits/resource";
export const EpisodeP = z.preprocess(
(x: any) => {
x.name = x.title;
return x;
},
ResourceP.merge(ImagesP).extend({
/**
* The season in witch this episode is in.
*/
seasonNumber: z.number().nullable(),
/**
* The number of this episode in it's season.
*/
episodeNumber: z.number().nullable(),
/**
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
*/
absoluteNumber: z.number().nullable(),
/**
* The title of this episode.
*/
name: z.string(),
/**
* The overview of this episode.
*/
overview: z.string(),
/**
* The release date of this episode. It can be null if unknown.
*/
releaseDate: zdate(),
}),
);
/**
* A class to represent a single show's episode.
*/
export type Episode = z.infer<typeof EpisodeP>;

View File

@ -26,3 +26,5 @@ export * from "./collection";
export * from "./genre"; export * from "./genre";
export * from "./person"; export * from "./person";
export * from "./studio"; export * from "./studio";
export * from "./episode";
export * from "./season";

View File

@ -0,0 +1,61 @@
/*
* 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 { z } from "zod";
import { zdate } from "~/utils/zod";
import { ImagesP } from "../traits";
import { ResourceP } from "../traits/resource";
export const SeasonP = z.preprocess(
(x: any) => {
x.name = x.title;
return x;
},
ResourceP.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(),
/**
* The starting air date of this season.
*/
startDate: zdate().nullable(),
/**
* The ending date of this season.
*/
endDate: zdate().nullable(),
}),
);
/**
* A season of a Show.
*/
export type Season = z.infer<typeof SeasonP>;

View File

@ -22,6 +22,7 @@ import { z } from "zod";
import { zdate } from "~/utils/zod"; import { zdate } from "~/utils/zod";
import { ImagesP, ResourceP } from "../traits"; import { ImagesP, ResourceP } from "../traits";
import { GenreP } from "./genre"; import { GenreP } from "./genre";
import { SeasonP } from "./season";
import { StudioP } from "./studio"; import { StudioP } from "./studio";
/** /**
@ -72,6 +73,10 @@ export const ShowP = z.preprocess(
* The studio that made this show. * The studio that made this show.
*/ */
studio: StudioP.optional(), studio: StudioP.optional(),
/**
* The list of seasons of this show.
*/
seasons: z.array(SeasonP).optional(),
}), }),
); );

View File

@ -56,6 +56,21 @@ const App = ({ Component, pageProps }: AppProps) => {
body { body {
margin: 0px; margin: 0px;
padding: 0px; padding: 0px;
background-color: ${defaultTheme.palette.background.default};
}
*::-webkit-scrollbar {
height: 6px;
width: 6px;
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 90px;
}
*:hover::-webkit-scrollbar-thumb {
background-color: rgb(134, 127, 127);
} }
`}</style> `}</style>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@ -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 { LocalMovies, PlayArrow } from "@mui/icons-material"; import { ArrowLeft, ArrowRight, LocalMovies, PlayArrow } from "@mui/icons-material";
import { import {
alpha, alpha,
Box, Box,
@ -27,6 +27,8 @@ import {
IconButton, IconButton,
Skeleton, Skeleton,
SxProps, SxProps,
Tab,
Tabs,
Tooltip, Tooltip,
Typography, Typography,
useTheme, useTheme,
@ -35,7 +37,7 @@ import useTranslation from "next-translate/useTranslation";
import Head from "next/head"; import Head from "next/head";
import { Navbar } from "~/components/navbar"; import { Navbar } from "~/components/navbar";
import { Image, Poster } from "~/components/poster"; import { Image, Poster } from "~/components/poster";
import { Page, Show, ShowP } from "~/models"; import { Episode, EpisodeP, Page, Season, Show, ShowP } from "~/models";
import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query"; import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query";
import { getDisplayDate } from "~/models/utils"; import { getDisplayDate } from "~/models/utils";
import { useScroll } from "~/utils/hooks/use-scroll"; import { useScroll } from "~/utils/hooks/use-scroll";
@ -47,6 +49,9 @@ import { Studio } from "~/models/resources/studio";
import { Paged, Person, PersonP } from "~/models"; import { Paged, Person, PersonP } from "~/models";
import { PersonAvatar } from "~/components/person"; import { PersonAvatar } from "~/components/person";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import { ErrorPage } from "~/components/errors";
import { useState } from "react";
import { EpisodeBox, EpisodeLine } from "~/components/episode";
const StudioText = ({ const StudioText = ({
studio, studio,
@ -84,7 +89,7 @@ const ShowHeader = ({ data }: { data?: Show }) => {
<Navbar <Navbar
position="fixed" position="fixed"
elevation={0} elevation={0}
sx={{ backgroundColor: `rgba(0, 0, 0, ${0 /*0.4 + scroll / 1000*/})` }} sx={{ backgroundColor: `rgba(0, 0, 0, ${0.4 /*+ scroll / 1000*/})` }}
/> />
<Image <Image
img={data?.thumbnail} img={data?.thumbnail}
@ -260,18 +265,32 @@ const ShowStaff = ({ slug }: { slug: string }) => {
const { ref } = useInView({ const { ref } = useInView({
onChange: () => !isFetching && hasNextPage && fetchNextPage(), onChange: () => !isFetching && hasNextPage && fetchNextPage(),
}); });
const { t } = useTranslation("browse");
// TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch)
// TODO: Handle errors
/* if (isError) return null; */ /* if (isError) return null; */
return ( return (
<> <>
<Typography variant="h4" component="h2" sx={{ py: 3, pl: 4 }}> <Container sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between", py: 3 }}>
Staff <Typography variant="h4" component="h2">
</Typography> {t("show.staff")}
<Box sx={{ display: "flex", flexDirection: "row", maxWidth: "100%", overflowY: "auto" }}> </Typography>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(6)]).map((x) => ( <Box>
<IconButton>
<ArrowLeft />
</IconButton>
<IconButton>
<ArrowRight />
</IconButton>
</Box>
</Container>
<Box sx={{ display: "flex", flexDirection: "row", maxWidth: "100%", overflowY: "auto", py: 1, }}>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(6)]).map((x, i) => (
<PersonAvatar <PersonAvatar
key={x.id} key={x ? x.id : i}
person={x} person={x}
sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }} sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }}
/> />
@ -282,18 +301,72 @@ const ShowStaff = ({ slug }: { slug: string }) => {
); );
}; };
const episodesQuery = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
parser: EpisodeP,
path: ["shows", slug, "episode"],
params: {
seasonNumber: season,
},
infinite: true,
});
const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
const { data, isError, error, isFetching, hasNextPage, fetchNextPage } = useInfiniteFetch(
episodesQuery(slug, season),
);
const { ref } = useInView({
onChange: () => !isFetching && hasNextPage && fetchNextPage(),
});
// TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch)
// TODO: Handle errors
/* if (isError) return null; */
return (
<Box sx={{ display: "flex", flexDirection: "column", backgroundColor: "background.paper" }}>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(12)]).map((x, i) => (
<EpisodeLine key={x ? x.id : i} episode={x} />
))}
{/* <div ref={ref} /> */}
</Box>
);
};
const SeasonTab = ({ slug, seasons, sx }: { slug: string; seasons?: Season[]; sx?: SxProps }) => {
const [season, setSeason] = useState(1);
// TODO: handle absolute number only shows (without seasons)
return (
<Container sx={sx}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons">
{seasons
? seasons.map((x) => <Tab key={x.seasonNumber} label={x.name} value={x.seasonNumber} />)
: [...Array(3)].map((_, i) => (
<Typography key={i} variant="button">
<Skeleton />
</Typography>
))}
</Tabs>
<EpisodeGrid slug={slug} season={season} />
</Box>
</Container>
);
};
const query = (slug: string): QueryIdentifier<Show> => ({ const query = (slug: string): QueryIdentifier<Show> => ({
parser: ShowP, parser: ShowP,
path: ["shows", slug], path: ["shows", slug],
params: { params: {
fields: ["genres", "studio"], fields: ["genres", "studio", "seasons"],
}, },
}); });
const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => { const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug)); const { data, error } = useFetch(query(slug));
if (error) return <p>oups</p>; if (error) return <ErrorPage {...error} />;
return ( return (
<> <>
@ -303,10 +376,15 @@ const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
</Head> </Head>
<ShowHeader data={data} /> <ShowHeader data={data} />
<ShowStaff slug={slug} /> <ShowStaff slug={slug} />
<SeasonTab slug={slug} seasons={data?.seasons} sx={{ pt: 3 }} />
</> </>
); );
}; };
ShowDetails.getFetchUrls = ({ slug }) => [query(slug), staffQuery(slug)]; ShowDetails.getFetchUrls = ({ slug, seasonNumber = 1 }) => [
query(slug),
staffQuery(slug),
episodesQuery(slug, seasonNumber),
];
export default withRoute(ShowDetails); export default withRoute(ShowDetails);

View File

@ -34,8 +34,9 @@ const queryFn = async <Data>(
type: z.ZodType<Data>, type: z.ZodType<Data>,
context: QueryFunctionContext, context: QueryFunctionContext,
): Promise<Data> => { ): Promise<Data> => {
let resp;
try { try {
const resp = await fetch( resp = await fetch(
[typeof window === "undefined" ? process.env.KYOO_URL : "/api"] [typeof window === "undefined" ? process.env.KYOO_URL : "/api"]
.concat( .concat(
context.pageParam ? [context.pageParam] : (context.queryKey.filter((x) => x) as string[]), context.pageParam ? [context.pageParam] : (context.queryKey.filter((x) => x) as string[]),
@ -43,21 +44,37 @@ const queryFn = async <Data>(
.join("/") .join("/")
.replace("/?", "?"), .replace("/?", "?"),
); );
if (!resp.ok) {
throw await resp.json();
}
const data = await resp.json();
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
console.log("Parse error: ", parsed.error);
throw { errors: parsed.error.errors.map((x) => x.message) } as KyooErrors;
}
return parsed.data;
} catch (e) { } catch (e) {
console.error("Fetch error: ", e); console.log("Fetch error", e);
throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors; throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors;
} }
if (resp.status === 404) {
throw { errors: ["Resource not found."] } as KyooErrors;
}
if (!resp.ok) {
const error = await resp.text();
let data;
try {
data = JSON.parse(error);
} catch (e) {
data = { errors: [error] };
}
throw data;
}
let data;
try {
data = await resp.json();
} catch (e) {
console.error("Invald json from kyoo", e);
throw { errors: ["Invalid repsonse from kyoo"] };
}
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
console.log("Parse error: ", parsed.error);
throw { errors: parsed.error.errors.map((x) => x.message) } as KyooErrors;
}
return parsed.data;
}; };
export const createQueryClient = () => export const createQueryClient = () =>