mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-04-07 01:31:56 -04:00
Add entry list on player (#1422)
This commit is contained in:
commit
63db5bf578
@ -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."
|
||||
|
||||
@ -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";
|
||||
|
||||
50
front/src/primitives/side-menu.tsx
Normal file
50
front/src/primitives/side-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
27
front/src/ui/player/entries-menu.tsx
Normal file
27
front/src/ui/player/entries-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user