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

View File

@ -26,7 +26,18 @@ import {
SeasonP, SeasonP,
useInfiniteFetch, useInfiniteFetch,
} from "@kyoo/models"; } 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 MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -38,14 +49,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string }; type SeasonProcessed = Season & { href: string };
export const SeasonHeader = ({ export const SeasonHeader = ({
isLoading,
seasonNumber, seasonNumber,
name, name,
seasons, seasons,
}: { }: {
isLoading: boolean; seasonNumber: number;
seasonNumber?: number; name: string | null;
name?: string;
seasons?: SeasonProcessed[]; seasons?: SeasonProcessed[];
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -63,21 +72,20 @@ export const SeasonHeader = ({
fontSize: rem(1.5), fontSize: rem(1.5),
})} })}
> >
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber} {seasonNumber}
</P> </P>
<H6 <H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
aria-level={2} {name ?? t("show.season", { number: seasonNumber })}
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })} </H2>
>
{isLoading ? <Skeleton /> : name}
</H6>
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}> <Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
{seasons {seasons
?.filter((x) => x.episodesCount > 0) ?.filter((x) => x.episodesCount > 0)
.map((x) => ( .map((x) => (
<Menu.Item <Menu.Item
key={x.seasonNumber} 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} 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> => ({ SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
parser: SeasonP, parser: SeasonP,
path: ["show", slug, "seasons"], path: ["show", slug, "seasons"],
@ -130,32 +163,37 @@ export const EpisodeList = <Props,>({
headerProps={headerProps} headerProps={headerProps}
getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")} getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")}
contentContainerStyle={pageStyle} contentContainerStyle={pageStyle}
> placeholderCount={5}
{(item) => { Render={({ item }) => {
const sea = item?.firstOfSeason const sea = item?.firstOfSeason
? seasons?.find((x) => x.seasonNumber === item.seasonNumber) ? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null; : null;
return ( return (
<> <>
{item.firstOfSeason && ( {item.firstOfSeason &&
<SeasonHeader (sea ? (
isLoading={!sea} <SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
name={sea?.name} ) : (
seasonNumber={sea?.seasonNumber} <SeasonHeader.Loader />
seasons={seasons} ))}
/>
)}
<EpisodeLine <EpisodeLine
{...item} {...item}
// Don't display "Go to show"
showSlug={null} showSlug={null}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!} displayNumber={episodeDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? null} watchedPercent={item.watchStatus?.watchedPercent ?? null}
watchedStatus={item.watchStatus?.status ?? 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> <H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine <EpisodeLine
isLoading={false}
{...nextEpisode} {...nextEpisode}
showSlug={null} showSlug={null}
watchedPercent={watchedPercent || null} watchedPercent={watchedPercent || null}
watchedStatus={status || null} watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode, "???")!} displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)} onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)} onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)} onFocus={() => setFocus(true)}

View File

@ -53,7 +53,7 @@ const mapData = (
if (!data) return { isLoading: true }; if (!data) return { isLoading: true };
return { return {
isLoading: false, 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, showName: data.type === "movie" ? data.name! : data.show!.name,
poster: data.type === "movie" ? data.poster : data.show!.poster, poster: data.type === "movie" ? data.poster : data.show!.poster,
subtitles: info?.subtitles, subtitles: info?.subtitles,

View File

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