mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-17 23:19:14 -04:00
Add video mapping modal & video select for movies (#1365)
This commit is contained in:
commit
1d0c8a81ed
5
front/src/app/(app)/movies/[slug]/videos.tsx
Normal file
5
front/src/app/(app)/movies/[slug]/videos.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { MovieVideosModal } from "~/ui/admin/videos-modal/movie-modal";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default MovieVideosModal;
|
||||
@ -25,6 +25,7 @@ export const EntryBox = ({
|
||||
thumbnail,
|
||||
href,
|
||||
watchedPercent,
|
||||
videosCount,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
@ -37,6 +38,7 @@ export const EntryBox = ({
|
||||
href: string;
|
||||
thumbnail: KImage | null;
|
||||
watchedPercent: number;
|
||||
videosCount: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [moreOpened, setMoreOpened] = useState(false);
|
||||
@ -63,6 +65,7 @@ export const EntryBox = ({
|
||||
kind={kind}
|
||||
slug={slug}
|
||||
serieSlug={serieSlug}
|
||||
videosCount={videosCount}
|
||||
isOpen={moreOpened}
|
||||
setOpen={(v) => setMoreOpened(v)}
|
||||
className={cn(
|
||||
|
||||
@ -136,6 +136,7 @@ export const EntryLine = ({
|
||||
kind={kind}
|
||||
slug={slug}
|
||||
serieSlug={serieSlug}
|
||||
videosCount={videosCount}
|
||||
isOpen={moreOpened}
|
||||
setOpen={(v) => setMoreOpened(v)}
|
||||
className={cn(
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
|
||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import type { Entry } from "~/models";
|
||||
import { HR, Icon, Link, P, Popup, SubP } from "~/primitives";
|
||||
import {
|
||||
HR,
|
||||
Icon,
|
||||
IconButton,
|
||||
Link,
|
||||
P,
|
||||
Popup,
|
||||
SubP,
|
||||
tooltip,
|
||||
} from "~/primitives";
|
||||
|
||||
// stolen from https://github.com/jimmywarting/groupby-polyfill/blob/main/lib/polyfill.js
|
||||
// needed since hermes doesn't support Map.groupBy yet
|
||||
@ -26,7 +36,7 @@ export const EntrySelect = ({
|
||||
videos,
|
||||
close,
|
||||
}: {
|
||||
displayNumber: string;
|
||||
displayNumber: string | null;
|
||||
name: string;
|
||||
videos: Entry["videos"];
|
||||
close?: () => void;
|
||||
@ -43,26 +53,36 @@ export const EntrySelect = ({
|
||||
<Fragment key={rendering}>
|
||||
{i > 0 && <HR />}
|
||||
{items.map((x) => (
|
||||
<Link
|
||||
<View
|
||||
key={x.slug}
|
||||
href={`/watch/${x.slug}`}
|
||||
className="flex-row items-center gap-2 rounded p-2 hover:bg-popover"
|
||||
className="flex-row items-center gap-2 rounded p-2"
|
||||
>
|
||||
<Icon icon={PlayArrow} className="shrink-0" />
|
||||
<View className="flex-1">
|
||||
<P>{x.path}</P>
|
||||
<SubP>
|
||||
{[
|
||||
t("show.version", { number: x.version }),
|
||||
x.part !== null
|
||||
? t("show.part", { number: x.part })
|
||||
: null,
|
||||
]
|
||||
.filter((s) => s != null)
|
||||
.join(" · ")}
|
||||
</SubP>
|
||||
</View>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/watch/${x.slug}`}
|
||||
className="flex-1 flex-row items-center gap-2 hover:bg-popover"
|
||||
>
|
||||
<Icon icon={PlayArrow} className="shrink-0" />
|
||||
<View className="flex-1">
|
||||
<P>{x.path}</P>
|
||||
<SubP>
|
||||
{[
|
||||
t("show.version", { number: x.version }),
|
||||
x.part !== null
|
||||
? t("show.part", { number: x.part })
|
||||
: null,
|
||||
]
|
||||
.filter((s) => s != null)
|
||||
.join(" · ")}
|
||||
</SubP>
|
||||
</View>
|
||||
</Link>
|
||||
<IconButton
|
||||
icon={MovieInfo}
|
||||
as={Link}
|
||||
href={`/info/${x.slug}`}
|
||||
{...tooltip(t("home.episodeMore.mediainfo"))}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</Fragment>
|
||||
),
|
||||
|
||||
@ -16,12 +16,14 @@ export const EntryContext = ({
|
||||
kind,
|
||||
slug,
|
||||
serieSlug,
|
||||
videosCount,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
kind: "movie" | "episode" | "special";
|
||||
serieSlug: string | null;
|
||||
slug: string;
|
||||
videosCount: number;
|
||||
className?: string;
|
||||
} & Partial<ComponentProps<typeof Menu>> &
|
||||
Partial<ComponentProps<typeof IconButton>>) => {
|
||||
@ -48,11 +50,13 @@ export const EntryContext = ({
|
||||
{/* icon={Download} */}
|
||||
{/* onSelect={() => downloader(type, slug)} */}
|
||||
{/* /> */}
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/info/${slug}`}
|
||||
/>
|
||||
{videosCount === 1 && (
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/info/${slug}`}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Icon } from "./icons";
|
||||
@ -30,9 +31,11 @@ export const Modal = ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Overlay icon={icon} title={title} close={close} scroll={scroll}>
|
||||
{children}
|
||||
</Overlay>
|
||||
<Portal>
|
||||
<Overlay icon={icon} title={title} close={close} scroll={scroll}>
|
||||
{children}
|
||||
</Overlay>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
135
front/src/ui/admin/videos-modal/movie-modal.tsx
Normal file
135
front/src/ui/admin/videos-modal/movie-modal.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
|
||||
import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { FullVideo, type Movie } from "~/models";
|
||||
import {
|
||||
Button,
|
||||
ComboBox,
|
||||
IconButton,
|
||||
Modal,
|
||||
P,
|
||||
Skeleton,
|
||||
tooltip,
|
||||
} from "~/primitives";
|
||||
import { useFetch, useMutation } from "~/query";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { Header } from "../../details/header";
|
||||
|
||||
const MoviePathItem = ({
|
||||
id,
|
||||
path,
|
||||
movieSlug,
|
||||
}: {
|
||||
id: string;
|
||||
path: string;
|
||||
movieSlug: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync } = useMutation({
|
||||
method: "PUT",
|
||||
path: ["api", "videos", "link"],
|
||||
compute: (videoId: string) => ({
|
||||
body: [{ id: videoId, for: [] }],
|
||||
}),
|
||||
invalidate: ["api", "movies", movieSlug],
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="mx-6 min-h-12 flex-1 flex-row items-center justify-between hover:bg-card">
|
||||
<View className="flex-1 flex-row items-center pr-1">
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={async () => {
|
||||
await mutateAsync(id);
|
||||
}}
|
||||
{...tooltip(t("videos-map.delete"))}
|
||||
/>
|
||||
<P className="wrap-anywhere flex-1 flex-wrap">{path}</P>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
MoviePathItem.Loader = () => {
|
||||
return (
|
||||
<View className="mx-6 min-h-12 flex-1 flex-row items-center justify-between hover:bg-card">
|
||||
<View className="flex-1 flex-row items-center pr-1">
|
||||
<IconButton icon={Close} />
|
||||
<Skeleton className="w-4/5" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AddMovieVideoFooter = ({ slug }: { slug: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync } = useMutation({
|
||||
method: "PUT",
|
||||
path: ["api", "videos", "link"],
|
||||
compute: (videoId: string) => ({
|
||||
body: [{ id: videoId, for: [{ movie: slug }] }],
|
||||
}),
|
||||
invalidate: ["api", "movies", slug],
|
||||
});
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
Trigger={(props) => (
|
||||
<Button
|
||||
icon={LibraryAdd}
|
||||
text={t("videos-map.add")}
|
||||
className="m-6 mt-10"
|
||||
onPress={props.onPress ?? (props as any).onClick}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
searchPlaceholder={t("navbar.search")}
|
||||
value={null}
|
||||
query={(q) => ({
|
||||
parser: FullVideo,
|
||||
path: ["api", "videos"],
|
||||
params: {
|
||||
query: q,
|
||||
sort: "path",
|
||||
},
|
||||
infinite: true,
|
||||
})}
|
||||
getKey={(x) => x.id}
|
||||
getLabel={(x) => x.path}
|
||||
onValueChange={async (x) => {
|
||||
if (x) await mutateAsync(x.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MovieVideosModal = () => {
|
||||
const [slug] = useQueryState<string>("slug", undefined!);
|
||||
const { data } = useFetch(Header.query("movie", slug));
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videos = (data as Movie)?.videos;
|
||||
|
||||
return (
|
||||
<Modal title={data?.name ?? t("misc.loading")}>
|
||||
{videos && videos.length > 0 ? (
|
||||
videos.map((video) => (
|
||||
<MoviePathItem
|
||||
key={video.id}
|
||||
id={video.id}
|
||||
path={video.path}
|
||||
movieSlug={slug}
|
||||
/>
|
||||
))
|
||||
) : videos ? (
|
||||
<P className="flex-1 self-center p-6">{t("videos-map.no-video")}</P>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<MoviePathItem.Loader key={i} />
|
||||
))
|
||||
)}
|
||||
<AddMovieVideoFooter slug={slug} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -7,9 +7,12 @@ import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.s
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { entryDisplayNumber } from "~/components/entries";
|
||||
import { EntrySelect } from "~/components/entries/select";
|
||||
import { WatchListInfo } from "~/components/items/watchlist-info";
|
||||
import { Rating } from "~/components/rating";
|
||||
import {
|
||||
type Entry,
|
||||
type Genre,
|
||||
type KImage,
|
||||
Show,
|
||||
@ -38,6 +41,7 @@ import {
|
||||
Skeleton,
|
||||
tooltip,
|
||||
UL,
|
||||
usePopup,
|
||||
} from "~/primitives";
|
||||
import { useAccount } from "~/providers/account-context";
|
||||
import { Fetch, type QueryIdentifier } from "~/query";
|
||||
@ -48,6 +52,9 @@ const ButtonList = ({
|
||||
kind,
|
||||
slug,
|
||||
playHref,
|
||||
displayNumber,
|
||||
name,
|
||||
videos,
|
||||
trailerUrl,
|
||||
watchStatus,
|
||||
iconsClassName,
|
||||
@ -55,12 +62,16 @@ const ButtonList = ({
|
||||
kind: "movie" | "serie" | "collection";
|
||||
slug: string;
|
||||
playHref: string | null;
|
||||
displayNumber: string | null;
|
||||
name: string;
|
||||
videos: Entry["videos"] | null;
|
||||
trailerUrl: string | null;
|
||||
watchStatus: WatchStatusV | null;
|
||||
iconsClassName?: string;
|
||||
}) => {
|
||||
const account = useAccount();
|
||||
const { t } = useTranslation();
|
||||
const [setPopup, closePopup] = usePopup();
|
||||
|
||||
// const metadataRefreshMutation = useMutation({
|
||||
// method: "POST",
|
||||
@ -73,8 +84,25 @@ const ButtonList = ({
|
||||
{playHref !== null && (
|
||||
<IconFab
|
||||
icon={PlayArrow}
|
||||
as={Link}
|
||||
href={playHref}
|
||||
{...(videos && videos.length > 1
|
||||
? {
|
||||
onPress: () => {
|
||||
if (!videos) return;
|
||||
setPopup(
|
||||
<EntrySelect
|
||||
displayNumber={displayNumber}
|
||||
name={name}
|
||||
videos={videos}
|
||||
close={closePopup}
|
||||
/>,
|
||||
);
|
||||
},
|
||||
}
|
||||
: {
|
||||
as: Link,
|
||||
href: videos?.length ? playHref : null,
|
||||
disabled: !!videos?.length,
|
||||
})}
|
||||
{...tooltip(t("show.play"))}
|
||||
/>
|
||||
)}
|
||||
@ -103,7 +131,7 @@ const ButtonList = ({
|
||||
iconClassName={iconsClassName}
|
||||
{...tooltip(t("misc.more"))}
|
||||
>
|
||||
{kind === "movie" && (
|
||||
{kind === "movie" && videos?.length === 1 && (
|
||||
<>
|
||||
{/* <Menu.Item */}
|
||||
{/* icon={Download} */}
|
||||
@ -115,15 +143,15 @@ const ButtonList = ({
|
||||
icon={MovieInfo}
|
||||
href={`/info/${slug}`}
|
||||
/>
|
||||
{account?.isAdmin && <HR />}
|
||||
</>
|
||||
)}
|
||||
{account?.isAdmin === true && (
|
||||
<>
|
||||
{kind === "movie" && <HR />}
|
||||
<Menu.Item
|
||||
label={t("show.videos-map")}
|
||||
icon={VideoLibrary}
|
||||
href={`/series/${slug}/videos`}
|
||||
href={`/${kind === "movie" ? "movies" : "series"}/${slug}/videos`}
|
||||
/>
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.refreshMetadata")} */}
|
||||
@ -150,6 +178,8 @@ export const TitleLine = ({
|
||||
poster,
|
||||
trailerUrl,
|
||||
watchStatus,
|
||||
displayNumber,
|
||||
videos,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
@ -164,6 +194,8 @@ export const TitleLine = ({
|
||||
poster: KImage | null;
|
||||
trailerUrl: string | null;
|
||||
watchStatus: WatchStatusV | null;
|
||||
displayNumber: string | null;
|
||||
videos: Entry["videos"] | null;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
@ -191,6 +223,9 @@ export const TitleLine = ({
|
||||
kind={kind}
|
||||
slug={slug}
|
||||
playHref={playHref}
|
||||
displayNumber={displayNumber}
|
||||
name={name}
|
||||
videos={videos}
|
||||
trailerUrl={trailerUrl}
|
||||
watchStatus={watchStatus}
|
||||
iconsClassName="lg:fill-slate-200 dark:fill-slate-200"
|
||||
@ -305,7 +340,7 @@ const Description = ({
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag && capitalize(tag)}
|
||||
href={`/search?q=${tag}`}
|
||||
href={`/browse?q=${tag}`}
|
||||
size="small"
|
||||
className="m-1"
|
||||
/>
|
||||
@ -432,6 +467,18 @@ export const Header = ({
|
||||
? (data.watchStatus?.status ?? null)
|
||||
: null
|
||||
}
|
||||
displayNumber={
|
||||
data.kind === "serie"
|
||||
? entryDisplayNumber(data.nextEntry ?? data.firstEntry!)
|
||||
: null
|
||||
}
|
||||
videos={
|
||||
data.kind === "movie"
|
||||
? (data.videos ?? null)
|
||||
: data.kind === "serie"
|
||||
? ((data.nextEntry ?? data.firstEntry)?.videos ?? null)
|
||||
: null
|
||||
}
|
||||
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
|
||||
/>
|
||||
<Description
|
||||
@ -481,6 +528,7 @@ Header.query = (
|
||||
with: [
|
||||
...(kind !== "collection" ? ["collection", "studios"] : []),
|
||||
...(kind === "serie" ? ["firstEntry", "nextEntry"] : []),
|
||||
...(kind === "movie" ? ["videos"] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@ export const NewsList = () => {
|
||||
thumbnail={item.thumbnail ?? item.show!.thumbnail}
|
||||
href={item.href ?? "#"}
|
||||
watchedPercent={item.progress.percent}
|
||||
videosCount={item.videos.length}
|
||||
/>
|
||||
)}
|
||||
Loader={EntryBox.Loader}
|
||||
|
||||
@ -47,6 +47,7 @@ export const NextupList = () => {
|
||||
thumbnail={item.thumbnail ?? item.show!.thumbnail}
|
||||
href={item.href ?? "#"}
|
||||
watchedPercent={item.progress.percent}
|
||||
videosCount={item.videos.length}
|
||||
/>
|
||||
)}
|
||||
Loader={EntryBox.Loader}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user