Add video mapping modal & video select for movies (#1365)

This commit is contained in:
Zoe Roux 2026-03-14 21:39:12 +01:00 committed by GitHub
commit 1d0c8a81ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 255 additions and 34 deletions

View File

@ -0,0 +1,5 @@
import { MovieVideosModal } from "~/ui/admin/videos-modal/movie-modal";
export { ErrorBoundary } from "~/ui/error-boundary";
export default MovieVideosModal;

View File

@ -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(

View File

@ -136,6 +136,7 @@ export const EntryLine = ({
kind={kind}
slug={slug}
serieSlug={serieSlug}
videosCount={videosCount}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
className={cn(

View File

@ -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>
),

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View 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>
);
};

View File

@ -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"] : []),
],
},
});

View File

@ -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}

View File

@ -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}