Add watch status indicator on items list and recommanded card

This commit is contained in:
Zoe Roux 2023-12-05 00:48:00 +01:00
parent e5dde472c9
commit c24c8914bf
5 changed files with 114 additions and 51 deletions

View File

@ -22,7 +22,6 @@ import { KyooImage, WatchStatusV } from "@kyoo/models";
import { import {
Link, Link,
Skeleton, Skeleton,
Poster,
ts, ts,
focusReset, focusReset,
P, P,
@ -31,7 +30,7 @@ import {
Icon, Icon,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { ImageStyle, View } from "react-native"; import { ImageStyle, View } from "react-native";
import { percent, px, rem, Stylable, Theme, useYoshiki } from "yoshiki/native"; import { percent, px, Stylable, Theme, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch"; import { Layout, WithLoading } from "../fetch";
import Done from "@material-symbols/svg-400/rounded/done-fill.svg"; import Done from "@material-symbols/svg-400/rounded/done-fill.svg";
@ -41,14 +40,16 @@ export const ItemGrid = ({
subtitle, subtitle,
poster, poster,
isLoading, isLoading,
watchInfo, watchStatus,
unseenEpisodesCount,
...props ...props
}: WithLoading<{ }: WithLoading<{
href: string; href: string;
name: string; name: string;
subtitle?: string; subtitle?: string;
poster?: KyooImage | null; poster?: KyooImage | null;
watchInfo: WatchStatusV | string | null; watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}> & }> &
Stylable<"text">) => { Stylable<"text">) => {
const { css } = useYoshiki("grid"); const { css } = useYoshiki("grid");
@ -89,7 +90,7 @@ export const ItemGrid = ({
layout={{ width: percent(100) }} layout={{ width: percent(100) }}
{...(css("poster") as { style: ImageStyle })} {...(css("poster") as { style: ImageStyle })}
> >
{watchInfo && ( {(watchStatus === WatchStatusV.Completed || unseenEpisodesCount) && (
<View <View
{...css({ {...css({
position: "absolute", position: "absolute",
@ -104,10 +105,10 @@ export const ItemGrid = ({
borderRadius: 999999, borderRadius: 999999,
})} })}
> >
{watchInfo === WatchStatusV.Completed ? ( {watchStatus === WatchStatusV.Completed ? (
<Icon icon={Done} size={16} /> <Icon icon={Done} size={16} />
) : ( ) : (
<P {...css({ m: 0, textAlign: "center" })}>{watchInfo}</P> <P {...css({ m: 0, textAlign: "center" })}>{unseenEpisodesCount}</P>
)} )}
</View> </View>
)} )}

View File

@ -25,7 +25,6 @@ import {
LibraryItemP, LibraryItemP,
ItemKind, ItemKind,
getDisplayDate, getDisplayDate,
WatchStatusV,
} from "@kyoo/models"; } from "@kyoo/models";
import { ComponentProps, useState } from "react"; import { ComponentProps, useState } from "react";
import { createParam } from "solito"; import { createParam } from "solito";
@ -42,16 +41,7 @@ const { useParam } = createParam<{ sortBy?: string }>();
export const itemMap = ( export const itemMap = (
item: WithLoading<LibraryItem>, item: WithLoading<LibraryItem>,
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => { ): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => {
if (item.isLoading) return item; if (item.isLoading) return item as any;
let watchInfo: string | WatchStatusV | null = null;
if (item.kind !== ItemKind.Collection && item.watchStatus?.status === WatchStatusV.Completed)
watchInfo = WatchStatusV.Completed;
else if (item.kind === ItemKind.Show) {
if (!item.watchStatus) watchInfo = item.episodesCount!.toString();
else if (item.watchStatus.status === WatchStatusV.Watching)
watchInfo = item.watchStatus.unseenEpisodesCount.toString();
}
return { return {
isLoading: item.isLoading, isLoading: item.isLoading,
@ -60,7 +50,11 @@ export const itemMap = (
href: item.href, href: item.href,
poster: item.poster, poster: item.poster,
thumbnail: item.thumbnail, thumbnail: item.thumbnail,
watchInfo: watchInfo, watchStatus: item.kind !== ItemKind.Collection ? item.watchStatus?.status ?? null : null,
unseenEpisodesCount:
item.kind === ItemKind.Show
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
: null,
}; };
}; };

View File

@ -18,12 +18,23 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { KyooImage } from "@kyoo/models"; import { KyooImage, WatchStatusV } from "@kyoo/models";
import { Link, P, Skeleton, ts, ImageBackground, Poster, Heading } from "@kyoo/primitives"; import {
Link,
P,
Skeleton,
ts,
ImageBackground,
Poster,
Heading,
Icon,
PosterBackground,
} from "@kyoo/primitives";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native"; import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch"; import { Layout, WithLoading } from "../fetch";
import Done from "@material-symbols/svg-400/rounded/done-fill.svg";
export const ItemList = ({ export const ItemList = ({
href, href,
@ -32,12 +43,17 @@ export const ItemList = ({
thumbnail, thumbnail,
poster, poster,
isLoading, isLoading,
watchStatus,
unseenEpisodesCount,
...props
}: WithLoading<{ }: WithLoading<{
href: string; href: string;
name: string; name: string;
subtitle?: string; subtitle?: string;
poster?: KyooImage | null; poster?: KyooImage | null;
thumbnail?: KyooImage | null; thumbnail?: KyooImage | null;
watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}>) => { }>) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const [isHovered, setHovered] = useState(0); const [isHovered, setHovered] = useState(0);
@ -59,14 +75,17 @@ export const ItemList = ({
imageStyle={{ imageStyle={{
borderRadius: px(6), borderRadius: px(6),
}} }}
{...css({ {...css(
alignItems: "center", {
justifyContent: "space-evenly", alignItems: "center",
flexDirection: "row", justifyContent: "space-evenly",
height: ItemList.layout.size, flexDirection: "row",
borderRadius: px(6), height: ItemList.layout.size,
marginX: ItemList.layout.gap, borderRadius: px(6),
})} marginX: ItemList.layout.gap,
},
props,
)}
> >
<View <View
{...css({ {...css({
@ -104,13 +123,36 @@ export const ItemList = ({
</Skeleton> </Skeleton>
)} )}
</View> </View>
<Poster <PosterBackground
src={poster} src={poster}
alt="" alt=""
quality="low" quality="low"
forcedLoading={isLoading} forcedLoading={isLoading}
layout={{ height: percent(80) }} layout={{ height: percent(80) }}
/> >
{(watchStatus === WatchStatusV.Completed || unseenEpisodesCount) && (
<View
{...css({
position: "absolute",
top: 0,
left: 0,
minWidth: ts(3.5),
aspectRatio: 1,
justifyContent: "center",
m: ts(0.5),
pX: ts(0.5),
bg: (theme) => theme.darkOverlay,
borderRadius: 999999,
})}
>
{watchStatus === WatchStatusV.Completed ? (
<Icon icon={Done} size={16} />
) : (
<P {...css({ m: 0, textAlign: "center" })}>{unseenEpisodesCount}</P>
)}
</View>
)}
</PosterBackground>
</ImageBackground> </ImageBackground>
); );
}; };

View File

@ -25,15 +25,17 @@ import {
LibraryItem, LibraryItem,
LibraryItemP, LibraryItemP,
QueryIdentifier, QueryIdentifier,
WatchStatusV,
getDisplayDate, getDisplayDate,
} from "@kyoo/models"; } from "@kyoo/models";
import { import {
Chip, Chip,
H3, H3,
Icon,
IconFab, IconFab,
ImageBackground,
Link, Link,
P, P,
PosterBackground,
Skeleton, Skeleton,
SubP, SubP,
focusReset, focusReset,
@ -48,6 +50,7 @@ import { Layout, WithLoading } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { ItemGrid } from "../browse/grid"; import { ItemGrid } from "../browse/grid";
import Done from "@material-symbols/svg-400/rounded/done-fill.svg";
export const ItemDetails = ({ export const ItemDetails = ({
isLoading, isLoading,
@ -59,6 +62,8 @@ export const ItemDetails = ({
genres, genres,
href, href,
playHref, playHref,
watchStatus,
unseenEpisodesCount,
...props ...props
}: WithLoading<{ }: WithLoading<{
name: string; name: string;
@ -69,6 +74,8 @@ export const ItemDetails = ({
overview: string | null; overview: string | null;
href: string; href: string;
playHref: string | null; playHref: string | null;
watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}>) => { }>) => {
const { push } = useRouter(); const { push } = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@ -100,13 +107,12 @@ export const ItemDetails = ({
props, props,
)} )}
> >
<ImageBackground <PosterBackground
src={poster} src={poster}
alt="" alt=""
quality="low" quality="low"
gradient={false}
forcedLoading={isLoading} forcedLoading={isLoading}
{...css({ height: percent(100), aspectRatio: 2 / 3 })} layout={{ height: percent(100) }}
> >
<View <View
{...css({ {...css({
@ -127,7 +133,29 @@ export const ItemDetails = ({
</Skeleton> </Skeleton>
)} )}
</View> </View>
</ImageBackground> {(watchStatus === WatchStatusV.Completed || unseenEpisodesCount) && (
<View
{...css({
position: "absolute",
top: 0,
right: 0,
minWidth: ts(3.5),
aspectRatio: 1,
justifyContent: "center",
m: ts(0.5),
pX: ts(0.5),
bg: (theme) => theme.darkOverlay,
borderRadius: 999999,
})}
>
{watchStatus === WatchStatusV.Completed ? (
<Icon icon={Done} size={16} />
) : (
<P {...css({ m: 0, textAlign: "center" })}>{unseenEpisodesCount}</P>
)}
</View>
)}
</PosterBackground>
<View {...css({ flexShrink: 1, flexGrow: 1, justifyContent: "flex-end" })}> <View {...css({ flexShrink: 1, flexGrow: 1, justifyContent: "flex-end" })}>
{(isLoading || tagline) && ( {(isLoading || tagline) && (
<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}> <Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}>
@ -195,7 +223,7 @@ export const Recommanded = () => {
fetchMore={false} fetchMore={false}
{...css({ padding: 0 })} {...css({ padding: 0 })}
> >
{(x, i) => ( {(x) => (
<ItemDetails <ItemDetails
isLoading={x.isLoading as any} isLoading={x.isLoading as any}
name={x.name} name={x.name}
@ -208,6 +236,14 @@ export const Recommanded = () => {
genres={"genres" in x ? x.genres : null} genres={"genres" in x ? x.genres : null}
href={x.href} href={x.href}
playHref={x.kind !== ItemKind.Collection && !x.isLoading ? x.playHref : undefined} playHref={x.kind !== ItemKind.Collection && !x.isLoading ? x.playHref : undefined}
watchStatus={
!x.isLoading && x.kind !== ItemKind.Collection ? x.watchStatus?.status ?? null : null
}
unseenEpisodesCount={
x.kind === ItemKind.Show
? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount!
: null
}
/> />
)} )}
</InfiniteFetch> </InfiniteFetch>
@ -222,6 +258,6 @@ Recommanded.query = (): QueryIdentifier<LibraryItem> => ({
params: { params: {
sortBy: "random", sortBy: "random",
limit: 6, limit: 6,
fields: ["firstEpisode"], fields: ["firstEpisode", "episodesCount", "watchStatus"],
}, },
}); });

View File

@ -26,6 +26,7 @@ import { InfiniteFetch } from "../fetch-infinite";
import { ItemList } from "../browse/list"; import { ItemList } from "../browse/list";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ItemGrid } from "../browse/grid"; import { ItemGrid } from "../browse/grid";
import { itemMap } from "../browse";
export const VerticalRecommanded = () => { export const VerticalRecommanded = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,19 +41,7 @@ export const VerticalRecommanded = () => {
layout={{ ...ItemList.layout, layout: "vertical" }} layout={{ ...ItemList.layout, layout: "vertical" }}
fetchMore={false} fetchMore={false}
> >
{(x, i) => ( {(x, i) => <ItemList key={x.id ?? i} {...itemMap(x)} />}
<ItemList
key={x.id ?? i}
isLoading={x.isLoading as any}
href={x.href}
name={x.name}
subtitle={
x.kind !== ItemKind.Collection && !x.isLoading ? getDisplayDate(x) : undefined
}
poster={x.poster}
thumbnail={x.thumbnail}
/>
)}
</InfiniteFetch> </InfiniteFetch>
</View> </View>
); );
@ -63,6 +52,7 @@ VerticalRecommanded.query = (): QueryIdentifier<LibraryItem> => ({
infinite: true, infinite: true,
path: ["items"], path: ["items"],
params: { params: {
fields: ["episodesCount", "watchStatus"],
sortBy: "random", sortBy: "random",
limit: 3, limit: 3,
}, },