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,
|
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,
|
||||||
|
@ -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 />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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)}
|
||||||
|
@ -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,
|
||||||
|
@ -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}}",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user