Remake details page for movies

This commit is contained in:
Zoe Roux 2025-06-23 11:52:02 +02:00
parent 26410734fc
commit c7103410dc
No known key found for this signature in database
33 changed files with 762 additions and 780 deletions

View File

@ -10,6 +10,6 @@ pkgs.mkShell {
postgresql_15
pgformatter
# to run tests
hurl
# hurl
];
}

View File

@ -0,0 +1,3 @@
import { MovieDetails } from "~/ui/details";
export default MovieDetails;

View File

@ -41,8 +41,8 @@ import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid";
import { Header as ShowHeader, TitleLine } from "../details/header";
import { SvgWave } from "../details/show";
import { Header as ShowHeader, TitleLine } from "../../../../src/ui/details/headeri/details/header";
import { SvgWave } from "../../../../src/ui/details/show/ui/details/show";
import { Fetch } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite";
import { ItemDetails } from "../home/recommended";

View File

@ -1,39 +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 Breakpoint, Icon, P, Skeleton, ts } from "@kyoo/primitives";
import Star from "@material-symbols/svg-400/rounded/star-fill.svg";
import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
export const Rating = ({ rating, color }: { rating?: number; color: Breakpoint<string> }) => {
const { css } = useYoshiki();
return (
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
<Skeleton {...css({ width: rem(2) })}>
{rating !== undefined && (
<P {...css({ color, verticalAlign: "middle" })}>{rating ? rating / 10 : "??"} / 10</P>
)}
</Skeleton>
</View>
);
};

View File

@ -1,590 +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 Genre,
type KyooImage,
type Movie,
type QueryIdentifier,
type Show,
type Studio,
getDisplayDate,
queryFn,
useAccount,
} from "@kyoo/models";
import type { WatchStatusV } from "@kyoo/models/src/resources/watch-status";
import {
A,
Chip,
Container,
DottedSeparator,
GradientImageBackground,
H1,
H2,
HR,
Head,
IconButton,
IconFab,
LI,
Link,
Menu,
P,
Poster,
Skeleton,
UL,
capitalize,
tooltip,
ts,
usePopup,
} from "@kyoo/primitives";
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
import Download from "@material-symbols/svg-400/rounded/download.svg";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import { useMutation } from "@tanstack/react-query";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { type ImageStyle, Platform, View } from "react-native";
import {
type Stylable,
type Theme,
em,
max,
md,
min,
percent,
px,
rem,
useYoshiki,
vh,
} from "yoshiki/native";
import { MediaInfoPopup } from "../components/media-info";
import { Rating } from "../components/rating";
import { WatchListInfo } from "../components/watchlist-info";
import { useDownloader } from "../downloads";
import { Fetch } from "../fetch";
import { displayRuntime } from "./episode";
import { ShowWatchStatusCard } from "./show";
const ButtonList = ({
playHref,
trailerUrl,
watchStatus,
type,
slug,
}: {
type: "movie" | "show" | "collection";
slug?: string;
playHref?: string | null;
trailerUrl?: string | null;
watchStatus?: WatchStatusV | null;
}) => {
const account = useAccount();
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const downloader = useDownloader();
const [setPopup, close] = usePopup();
const metadataRefreshMutation = useMutation({
mutationFn: () =>
queryFn({
path: [type, slug, "refresh"],
method: "POST",
}),
});
return (
<View {...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}>
{playHref !== null && (
<IconFab
icon={PlayArrow}
as={Link}
href={playHref}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))}
/>
)}
{trailerUrl && (
<IconButton
icon={Theaters}
as={Link}
href={trailerUrl}
target="_blank"
color={{ xs: theme.user.contrast, md: theme.colors.white }}
{...tooltip(t("show.trailer"))}
/>
)}
{watchStatus !== undefined && type !== "collection" && slug && (
<WatchListInfo
type={type}
slug={slug}
status={watchStatus}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
)}
{((type === "movie" && slug) || account?.isAdmin === true) && (
<Menu Trigger={IconButton} icon={MoreHoriz} {...tooltip(t("misc.more"))}>
{type === "movie" && slug && (
<>
<Menu.Item
icon={Download}
onSelect={() => downloader(type, slug)}
label={t("home.episodeMore.download")}
/>
<Menu.Item
icon={MovieInfo}
label={t("home.episodeMore.mediainfo")}
onSelect={() =>
setPopup(<MediaInfoPopup mediaType={"movie"} mediaSlug={slug!} close={close} />)
}
/>
</>
)}
{account?.isAdmin === true && (
<>
{type === "movie" && <HR />}
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu>
)}
</View>
);
};
export const TitleLine = ({
isLoading,
playHref,
name,
tagline,
date,
rating,
runtime,
poster,
studio,
trailerUrl,
type,
watchStatus,
slug,
...props
}: {
isLoading: boolean;
playHref?: string | null;
name?: string;
tagline?: string | null;
date?: string | null;
rating?: number | null;
runtime?: number | null;
poster?: KyooImage | null;
studio?: Studio | null;
trailerUrl?: string | null;
watchStatus?: WatchStatusV | null;
type: "movie" | "show" | "collection";
slug?: string;
} & Stylable) => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
return (
<Container
{...css(
{
flexDirection: { xs: "column", md: "row" },
},
props,
)}
>
<View
{...css({
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "flex-start" },
flexGrow: 1,
maxWidth: percent(100),
})}
>
<Poster
src={poster}
alt={name}
quality="medium"
forcedLoading={isLoading}
layout={{
width: { xs: percent(50), md: percent(25) },
}}
{...(css({
maxWidth: { xs: px(175), sm: Platform.OS === "web" ? ("unset" as any) : 99999999 },
flexShrink: 0,
}) as { style: ImageStyle })}
/>
<View
{...css({
alignSelf: { xs: "center", sm: "flex-end", md: "center" },
alignItems: { xs: "center", sm: "flex-start" },
paddingLeft: { sm: em(2.5) },
flexShrink: 1,
flexGrow: 1,
})}
>
<P
{...css({
textAlign: { xs: "center", sm: "left" },
})}
>
<Skeleton
variant="header"
{...css({ width: rem(15), height: rem(2.5), marginBottom: rem(1) })}
>
{isLoading || (
<>
<H1
{...css({
color: (theme: Theme) => ({ xs: theme.user.heading, md: theme.heading }),
})}
>
{name}
</H1>
{date && (
<P
{...css({
fontSize: rem(2.5),
color: (theme: Theme) => ({
xs: theme.user.paragraph,
md: theme.paragraph,
}),
})}
>
{" "}
({date})
</P>
)}
</>
)}
</Skeleton>
</P>
{(isLoading || tagline) && (
<Skeleton
{...css({
width: rem(5),
height: rem(1.5),
marginBottom: rem(0.5),
})}
>
{isLoading || (
<P
{...css({
fontWeight: "300",
fontSize: rem(1.5),
marginTop: 0,
letterSpacing: 0,
textAlign: { xs: "center", sm: "left" },
color: (theme: Theme) => ({ xs: theme.user.heading, md: theme.heading }),
})}
>
{tagline}
</P>
)}
</Skeleton>
)}
<View
{...css({
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
})}
>
<ButtonList
type={type}
slug={slug}
playHref={playHref}
trailerUrl={trailerUrl}
watchStatus={watchStatus}
/>
<View
{...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}
>
{rating !== null && rating !== 0 && (
<>
<DottedSeparator
{...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}
/>
<Rating
rating={rating}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
</>
)}
{runtime && (
<>
<DottedSeparator
{...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}
/>
<P {...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}>
{displayRuntime(runtime)}
</P>
</>
)}
</View>
</View>
</View>
</View>
<View
{...css([
{
paddingTop: { xs: ts(3), sm: ts(8) },
alignSelf: { xs: "flex-start", md: "flex-end" },
justifyContent: "flex-end",
flexDirection: "column",
},
md({
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: percent(25),
height: percent(100),
paddingRight: ts(3),
}),
])}
>
{isLoading ||
(studio && (
<P
{...css({
color: (theme: Theme) => theme.user.paragraph,
display: "flex",
})}
>
{t("show.studio")}:{" "}
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/studio/${studio.slug}`} {...css({ color: (theme) => theme.user.link })}>
{studio.name}
</A>
)}
</P>
))}
</View>
</Container>
);
};
const Description = ({
isLoading,
overview,
tags,
genres,
...props
}: {
isLoading: boolean;
overview?: string | null;
tags?: string[];
genres?: Genre[];
} & Stylable) => {
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<Container
{...css({ paddingBottom: ts(1), flexDirection: { xs: "column", sm: "row" } }, props)}
>
<P
{...css({
display: { xs: "flex", sm: "none" },
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
})}
>
{t("show.genre")}:{" "}
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
<Fragment key={genre ?? i.toString()}>
<P {...css({ m: 0 })}>{i !== 0 && ", "}</P>
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/genres/${genre.toLowerCase()}`}>{t(`genres.${genre}`)}</A>
)}
</Fragment>
))}
</P>
<View
{...css({
flexDirection: "column",
flexGrow: 1,
flexBasis: { sm: 0 },
paddingTop: ts(4),
})}
>
<Skeleton lines={4}>
{isLoading || (
<P {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</P>
)}
</Skeleton>
<View
{...css({
flexWrap: "wrap",
flexDirection: "row",
alignItems: "center",
marginTop: ts(0.5),
})}
>
<P {...css({ marginRight: ts(0.5) })}>{t("show.tags")}:</P>
{(isLoading ? [...Array<string>(3)] : tags!).map((tag, i) => (
<Chip
key={tag ?? i}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
{...css({ m: ts(0.5) })}
/>
))}
</View>
</View>
<HR
orientation="vertical"
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
/>
<View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}>
<H2>{t("show.genre")}</H2>
{isLoading || genres?.length ? (
<UL>
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
<LI key={genre ?? i}>
{isLoading ? (
<Skeleton {...css({ marginBottom: 0 })} />
) : (
<A href={`/genres/${genre.toLowerCase()}`}>{t(`genres.${genre}`)}</A>
)}
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
</View>
</Container>
);
};
export const Header = ({
query,
type,
}: {
query: QueryIdentifier<Show | Movie>;
type: "movie" | "show";
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<Fetch query={query}>
{({ isLoading, ...data }) => (
<>
<Head title={data?.name} description={data?.overview} image={data?.thumbnail?.high} />
<GradientImageBackground
src={data?.thumbnail}
quality="high"
alt=""
containerStyle={Header.containerStyle}
>
<TitleLine
isLoading={isLoading}
type={type}
slug={data?.slug}
playHref={data?.playHref}
name={data?.name}
tagline={data?.tagline}
date={data ? getDisplayDate(data as any) : undefined}
rating={data?.rating}
runtime={"runtime" in data ? data.runtime : null}
poster={data?.poster}
trailerUrl={data?.trailer}
studio={data?.studio}
watchStatus={data?.watchStatus?.status ?? null}
{...css(Header.childStyle)}
/>
</GradientImageBackground>
<Description
isLoading={isLoading}
overview={data?.overview}
genres={data?.genres}
tags={data?.tags}
{...css({ paddingTop: { xs: 0, md: ts(2) } })}
/>
<Container
{...css({
flexWrap: "wrap",
flexDirection: "row",
alignItems: "center",
marginTop: ts(0.5),
})}
>
<P {...css({ marginRight: ts(0.5), textAlign: "center" })}>{t("show.links")}:</P>
{(!isLoading
? Object.entries(data.externalId!).filter(([_, data]) => data.link)
: [...Array(3)].map((_) => [undefined, undefined] as const)
).map(([name, data], i) => (
<Chip
key={name ?? i}
label={name}
href={data?.link || undefined}
target="_blank"
size="small"
outline
{...css({ m: ts(0.5) })}
/>
))}
</Container>
{type === "show" && <ShowWatchStatusCard {...(data?.watchStatus as any)} />}
</>
)}
</Fetch>
);
};
Header.containerStyle = {
height: {
xs: vh(40),
sm: min(vh(60), px(750)),
md: min(vh(60), px(680)),
lg: vh(70),
},
minHeight: { xs: px(350), sm: px(300), md: px(400), lg: px(600) },
};
Header.childStyle = {
marginTop: {
xs: max(vh(20), px(200)),
sm: vh(45),
md: max(vh(30), px(150)),
lg: max(vh(35), px(200)),
},
};

View File

@ -1,22 +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/>.
*/
export { MovieDetails } from "./movie";
export { ShowDetails } from "./show";

View File

@ -1,68 +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 Movie, MovieP, type QueryIdentifier, type QueryPage } from "@kyoo/models";
import { usePageStyle } from "@kyoo/primitives";
import { Platform, ScrollView } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
import { DetailsCollections } from "./collection";
import { Header } from "./header";
const query = (slug: string): QueryIdentifier<Movie> => ({
parser: MovieP,
path: ["movie", slug],
params: {
fields: ["studio", "watchStatus"],
},
});
export const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => {
const { css } = useYoshiki();
const pageStyle = usePageStyle();
return (
<ScrollView
{...css([
Platform.OS === "web" && {
// @ts-ignore Web only property
overflow: "auto" as any,
// @ts-ignore Web only property
overflowX: "hidden",
// @ts-ignore Web only property
overflowY: "overlay",
},
pageStyle,
])}
>
<Header type="movie" query={query(slug)} />
<DetailsCollections type="movie" slug={slug} />
{/* <Staff slug={slug} /> */}
</ScrollView>
);
};
MovieDetails.getFetchUrls = ({ slug }) => [
query(slug),
DetailsCollections.query("movie", slug),
// ShowStaff.query(slug),
];
MovieDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } };

View File

@ -42,7 +42,7 @@ import { type Atom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { percent, useYoshiki } from "yoshiki/native";
import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../details/episode";
import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { EmptyView } from "../fetch";
import { type State, downloadAtom } from "./state";

View File

@ -37,7 +37,7 @@ 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 { Header as DetailsHeader } from "../details/header";
import { Header as DetailsHeader } from "../../../../src/ui/details/header";
import type { WithLoading } from "../fetch";
export const Header = ({

View File

@ -22,7 +22,7 @@ import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/mo
import { useTranslation } from "react-i18next";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid";
import { EpisodeBox, episodeDisplayNumber } from "../details/episode";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { InfiniteFetch } from "../fetch-infinite";
import { Header } from "./genre";

View File

@ -30,7 +30,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid";
import { EpisodeBox, episodeDisplayNumber } from "../details/episode";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { InfiniteFetch } from "../fetch-infinite";
import { Header } from "./genre";

View File

@ -35,7 +35,7 @@ import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useRouter } from "solito/router";
import { useYoshiki } from "yoshiki/native";
import { episodeDisplayNumber } from "../details/episode";
import { episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { ErrorView } from "../../../../src/ui/errors";
import { Back, Hover, LoadingIndicator } from "./components/hover";
import { useVideoKeyboard } from "./keyboard";

View File

@ -26,12 +26,12 @@ export const watchListIcon = (status: WatchStatus | null) => {
};
export const WatchListInfo = ({
type,
kind,
slug,
status,
...props
}: {
type: "movie" | "show" | "episode";
kind: "movie" | "serie" | "episode";
slug: string;
status: WatchStatus | null;
color: ComponentProps<typeof IconButton>["color"];
@ -40,12 +40,12 @@ export const WatchListInfo = ({
const { t } = useTranslation();
const mutation = useMutation({
path: [type, slug, "watchStatus"],
path: [kind, slug, "watchStatus"],
compute: (newStatus: WatchStatus | null) => ({
method: newStatus ? "POST" : "DELETE",
params: newStatus ? { status: newStatus } : undefined,
}),
invalidate: [type, slug],
invalidate: [kind, slug],
});
if (mutation.isPending) status = mutation.variables;

View File

@ -0,0 +1,34 @@
import Star from "@material-symbols/svg-400/rounded/star-fill.svg";
import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native";
import { type Breakpoint, Icon, P, Skeleton, ts } from "~/primitives";
export const Rating = ({
rating,
color,
}: {
rating: number | null;
color: Breakpoint<string>;
}) => {
const { css } = useYoshiki();
return (
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
<P {...css({ color, verticalAlign: "middle" })}>
{rating ? rating / 10 : "??"} / 10
</P>
</View>
);
};
Rating.Loader = ({ color }: { color: Breakpoint<string> }) => {
const { css } = useYoshiki();
return (
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
<Skeleton {...css({ width: rem(2) })} />
</View>
);
};

View File

@ -12,3 +12,4 @@ export * from "./video";
export * from "./user";
export * from "./utils/images";
export * from "./utils/genre";

View File

@ -31,7 +31,7 @@ export const Movie = z
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
logo: KImage.nullable(),
trailerUrl: z.string().optional().nullable(),
trailerUrl: z.string().nullable(),
isAvailable: z.boolean(),
@ -42,7 +42,13 @@ export const Movie = z
videos: z.array(EmbeddedVideo).optional(),
watchStatus: z
.object({
status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]),
status: z.enum([
"completed",
"watching",
"rewatching",
"dropped",
"planned",
]),
score: z.number().int().gte(0).lte(100).nullable(),
completedAt: zdate().nullable(),
percent: z.number().int().gte(0).lte(100),
@ -52,5 +58,6 @@ export const Movie = z
.transform((x) => ({
...x,
href: `/movies/${x.slug}`,
playHref: `/watch/${x.slug}`,
}));
export type Movie = z.infer<typeof Movie>;

View File

@ -32,7 +32,7 @@ export const Serie = z
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
logo: KImage.nullable(),
trailerUrl: z.string().optional().nullable(),
trailerUrl: z.string().nullable(),
entriesCount: z.number().int(),
availableCount: z.number().int(),
@ -45,7 +45,13 @@ export const Serie = z
nextEntry: Entry.optional().nullable(),
watchStatus: z
.object({
status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]),
status: z.enum([
"completed",
"watching",
"rewatching",
"dropped",
"planned",
]),
score: z.number().int().gte(0).lte(100).nullable(),
startedAt: zdate().nullable(),
completedAt: zdate().nullable(),
@ -53,10 +59,13 @@ export const Serie = z
})
.nullable(),
})
.transform((x) => ({
.transform((x) => {
const entry = x.nextEntry ?? x.firstEntry;
return {
...x,
href: `/series/${x.slug}`,
playHref: x.firstEntry ? `/watch/${x.firstEntry.slug}` : null,
}));
playHref: entry ? `/watch/${entry.slug}` : null,
};
});
export type Serie = z.infer<typeof Serie>;

View File

@ -54,5 +54,5 @@ export const Account = User.and(
token: z.string(),
selected: z.boolean(),
}),
);
).transform((x) => ({ ...x, isAdmin: true }));
export type Account = z.infer<typeof Account>;

View File

@ -25,3 +25,4 @@ export const Genre = z.enum([
"soap",
"talk",
]);
export type Genre = z.infer<typeof Genre>;

View File

@ -1,9 +1,14 @@
import type React from "react";
import { type ComponentProps, type ComponentType, type ForwardedRef, forwardRef } from "react";
import {
type ComponentProps,
type ComponentType,
type ForwardedRef,
forwardRef,
} from "react";
import { Platform, type PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg";
import type { YoshikiStyle } from "yoshiki";
import { type Stylable, type Theme, px, useYoshiki } from "yoshiki/native";
import { px, type Stylable, type Theme, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { type Breakpoint, focusReset, ts } from "./utils";
@ -25,7 +30,12 @@ type IconProps = {
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki();
const computed = css(
{ width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
{
width: size,
height: size,
fill: color ?? theme.contrast,
flexShrink: 0,
} as any,
props,
) as any;
@ -44,7 +54,9 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
);
};
export const IconButton = forwardRef(function IconButton<AsProps = PressableProps>(
export const IconButton = forwardRef(function IconButton<
AsProps = PressableProps,
>(
{
icon,
size,
@ -84,7 +96,9 @@ export const IconButton = forwardRef(function IconButton<AsProps = PressableProp
<Icon
icon={icon}
size={size}
color={"disabled" in asProps && asProps.disabled ? theme.overlay1 : color}
color={
"disabled" in asProps && asProps.disabled ? theme.overlay1 : color
}
/>
</Container>
);
@ -114,7 +128,7 @@ export const IconFab = <AsProps = PressableProps>(
);
};
export const DottedSeparator = (props: Stylable) => {
export const DottedSeparator = (props: Stylable<"text">) => {
const { css } = useYoshiki();
return <P {...css({ mX: ts(1) }, props)}>{String.fromCharCode(0x2022)}</P>;
};

View File

@ -66,13 +66,17 @@ Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
export const Poster = ({
layout,
...props
}: ComponentProps<typeof Image> & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}: Omit<ComponentProps<typeof Image>, "layout"> & {
layout: YoshikiEnhanced<
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
>;
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
Poster.Loader = ({
layout,
...props
}: {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
layout: YoshikiEnhanced<
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;

View File

@ -1,3 +1,5 @@
import EHead from "expo-router/head";
export const Head = ({
title,
description,
@ -7,5 +9,11 @@ export const Head = ({
description?: string | null;
image?: string | null;
}) => {
return null;
return (
<EHead>
{title && <title>{`${title} - Kyoo`}</title>}
{description && <meta name="description" content={description} />}
{image && <meta property="og:image" content={image} />}
</EHead>
);
};

View File

@ -1,19 +0,0 @@
// import NextHead from "next/head";
export const Head = ({
title,
description,
image,
}: {
title?: string | null;
description?: string | null;
image?: string | null;
}) => {
return (
<NextHead>
{title && <title>{`${title} - Kyoo`}</title>}
{description && <meta name="description" content={description} />}
{image && <meta property="og:image" content={image} />}
</NextHead>
);
};

View File

@ -37,7 +37,7 @@ import {
} from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { type Theme, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../../../../src/ui/errors";
import { ErrorView } from "../errors";
export const PartOf = ({
name,

View File

@ -56,12 +56,6 @@ export const episodeDisplayNumber = (episode: {
return "??";
};
export const displayRuntime = (runtime: number | null) => {
if (!runtime) return null;
if (runtime < 60) return `${runtime}min`;
return `${Math.floor(runtime / 60)}h${runtime % 60}`;
};
export const EpisodeBox = ({
slug,
showSlug,

View File

@ -0,0 +1,605 @@
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
import Download from "@material-symbols/svg-400/rounded/download.svg";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { type ImageStyle, Platform, View } from "react-native";
import {
em,
max,
md,
min,
percent,
px,
rem,
type Stylable,
type Theme,
useYoshiki,
vh,
} from "yoshiki/native";
import { WatchListInfo } from "~/components/items/watchlist-info";
import { Rating } from "~/components/rating";
import {
type Genre,
type KImage,
Movie,
Serie,
Show,
type Studio,
type WatchStatusV,
} from "~/models";
import {
A,
Chip,
Container,
capitalize,
DottedSeparator,
GradientImageBackground,
H1,
H2,
Head,
HR,
IconButton,
IconFab,
LI,
Link,
Menu,
P,
Poster,
Skeleton,
tooltip,
ts,
UL,
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { Fetch, type QueryIdentifier, useMutation } from "~/query";
import { displayRuntime, getDisplayDate } from "~/utils";
const ButtonList = ({
kind,
slug,
playHref,
trailerUrl,
watchStatus,
}: {
kind: "movie" | "serie" | "collection";
slug: string;
playHref: string | null;
trailerUrl: string | null;
watchStatus: WatchStatusV | null;
}) => {
const account = useAccount();
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const metadataRefreshMutation = useMutation({
method: "POST",
path: [kind, slug, "refresh"],
invalidate: null,
});
return (
<View
{...css({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
})}
>
{playHref !== null && (
<IconFab
icon={PlayArrow}
as={Link}
href={playHref}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))}
/>
)}
{trailerUrl && (
<IconButton
icon={Theaters}
as={Link}
href={trailerUrl}
target="_blank"
color={{ xs: theme.user.contrast, md: theme.colors.white }}
{...tooltip(t("show.trailer"))}
/>
)}
{kind !== "collection" && (
<WatchListInfo
kind={kind}
slug={slug}
status={watchStatus}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
)}
{(kind === "movie" || account?.isAdmin === true) && (
<Menu
Trigger={IconButton}
icon={MoreHoriz}
{...tooltip(t("misc.more"))}
>
{kind === "movie" && (
<>
{/* <Menu.Item */}
{/* icon={Download} */}
{/* onSelect={() => downloader(kind, slug)} */}
{/* label={t("home.episodeMore.download")} */}
{/* /> */}
<Menu.Item
label={t("home.episodeMore.mediainfo")}
icon={MovieInfo}
href={`/${kind}/${slug}/info`}
/>
</>
)}
{account?.isAdmin === true && (
<>
{kind === "movie" && <HR />}
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu>
)}
</View>
);
};
export const TitleLine = ({
kind,
slug,
playHref,
name,
tagline,
date,
rating,
runtime,
poster,
trailerUrl,
// studio,
watchStatus,
...props
}: {
kind: "movie" | "serie" | "collection";
slug: string;
playHref: string | null;
name: string;
tagline: string | null;
date: string | null;
rating: number | null;
runtime: number | null;
poster: KImage | null;
trailerUrl: string | null;
// studio: Studio;
watchStatus: WatchStatusV | null;
} & Stylable) => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
return (
<Container
{...css(
{
flexDirection: { xs: "column", md: "row" },
},
props,
)}
>
<View
{...css({
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "flex-start" },
flexGrow: 1,
maxWidth: percent(100),
})}
>
<Poster
src={poster}
alt={name}
quality="medium"
layout={{
width: { xs: percent(50), md: percent(25) },
}}
{...(css({
maxWidth: {
xs: px(175),
sm: Platform.OS === "web" ? ("unset" as any) : 99999999,
},
flexShrink: 0,
}) as { style: ImageStyle })}
/>
<View
{...css({
alignSelf: { xs: "center", sm: "flex-end", md: "center" },
alignItems: { xs: "center", sm: "flex-start" },
paddingLeft: { sm: em(2.5) },
flexShrink: 1,
flexGrow: 1,
})}
>
<P
{...css({
textAlign: { xs: "center", sm: "left" },
})}
>
<H1
{...css({
color: (theme: Theme) => ({
xs: theme.user.heading,
md: theme.heading,
}),
})}
>
{name}
</H1>
{date && (
<P
{...css({
fontSize: rem(2.5),
color: (theme: Theme) => ({
xs: theme.user.paragraph,
md: theme.paragraph,
}),
})}
>
{" "}
({date})
</P>
)}
</P>
{tagline && (
<P
{...css({
fontWeight: "300",
fontSize: rem(1.5),
marginTop: 0,
letterSpacing: 0,
textAlign: { xs: "center", sm: "left" },
color: (theme: Theme) => ({
xs: theme.user.heading,
md: theme.heading,
}),
})}
>
{tagline}
</P>
)}
<View
{...css({
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
})}
>
<ButtonList
kind={kind}
slug={slug}
playHref={playHref}
trailerUrl={trailerUrl}
watchStatus={watchStatus}
/>
<View
{...css({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
})}
>
{rating !== null && rating !== 0 && (
<>
<DottedSeparator
{...css({
color: {
xs: theme.user.contrast,
md: theme.colors.white,
},
})}
/>
<Rating
rating={rating}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
</>
)}
{runtime && (
<>
<DottedSeparator
{...css({
color: {
xs: theme.user.contrast,
md: theme.colors.white,
},
})}
/>
<P
{...css({
color: {
xs: theme.user.contrast,
md: theme.colors.white,
},
})}
>
{displayRuntime(runtime)}
</P>
</>
)}
</View>
</View>
</View>
</View>
{/* <View */}
{/* {...css([ */}
{/* { */}
{/* paddingTop: { xs: ts(3), sm: ts(8) }, */}
{/* alignSelf: { xs: "flex-start", md: "flex-end" }, */}
{/* justifyContent: "flex-end", */}
{/* flexDirection: "column", */}
{/* }, */}
{/* md({ */}
{/* position: "absolute", */}
{/* top: 0, */}
{/* bottom: 0, */}
{/* right: 0, */}
{/* width: percent(25), */}
{/* height: percent(100), */}
{/* paddingRight: ts(3), */}
{/* }) as any, */}
{/* ])} */}
{/* > */}
{/* {studio && ( */}
{/* <P */}
{/* {...css({ */}
{/* color: (theme: Theme) => theme.user.paragraph, */}
{/* display: "flex", */}
{/* })} */}
{/* > */}
{/* {t("show.studio")}:{" "} */}
{/* <A */}
{/* href={`/studio/${studio.slug}`} */}
{/* {...css({ color: (theme) => theme.user.link })} */}
{/* > */}
{/* {studio.name} */}
{/* </A> */}
{/* </P> */}
{/* )} */}
{/* </View> */}
</Container>
);
};
const Description = ({
isLoading,
overview,
tags,
genres,
...props
}: {
isLoading: boolean;
overview?: string | null;
tags?: string[];
genres?: Genre[];
} & Stylable) => {
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<Container
{...css(
{ paddingBottom: ts(1), flexDirection: { xs: "column", sm: "row" } },
props,
)}
>
<P
{...css({
display: { xs: "flex", sm: "none" },
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
})}
>
{t("show.genre")}:{" "}
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
<Fragment key={genre ?? i.toString()}>
<P {...css({ m: 0 })}>{i !== 0 && ", "}</P>
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/genres/${genre.toLowerCase()}`}>
{t(`genres.${genre}`)}
</A>
)}
</Fragment>
))}
</P>
<View
{...css({
flexDirection: "column",
flexGrow: 1,
flexBasis: { sm: 0 },
paddingTop: ts(4),
})}
>
<Skeleton lines={4}>
{isLoading || (
<P {...css({ textAlign: "justify" })}>
{overview ?? t("show.noOverview")}
</P>
)}
</Skeleton>
<View
{...css({
flexWrap: "wrap",
flexDirection: "row",
alignItems: "center",
marginTop: ts(0.5),
})}
>
<P {...css({ marginRight: ts(0.5) })}>{t("show.tags")}:</P>
{(isLoading ? [...Array<string>(3)] : tags!).map((tag, i) => (
<Chip
key={tag ?? i}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
{...css({ m: ts(0.5) })}
/>
))}
</View>
</View>
<HR
orientation="vertical"
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
/>
<View
{...css({
flexBasis: percent(25),
display: { xs: "none", sm: "flex" },
})}
>
<H2>{t("show.genre")}</H2>
{isLoading || genres?.length ? (
<UL>
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
<LI key={genre ?? i}>
{isLoading ? (
<Skeleton {...css({ marginBottom: 0 })} />
) : (
<A href={`/genres/${genre.toLowerCase()}`}>
{t(`genres.${genre}`)}
</A>
)}
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
</View>
</Container>
);
};
export const Header = ({
kind,
slug,
}: {
kind: "movie" | "serie";
slug: string;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<Fetch
query={Header.query(kind, slug)}
Loader={() => <p>loading</p>}
Render={(data) => (
<>
<Head
title={data.name}
description={data.description}
image={data.thumbnail?.high}
/>
<GradientImageBackground
src={data.thumbnail}
quality="high"
alt=""
layout={{
width: percent(100),
height: {
xs: vh(40),
sm: min(vh(60), px(750)),
md: min(vh(60), px(680)),
lg: vh(70),
},
}}
{...(css({
minHeight: { xs: px(350), sm: px(300), md: px(400), lg: px(600) },
}) as any)}
>
<TitleLine
kind={kind}
slug={slug}
name={data.name}
tagline={data.tagline}
date={getDisplayDate(data)}
rating={data.rating}
runtime={data.kind === "movie" ? data.runtime : null}
poster={data.poster}
// studio={data.studio}
playHref={data.kind !== "collection" ? data.playHref : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
watchStatus={data.kind !== "collection" ? data.watchStatus?.status! : null}
{...css({
marginTop: {
xs: max(vh(20), px(200)),
sm: vh(45),
md: max(vh(30), px(150)),
lg: max(vh(35), px(200)),
},
})}
/>
</GradientImageBackground>
{/* <Description */}
{/* isLoading={isLoading} */}
{/* overview={data?.overview} */}
{/* genres={data?.genres} */}
{/* tags={data?.tags} */}
{/* {...css({ paddingTop: { xs: 0, md: ts(2) } })} */}
{/* /> */}
{/* <Container */}
{/* {...css({ */}
{/* flexWrap: "wrap", */}
{/* flexDirection: "row", */}
{/* alignItems: "center", */}
{/* marginTop: ts(0.5), */}
{/* })} */}
{/* > */}
{/* <P {...css({ marginRight: ts(0.5), textAlign: "center" })}> */}
{/* {t("show.links")}: */}
{/* </P> */}
{/* {(!isLoading */}
{/* ? Object.entries(data.externalId!).filter( */}
{/* ([_, data]) => data.link, */}
{/* ) */}
{/* : [...Array(3)].map((_) => [undefined, undefined] as const) */}
{/* ).map(([name, data], i) => ( */}
{/* <Chip */}
{/* key={name ?? i} */}
{/* label={name} */}
{/* href={data?.link || undefined} */}
{/* target="_blank" */}
{/* size="small" */}
{/* outline */}
{/* {...css({ m: ts(0.5) })} */}
{/* /> */}
{/* ))} */}
{/* </Container> */}
{/* {type === "show" && ( */}
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
{/* )} */}
</>
)}
/>
);
};
Header.query = (kind: string, slug: string): QueryIdentifier<Show> => ({
parser: Show,
path: [kind, slug],
params: {
with: ["studio", "watchStatus"],
},
});

View File

@ -0,0 +1 @@
export { MovieDetails } from "./movie";

View File

@ -0,0 +1,30 @@
import { Platform, ScrollView } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { Movie } from "~/models";
import type { QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "./header";
export const MovieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
const { css } = useYoshiki();
return (
<ScrollView
//{...css(
// Platform.OS === "web" && {
// // @ts-ignore Web only property
// overflow: "auto" as any,
// // @ts-ignore Web only property
// overflowX: "hidden",
// // @ts-ignore Web only property
// overflowY: "overlay",
// },
//)}
>
<Header kind="movie" slug={slug} />
{/* <DetailsCollections type="movie" slug={slug} /> */}
{/* <Staff slug={slug} /> */}
</ScrollView>
);
};

View File

@ -31,7 +31,7 @@ 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 "../layout";
import { DefaultLayout } from "../../../packages/ui/src/layoutpackages/ui/src/layout";
import { DetailsCollections } from "./collection";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
import { Header } from "./header";

View File

@ -23,16 +23,25 @@ export const getDisplayDate = (data: Show | Movie) => {
startAir,
endAir,
airDate,
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = data;
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } =
data;
if (startAir) {
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
return startAir.getFullYear().toString();
}
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
return (
startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "")
);
}
if (airDate) {
return airDate.getFullYear().toString();
}
return null;
};
export const displayRuntime = (runtime: number | null) => {
if (!runtime) return null;
if (runtime < 60) return `${runtime}min`;
return `${Math.floor(runtime / 60)}h${runtime % 60}`;
};