Handle multi video entries

This commit is contained in:
Zoe Roux 2026-01-30 19:28:29 +01:00
parent 3c106844aa
commit 65756e2e1c
No known key found for this signature in database
7 changed files with 130 additions and 51 deletions

View File

@ -41,7 +41,9 @@
"null": "Mark as not seen"
},
"nextUp": "Next up",
"season": "Season {{number}}"
"season": "Season {{number}}",
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos"
},
"browse": {
"mediatypekey": {

View File

@ -1,5 +1,4 @@
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 MultipleVideos from "@material-symbols/svg-400/rounded/subscriptions-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type PressableProps, View } from "react-native";
@ -7,12 +6,13 @@ import { EntryContext } from "~/components/items/context-menus";
import { ItemProgress } from "~/components/items/item-grid";
import type { KImage } from "~/models";
import {
CroppedText,
Heading,
IconButton,
Icon,
Image,
ImageBackground,
Link,
P,
PressableFeedback,
Skeleton,
SubP,
tooltip,
@ -35,6 +35,7 @@ export const EntryLine = ({
watchedPercent,
href,
className,
videosCount,
...props
}: {
slug: string;
@ -50,9 +51,9 @@ export const EntryLine = ({
runtime: number | null;
watchedPercent: number | null;
href: string | null;
videosCount: number;
} & PressableProps) => {
const [moreOpened, setMoreOpened] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const { t } = useTranslation();
return (
@ -71,7 +72,7 @@ export const EntryLine = ({
quality="low"
alt=""
className={cn(
"m-1 w-1/5 shrink-0 rounded",
"mr-1 w-1/5 shrink-0 rounded",
poster ? "aspect-2/3" : "aspect-video",
"group-hover:ring-3 group-hover:ring-accent group-focus-visible:ring-3 group-focus-visible:ring-accent",
)}
@ -81,12 +82,12 @@ export const EntryLine = ({
)}
</ImageBackground>
<View className="m-1 mx-2 flex-1">
<View className="flex-1 flex-row justify-between">
<View className="mb-5 flex-1">
<View className="mb-5 flex-1 flex-row">
<View className="flex-1 flex-row items-center">
<Heading
className={cn(
"font-medium group-hover:underline group-focus-visible:underline",
"text-lg",
"shrink font-medium text-lg",
"group-hover:underline group-focus-visible:underline",
)}
>
{[displayNumber, name ?? t("show.episodeNoMetadata")]
@ -95,47 +96,48 @@ export const EntryLine = ({
</Heading>
{tagline && <Heading>{tagline}</Heading>}
</View>
<View className="flex-row items-center">
<SubP>
{[
airDate
? // @ts-expect-error Source https://www.i18next.com/translation-function/formatting#datetime
t("{{val, datetime}}", { val: airDate })
: null,
displayRuntime(runtime),
]
.filter((item) => item != null)
.join(" · ")}
</SubP>
<View className="flex-row">
<View className="flex-col-reverse justify-end md:flex-row md:items-center">
{videosCount > 1 && (
<PressableFeedback
className="flex-row items-center rounded-2xl bg-popover p-2 md:mx-4"
{...tooltip(t("show.multiVideos"))}
>
<Icon
icon={MultipleVideos}
fillClassName="accent-accent dark:accent-slate-400"
/>
<SubP className="ml-2">
{t("show.videosCount", { number: videosCount })}
</SubP>
</PressableFeedback>
)}
<SubP>
{[
airDate
? // @ts-expect-error Source https://www.i18next.com/translation-function/formatting#datetime
t("{{val, datetime}}", { val: airDate })
: null,
displayRuntime(runtime),
]
.filter((item) => item != null)
.join(" · ")}
</SubP>
</View>
<EntryContext
slug={slug}
serieSlug={serieSlug}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
className={cn(
"ml-3 flex",
"not:web:opacity-100 opacity-0 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100",
"ml-3 flex native:hidden",
"opacity-0 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100",
moreOpened && "opacity-100",
)}
/>
</View>
</View>
<View className="flex-row justify-between">
<P numberOfLines={descriptionExpanded ? undefined : 3}>
{description}
</P>
<IconButton
className="not:web:opacity-100 opacity-0 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100"
icon={descriptionExpanded ? ExpandLess : ExpandMore}
{...tooltip(
t(descriptionExpanded ? "misc.collapse" : "misc.expand"),
)}
onPress={(e) => {
e.preventDefault();
setDescriptionExpanded((isExpanded) => !isExpanded);
}}
/>
</View>
<CroppedText numberOfLines={3}>{description}</CroppedText>
</View>
</Link>
);

View File

@ -21,20 +21,31 @@ const BaseIcon = withUniwind(IconWrapper, {
fromClassName: "fillClassName",
styleProperty: "accentColor",
},
width: {
fromClassName: "widthClassName",
styleProperty: "width",
},
height: {
fromClassName: "heightClassName",
styleProperty: "height",
},
});
export const Icon = ({
className,
fillClassName,
widthClassName,
heightClassName,
...props
}: ComponentProps<typeof BaseIcon>) => {
return (
<BaseIcon
fillClassName={cn(
"accent-slate-600 dark:accent-slate-400",
fillClassName,
)}
className={cn("h-6 w-6 shrink-0", className)}
fillClassName={
fillClassName ? fillClassName : "accent-slate-600 dark:accent-slate-400"
}
widthClassName={cn("w-6", widthClassName)}
heightClassName={cn("h-6", heightClassName)}
className={cn("shrink-0", className)}
{...props}
/>
);
@ -58,7 +69,7 @@ export const IconButton = <AsProps = PressableProps>({
<Container
focusRipple
className={cn(
"m-1 self-center overflow-hidden rounded-full p-2",
"m-2 h-6 w-6 self-center overflow-hidden rounded-full",
"hover:bg-gray-300 focus-visible:bg-gray-300 focus-visible:dark:bg-gray-700 hover:dark:bg-gray-700",
className,
)}

View File

@ -7,9 +7,20 @@ import {
H6 as EH6,
P as EP,
} from "@expo/html-elements";
import type { ComponentProps, ComponentType } from "react";
import { Text } from "react-native";
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 {
type ComponentProps,
type ComponentType,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Platform, Text, View, type ViewProps } from "react-native";
import { cn } from "~/utils";
import { IconButton } from "./icons";
import { tooltip } from "./tooltip";
import { useTranslation } from "react-i18next";
const styleText = (
Component: ComponentType<ComponentProps<typeof EP>>,
@ -53,3 +64,52 @@ export const LI = ({ children, ...props }: ComponentProps<typeof P>) => {
</P>
);
};
export const CroppedText = ({
className,
numberOfLines,
onTextLayout,
ref,
containerProps,
children,
...props
}: { containerProps?: ViewProps } & ComponentProps<typeof P>) => {
const desc = useRef<HTMLElement>(null);
const [expended, setExpanded] = useState(false);
const [needExpand, setNeedExpand] = useState(false);
const { t } = useTranslation();
useLayoutEffect(() => {
if (Platform.OS !== "web" || !desc.current || expended) return;
setNeedExpand(desc.current.scrollHeight > desc.current.clientHeight + 1);
});
return (
<View className="flex-row justify-between" {...(containerProps ?? {})}>
<P
ref={ref}
numberOfLines={expended ? undefined : numberOfLines}
onTextLayout={(e) => {
const visible = e.nativeEvent.lines.reduce(
(acc, line) => acc + line.text,
"",
);
setNeedExpand(visible !== children);
}}
{...props}
>
{children}
</P>
{needExpand && (
<IconButton
icon={expended ? ExpandLess : ExpandMore}
{...tooltip(t(expended ? "misc.collapse" : "misc.expand"))}
onPress={(e) => {
e.preventDefault();
setExpanded((isExpanded) => !isExpanded);
}}
/>
)}
</View>
);
};

View File

@ -113,7 +113,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
contentContainerStyle={{
...contentContainerStyle,
gap,
marginHorizontal: gap,
marginHorizontal: numColumns > 1 ? gap : 0,
}}
{...props}
/>

View File

@ -44,7 +44,9 @@ export const SeasonHeader = ({
className={cn("m-1 flex-row", className)}
{...props}
>
<P className="mx-1 w-16 shrink-0 text-center text-2xl">{seasonNumber}</P>
<P className="mx-1 w-16 shrink-0 text-center text-2xl text-accent">
{seasonNumber}
</P>
<H2 className="mx-1 flex-1 text-2xl">
{name ?? t("show.season", { number: seasonNumber })}
</H2>
@ -131,6 +133,7 @@ export const EntryList = ({
as={EntryLine}
{...item}
// Don't display "Go to serie"
videosCount={item.videos.length}
serieSlug={null}
displayNumber={entryDisplayNumber(item)}
watchedPercent={item.progress.percent}

View File

@ -43,6 +43,7 @@ export const NextUp = (nextEntry: Entry) => {
<EntryLine
{...nextEntry}
serieSlug={null}
videosCount={nextEntry.videos.length}
watchedPercent={nextEntry.progress.percent}
displayNumber={entryDisplayNumber(nextEntry)}
/>