mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Split loaders in the episode list
This commit is contained in:
parent
444de0af26
commit
2756397898
@ -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: {
|
||||
export const episodeDisplayNumber = (episode: {
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
absoluteNumber?: number | null;
|
||||
},
|
||||
def?: string,
|
||||
) => {
|
||||
}) => {
|
||||
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,29 +288,20 @@ export const EpisodeLine = ({
|
||||
justifyContent: "space-between",
|
||||
})}
|
||||
>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
|
||||
{/* 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>
|
||||
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||
<Skeleton>
|
||||
{isLoading || (
|
||||
<SubP>
|
||||
{/* Source https://www.i18next.com/translation-function/formatting#datetime */}
|
||||
{[
|
||||
// @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>
|
||||
)}
|
||||
</Skeleton>
|
||||
{slug && watchedStatus !== undefined && (
|
||||
<EpisodesContext
|
||||
slug={slug}
|
||||
showSlug={showSlug}
|
||||
@ -328,13 +314,10 @@ export const EpisodeLine = ({
|
||||
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,
|
||||
|
@ -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 />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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)}
|
||||
|
@ -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,
|
||||
|
@ -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}}",
|
||||
|
Loading…
x
Reference in New Issue
Block a user