Add entry list on player (#1422)

This commit is contained in:
Zoe Roux 2026-04-04 12:39:43 +02:00 committed by GitHub
commit 63db5bf578
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 141 additions and 16 deletions

View File

@ -255,7 +255,8 @@
"notInPristine": "Unavailable in pristine",
"unsupportedError": "Video codec not supported, transcoding in progress...",
"not-available": "{{entry}} is not available on kyoo yet, ask your server admins about it",
"fatal": "Fatal playback error"
"fatal": "Fatal playback error",
"entry-list": "Entry list"
},
"search": {
"empty": "No result found. Try a different query."

View File

@ -17,6 +17,7 @@ export * from "./modal";
export * from "./popup";
export * from "./progress";
export * from "./select";
export * from "./side-menu";
export * from "./skeleton";
export * from "./slider";
export * from "./tabs";

View File

@ -0,0 +1,50 @@
import { Portal } from "@gorhom/portal";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { cn } from "~/utils";
import { IconButton } from "./icons";
import { Heading } from "./text";
export const SideMenu = ({
isOpen,
title,
onClose,
children,
className,
containerClassName,
}: {
isOpen: boolean;
title?: string;
onClose: () => void;
children: ReactNode;
className?: string;
containerClassName?: string;
}) => {
if (!isOpen) return null;
return (
<Portal>
<Pressable
onPress={onClose}
className="absolute inset-0 cursor-default! bg-black/60"
tabIndex={-1}
/>
<View
className={cn(
"absolute inset-y-0 right-0 w-4/5 max-w-xl bg-popover",
"border-white/10 border-l",
containerClassName,
)}
>
{title && (
<View className="flex-row items-center justify-between border-white/10 border-b p-4 pt-safe">
<Heading>{title}</Heading>
<IconButton icon={Close} onPress={onClose} />
</View>
)}
<View className={cn("flex-1 pb-safe", className)}>{children}</View>
</View>
</Portal>
);
};

View File

@ -156,33 +156,40 @@ SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
export const EntryList = ({
slug,
season,
currentEntrySlug,
onSelectVideos,
search,
withContainer,
stickyHeaderConfig,
...props
}: {
slug: string;
season: string | number;
currentEntrySlug?: string;
onSelectVideos?: (entry: {
displayNumber: string;
name: string | null;
videos: Entry["videos"];
}) => void;
search?: string;
withContainer?: boolean;
} & Partial<ComponentProps<typeof InfiniteFetch<EntryOrSeason>>>) => {
const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
if (error) console.error("Could not fetch seasons", error);
const C = withContainer ? Container : View;
return (
<InfiniteFetch
query={EntryList.query(slug, season, search)}
layout={EntryLine.layout}
Empty={<EmptyView message={t("show.episode-none")} />}
Divider={() => (
<Container>
<C>
<HR />
</Container>
</C>
)}
getItemType={(item, idx) =>
item ? item.kind : idx === 0 ? "season" : "episode"
@ -194,7 +201,7 @@ export const EntryList = ({
}
placeholderCount={5}
Render={({ item }) => (
<Container>
<C>
{item.kind === "season" ? (
<SeasonHeader
serieSlug={slug}
@ -205,8 +212,13 @@ export const EntryList = ({
) : (
<EntryLine
{...item}
// Don't display "Go to serie"
videos={item.videos}
className={
item.slug === currentEntrySlug
? "rounded-md bg-accent/10"
: undefined
}
// Don't display "Go to serie"
serieSlug={null}
displayNumber={entryDisplayNumber(item)}
watchedPercent={item.progress.percent}
@ -219,13 +231,18 @@ export const EntryList = ({
}
/>
)}
</Container>
</C>
)}
Loader={({ index }) => (
<Container>
{index === 0 ? <SeasonHeader.Loader /> : <EntryLine.Loader />}
</Container>
<C>{index === 0 ? <SeasonHeader.Loader /> : <EntryLine.Loader />}</C>
)}
stickyHeaderConfig={{
...stickyHeaderConfig,
backdropComponent: () => (
// hr bottom margin is m-4 and layout gap is 2 but it's only applied on the web and idk why
<View className="absolute inset-0 mb-4 web:mb-6 bg-card" />
),
}}
{...props}
/>
);

View File

@ -165,15 +165,10 @@ export const SerieDetails = () => {
/>
)}
contentContainerStyle={{ paddingBottom: insets.bottom }}
withContainer
onScroll={scrollHandler}
scrollEventThrottle={16}
stickyHeaderConfig={{
offset: headerHeight,
backdropComponent: () => (
// hr bottom margin is m-4 and layout gap is 2 but it's only applied on the web and idk why
<View className="absolute inset-0 mb-4 web:mb-6 bg-card" />
),
}}
stickyHeaderConfig={{ offset: headerHeight }}
/>
</View>
);

View File

@ -1,3 +1,4 @@
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import { type ComponentProps, useState } from "react";
@ -33,6 +34,7 @@ export const BottomControls = ({
playPrev,
playNext,
setMenu,
onOpenEntriesMenu,
className,
...props
}: {
@ -43,6 +45,7 @@ export const BottomControls = ({
playPrev: (() => boolean) | null;
playNext: (() => boolean) | null;
setMenu: (isOpen: boolean) => void;
onOpenEntriesMenu?: () => void;
} & ViewProps) => {
const [seek, setSeek] = useState<number | null>(null);
const bottomSeek = Platform.OS !== "web" && seek !== null;
@ -90,6 +93,7 @@ export const BottomControls = ({
playPrev={playPrev}
playNext={playNext}
setMenu={setMenu}
onOpenEntriesMenu={onOpenEntriesMenu}
/>
)}
</View>
@ -102,6 +106,7 @@ const ControlButtons = ({
playPrev,
playNext,
setMenu,
onOpenEntriesMenu,
className,
...props
}: {
@ -109,6 +114,7 @@ const ControlButtons = ({
playPrev: (() => boolean) | null;
playNext: (() => boolean) | null;
setMenu: (isOpen: boolean) => void;
onOpenEntriesMenu?: () => void;
className?: string;
}) => {
const { t } = useTranslation();
@ -170,6 +176,15 @@ const ControlButtons = ({
/>
</View>
<View className="flex-row">
{onOpenEntriesMenu && (
<IconButton
icon={MenuIcon}
onPress={onOpenEntriesMenu}
className="mr-4"
iconClassName="fill-slate-200 dark:fill-slate-200"
{...tooltip(t("player.entry-list"), true)}
/>
)}
<SubtitleMenu player={player} {...menuProps} />
<AudioMenu player={player} {...menuProps} />
<VideoMenu player={player} {...menuProps} />

View File

@ -20,6 +20,7 @@ export const Controls = ({
chapters,
playPrev,
playNext,
onOpenEntriesMenu,
forceShow,
}: {
player: VideoPlayer;
@ -32,6 +33,7 @@ export const Controls = ({
chapters: Chapter[];
playPrev: (() => boolean) | null;
playNext: (() => boolean) | null;
onOpenEntriesMenu?: () => void;
forceShow?: boolean;
}) => {
const isTouch = useIsTouch();
@ -83,6 +85,7 @@ export const Controls = ({
chapters={chapters}
playPrev={playPrev}
playNext={playNext}
onOpenEntriesMenu={onOpenEntriesMenu}
setMenu={setMenu}
className="absolute bottom-0 w-full bg-slate-900/50 px-safe pt-safe"
{...hoverControls}

View File

@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { SideMenu } from "~/primitives";
import { EntryList } from "~/ui/details/season";
export const EntriesMenu = ({
isOpen,
onClose,
showSlug,
season,
currentEntrySlug,
}: {
isOpen: boolean;
onClose: () => void;
showSlug: string;
season: string | number;
currentEntrySlug?: string;
}) => {
return (
<SideMenu isOpen={isOpen} onClose={onClose} containerClassName="bg-card">
<EntryList
slug={showSlug}
season={season}
currentEntrySlug={currentEntrySlug}
/>
</SideMenu>
);
};

View File

@ -18,6 +18,7 @@ import { Controls, LoadingIndicator } from "./controls";
import { ErrorPopup } from "./controls/error-popup";
import { toggleFullscreen } from "./controls/misc";
import { PlayModeContext } from "./controls/tracks-menu";
import { EntriesMenu } from "./entries-menu";
import { useKeyboard } from "./keyboard";
import { useLanguagePreference } from "./language-preference";
import { useProgressObserver } from "./progress-observer";
@ -48,6 +49,7 @@ export const Player = () => {
const playModeState = useState(defaultPlayMode);
const [playMode, setPlayMode] = playModeState;
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
const [entriesMenuOpen, setEntriesMenuOpen] = useState(false);
const player = useVideoPlayer(
{
uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
@ -223,9 +225,23 @@ export const Player = () => {
chapters={info?.chapters ?? []}
playPrev={data?.previous ? playPrev : null}
playNext={data?.next ? playNext : null}
onOpenEntriesMenu={
data?.show?.kind === "serie"
? () => setEntriesMenuOpen(true)
: undefined
}
forceShow={!!playbackError}
/>
</PlayModeContext.Provider>
{data?.show?.kind === "serie" && (
<EntriesMenu
isOpen={entriesMenuOpen}
onClose={() => setEntriesMenuOpen(false)}
showSlug={data.show.slug}
season={entry?.kind === "episode" ? entry.seasonNumber : 0}
currentEntrySlug={entry?.slug}
/>
)}
{playbackError && (
<ErrorPopup
message={playbackError.message}