Split loaders in the episode list

This commit is contained in:
Zoe Roux 2024-05-20 17:54:28 +02:00
parent 444de0af26
commit 2756397898
No known key found for this signature in database
5 changed files with 142 additions and 82 deletions

View File

@ -32,6 +32,7 @@ import {
important,
tooltip,
ts,
Image,
} from "@kyoo/primitives";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg";
@ -43,18 +44,15 @@ import { ItemProgress } from "../browse/grid";
import { EpisodesContext } from "../components/context-menus";
import type { Layout, WithLoading } from "../fetch";
export const episodeDisplayNumber = (
episode: {
seasonNumber?: number | null;
episodeNumber?: number | null;
absoluteNumber?: number | null;
},
def?: string,
) => {
export const episodeDisplayNumber = (episode: {
seasonNumber?: number | null;
episodeNumber?: number | null;
absoluteNumber?: number | null;
}) => {
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return def;
return "??";
};
export const displayRuntime = (runtime: number | null) => {
@ -187,7 +185,6 @@ export const EpisodeLine = ({
name,
thumbnail,
overview,
isLoading,
id,
absoluteNumber,
episodeNumber,
@ -198,7 +195,7 @@ export const EpisodeLine = ({
watchedStatus,
href,
...props
}: WithLoading<{
}: {
id: string;
slug: string;
// if show slug is null, disable "Go to show" in the context menu
@ -215,8 +212,7 @@ export const EpisodeLine = ({
watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
href: string;
}> &
PressableProps &
} & PressableProps &
Stylable) => {
const [moreOpened, setMoreOpened] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
@ -254,7 +250,6 @@ export const EpisodeLine = ({
quality="low"
alt=""
gradient={false}
hideLoad={false}
layout={{
width: percent(18),
aspectRatio: 16 / 9,
@ -293,48 +288,36 @@ export const EpisodeLine = ({
justifyContent: "space-between",
})}
>
<Skeleton>
{isLoading || (
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
</H6>
)}
</Skeleton>
{/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
</H6>
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Skeleton>
{isLoading || (
<SubP>
{/* Source https://www.i18next.com/translation-function/formatting#datetime */}
{[
releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
displayRuntime(runtime),
]
.filter((item) => item != null)
.join(" · ")}
</SubP>
)}
</Skeleton>
{slug && watchedStatus !== undefined && (
<EpisodesContext
slug={slug}
showSlug={showSlug}
status={watchedStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
"more",
{ display: "flex", marginLeft: ts(3) },
Platform.OS === "web" && moreOpened && { display: important("flex") },
])}
/>
)}
<SubP>
{[
// @ts-ignore Source https://www.i18next.com/translation-function/formatting#datetime
releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
displayRuntime(runtime),
]
.filter((item) => item != null)
.join(" · ")}
</SubP>
<EpisodesContext
slug={slug}
showSlug={showSlug}
status={watchedStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
"more",
{ display: "flex", marginLeft: ts(3) },
Platform.OS === "web" && moreOpened && { display: important("flex") },
])}
/>
</View>
</View>
<View {...css({ flexDirection: "row" })}>
<Skeleton>
{isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>}
</Skeleton>
<View {...css({ flexDirection: "row", justifyContent: "space-between" })}>
<P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>
<IconButton
{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
icon={descriptionExpanded ? ExpandLess : ExpandMore}
@ -349,6 +332,45 @@ export const EpisodeLine = ({
</Link>
);
};
EpisodeLine.Loader = (props: Stylable) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
flexDirection: "row",
},
props,
)}
>
<Image.Loader
layout={{
width: percent(18),
aspectRatio: 16 / 9,
}}
{...css({ flexShrink: 0, m: ts(1) })}
/>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<View
{...css({
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
justifyContent: "space-between",
})}
>
<Skeleton {...css({ width: percent(30) })} />
<Skeleton {...css({ width: percent(15) })} />
</View>
<Skeleton />
</View>
</View>
);
};
EpisodeLine.layout = {
numColumns: 1,
size: 100,

View File

@ -26,7 +26,18 @@ import {
SeasonP,
useInfiniteFetch,
} from "@kyoo/models";
import { H6, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
import {
H2,
H6,
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";
@ -38,14 +49,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string };
export const SeasonHeader = ({
isLoading,
seasonNumber,
name,
seasons,
}: {
isLoading: boolean;
seasonNumber?: number;
name?: string;
seasonNumber: number;
name: string | null;
seasons?: SeasonProcessed[];
}) => {
const { css } = useYoshiki();
@ -63,21 +72,20 @@ export const SeasonHeader = ({
fontSize: rem(1.5),
})}
>
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber}
{seasonNumber}
</P>
<H6
aria-level={2}
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}
>
{isLoading ? <Skeleton /> : name}
</H6>
<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"))}>
{seasons
?.filter((x) => x.episodesCount > 0)
.map((x) => (
<Menu.Item
key={x.seasonNumber}
label={`${x.seasonNumber}: ${x.name} (${x.episodesCount})`}
label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.episodesCount})`}
href={x.href}
/>
))}
@ -88,6 +96,31 @@ export const SeasonHeader = ({
);
};
SeasonHeader.Loader = () => {
const { css } = useYoshiki();
return (
<View>
<View {...css({ flexDirection: "row", marginX: ts(1), justifyContent: "space-between" })}>
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<Skeleton
variant="custom"
{...css({
width: rem(4),
flexShrink: 0,
marginX: ts(1),
height: rem(1.5),
})}
/>
<Skeleton {...css({ marginX: ts(1), width: rem(12), height: rem(2) })} />
</View>
<IconButton icon={MenuIcon} disabled />
</View>
<HR />
</View>
);
};
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
parser: SeasonP,
path: ["show", slug, "seasons"],
@ -130,32 +163,37 @@ export const EpisodeList = <Props,>({
headerProps={headerProps}
getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")}
contentContainerStyle={pageStyle}
>
{(item) => {
placeholderCount={5}
Render={({ item }) => {
const sea = item?.firstOfSeason
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null;
return (
<>
{item.firstOfSeason && (
<SeasonHeader
isLoading={!sea}
name={sea?.name}
seasonNumber={sea?.seasonNumber}
seasons={seasons}
/>
)}
{item.firstOfSeason &&
(sea ? (
<SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
) : (
<SeasonHeader.Loader />
))}
<EpisodeLine
{...item}
// Don't display "Go to show"
showSlug={null}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
displayNumber={episodeDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? null}
watchedStatus={item.watchStatus?.status ?? null}
/>
</>
);
}}
</InfiniteFetch>
Loader={({ index }) => (
<>
{index === 0 && <SeasonHeader.Loader />}
<EpisodeLine.Loader />
</>
)}
/>
);
};

View File

@ -79,12 +79,11 @@ export const ShowWatchStatusCard = ({ watchedPercent, status, nextEpisode }: Sho
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine
isLoading={false}
{...nextEpisode}
showSlug={null}
watchedPercent={watchedPercent || null}
watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode, "???")!}
displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)}

View File

@ -53,7 +53,7 @@ const mapData = (
if (!data) return { isLoading: true };
return {
isLoading: false,
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`,
showName: data.type === "movie" ? data.name! : data.show!.name,
poster: data.type === "movie" ? data.poster : data.show!.poster,
subtitles: info?.subtitles,

View File

@ -39,7 +39,8 @@
"droped": "Mark as dropped",
"null": "Mark as not seen"
},
"nextUp": "Next up"
"nextUp": "Next up",
"season": "Season {{number}}"
},
"browse": {
"sortby": "Sort by {{key}}",