UI Plugin API (#4256)

* Add page registration
* Add example plugin
* First version of proper react plugins
* Make reference react plugin
* Add patching functions
* Add tools link poc
* NavItem poc
* Add loading hook for lazily loaded components
* Add documentation
This commit is contained in:
WithoutPants
2023-11-28 13:06:44 +11:00
committed by GitHub
parent 11be56cc42
commit b915428f06
17 changed files with 2418 additions and 384 deletions

View File

@@ -31,6 +31,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { objectPath, objectTitle } from "src/core/files";
import { PreviewScrubber } from "./PreviewScrubber";
import { PatchComponent } from "src/pluginApi";
interface IScenePreviewProps {
isPortrait: boolean;
@@ -103,372 +104,416 @@ interface ISceneCardProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
export const SceneCard: React.FC<ISceneCardProps> = (
props: ISceneCardProps
) => {
const history = useHistory();
const { configuration } = React.useContext(ConfigurationContext);
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
function maybeRenderSceneSpecsOverlay() {
let sizeObj = null;
if (file?.size) {
sizeObj = TextUtils.fileSize(file.size);
}
return (
<div className="scene-specs-overlay">
{sizeObj != null ? (
<span className="overlay-filesize extra-scene-info">
<FormattedNumber
value={sizeObj.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
sizeObj.unit
)}
/>
{TextUtils.formatFileSizeUnit(sizeObj.unit)}
</span>
) : (
""
)}
{file?.width && file?.height ? (
<span className="overlay-resolution">
{" "}
{TextUtils.resolution(file?.width, file?.height)}
</span>
) : (
""
)}
{(file?.duration ?? 0) >= 1
? TextUtils.secondsToTimestamp(file?.duration ?? 0)
: ""}
</div>
const SceneCardPopovers = PatchComponent(
"SceneCard.Popovers",
(props: ISceneCardProps) => {
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
}
function maybeRenderInteractiveSpeedOverlay() {
return (
<div className="scene-interactive-speed-overlay">
{props.scene.interactive_speed ?? ""}
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) return;
function renderStudioThumbnail() {
const studioImage = props.scene.studio?.image_path;
const studioName = props.scene.studio?.name;
const popoverContent = props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
));
if (configuration?.interface.showStudioAsText || !studioImage) {
return studioName;
}
const studioImageURL = new URL(studioImage);
if (studioImageURL.searchParams.get("default") === "true") {
return studioName;
}
return (
<img
className="image-thumbnail"
loading="lazy"
alt={studioName}
src={studioImage}
/>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{renderStudioThumbnail()}
</Link>
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) return;
const popoverContent = props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
));
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{props.scene.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) return;
return <PerformerPopoverButton performers={props.scene.performers} />;
}
function maybeRenderMoviePopoverButton() {
if (props.scene.movies.length <= 0) return;
const popoverContent = props.scene.movies.map((sceneMovie) => (
<div className="movie-tag-container row" key="movie">
<Link
to={`/movies/${sceneMovie.movie.id}`}
className="movie-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={sceneMovie.movie.name ?? ""}
src={sceneMovie.movie.front_image_path ?? ""}
/>
</Link>
<MovieLink
key={sceneMovie.movie.id}
movie={sceneMovie.movie}
className="d-block"
/>
</div>
));
return (
<HoverPopover
placement="bottom"
content={popoverContent}
className="movie-count tag-tooltip"
>
<Button className="minimal">
<Icon icon={faFilm} />
<span>{props.scene.movies.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map((marker) => {
const markerWithScene = { ...marker, scene: { id: props.scene.id } };
return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
});
return (
<HoverPopover
className="marker-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faMapMarkerAlt} />
<span>{props.scene.scene_markers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<div className="o-count">
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<span className="fa-icon">
<SweatDrops />
</span>
<span>{props.scene.o_counter}</span>
<Icon icon={faTag} />
<span>{props.scene.tags.length}</span>
</Button>
</div>
</HoverPopover>
);
}
}
function maybeRenderGallery() {
if (props.scene.galleries.length <= 0) return;
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) return;
const popoverContent = props.scene.galleries.map((gallery) => (
<GalleryLink key={gallery.id} gallery={gallery} />
));
return <PerformerPopoverButton performers={props.scene.performers} />;
}
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faImages} />
<span>{props.scene.galleries.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderMoviePopoverButton() {
if (props.scene.movies.length <= 0) return;
const popoverContent = props.scene.movies.map((sceneMovie) => (
<div className="movie-tag-container row" key="movie">
<Link
to={`/movies/${sceneMovie.movie.id}`}
className="movie-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={sceneMovie.movie.name ?? ""}
src={sceneMovie.movie.front_image_path ?? ""}
/>
</Link>
<MovieLink
key={sceneMovie.movie.id}
movie={sceneMovie.movie}
className="d-block"
/>
</div>
));
function maybeRenderOrganized() {
if (props.scene.organized) {
return (
<OverlayTrigger
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
<HoverPopover
placement="bottom"
content={popoverContent}
className="movie-count tag-tooltip"
>
<div className="organized">
<Button className="minimal">
<Icon icon={faFilm} />
<span>{props.scene.movies.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map((marker) => {
const markerWithScene = { ...marker, scene: { id: props.scene.id } };
return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
});
return (
<HoverPopover
className="marker-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faMapMarkerAlt} />
<span>{props.scene.scene_markers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<div className="o-count">
<Button className="minimal">
<Icon icon={faBox} />
<span className="fa-icon">
<SweatDrops />
</span>
<span>{props.scene.o_counter}</span>
</Button>
</div>
</OverlayTrigger>
);
}
}
function maybeRenderGallery() {
if (props.scene.galleries.length <= 0) return;
const popoverContent = props.scene.galleries.map((gallery) => (
<GalleryLink key={gallery.id} gallery={gallery} />
));
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faImages} />
<span>{props.scene.galleries.length}</span>
</Button>
</HoverPopover>
);
}
}
function maybeRenderDupeCopies() {
const phash = file
? file.fingerprints.find((fp) => fp.type === "phash")
: undefined;
if (phash) {
return (
<div className="other-copies extra-scene-info">
<Button
href={NavUtils.makeScenesPHashMatchUrl(phash.value)}
className="minimal"
function maybeRenderOrganized() {
if (props.scene.organized) {
return (
<OverlayTrigger
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
placement="bottom"
>
<Icon icon={faCopy} />
</Button>
<div className="organized">
<Button className="minimal">
<Icon icon={faBox} />
</Button>
</div>
</OverlayTrigger>
);
}
}
function maybeRenderDupeCopies() {
const phash = file
? file.fingerprints.find((fp) => fp.type === "phash")
: undefined;
if (phash) {
return (
<div className="other-copies extra-scene-info">
<Button
href={NavUtils.makeScenesPHashMatchUrl(phash.value)}
className="minimal"
>
<Icon icon={faCopy} />
</Button>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
!props.compact &&
(props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 ||
props.scene?.o_counter ||
props.scene.galleries.length > 0 ||
props.scene.organized)
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
{maybeRenderDupeCopies()}
</ButtonGroup>
</>
);
}
}
return <>{maybeRenderPopoverButtonGroup()}</>;
}
);
const SceneCardDetails = PatchComponent(
"SceneCard.Details",
(props: ISceneCardProps) => {
return (
<div className="scene-card__details">
<span className="scene-card__date">{props.scene.date}</span>
<span className="file-path extra-scene-info">
{objectPath(props.scene)}
</span>
<TruncatedText
className="scene-card__description"
text={props.scene.details}
lineCount={3}
/>
</div>
);
}
);
const SceneCardOverlays = PatchComponent(
"SceneCard.Overlays",
(props: ISceneCardProps) => {
const { configuration } = React.useContext(ConfigurationContext);
function renderStudioThumbnail() {
const studioImage = props.scene.studio?.image_path;
const studioName = props.scene.studio?.name;
if (configuration?.interface.showStudioAsText || !studioImage) {
return studioName;
}
const studioImageURL = new URL(studioImage);
if (studioImageURL.searchParams.get("default") === "true") {
return studioName;
}
return (
<img
className="image-thumbnail"
loading="lazy"
alt={studioName}
src={studioImage}
/>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{renderStudioThumbnail()}
</Link>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
!props.compact &&
(props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 ||
props.scene?.o_counter ||
props.scene.galleries.length > 0 ||
props.scene.organized)
) {
return <>{maybeRenderSceneStudioOverlay()}</>;
}
);
const SceneCardImage = PatchComponent(
"SceneCard.Image",
(props: ISceneCardProps) => {
const history = useHistory();
const { configuration } = React.useContext(ConfigurationContext);
const cont = configuration?.interface.continuePlaylistDefault ?? false;
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
function maybeRenderSceneSpecsOverlay() {
let sizeObj = null;
if (file?.size) {
sizeObj = TextUtils.fileSize(file.size);
}
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
{maybeRenderDupeCopies()}
</ButtonGroup>
</>
<div className="scene-specs-overlay">
{sizeObj != null ? (
<span className="overlay-filesize extra-scene-info">
<FormattedNumber
value={sizeObj.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
sizeObj.unit
)}
/>
{TextUtils.formatFileSizeUnit(sizeObj.unit)}
</span>
) : (
""
)}
{file?.width && file?.height ? (
<span className="overlay-resolution">
{" "}
{TextUtils.resolution(file?.width, file?.height)}
</span>
) : (
""
)}
{(file?.duration ?? 0) >= 1
? TextUtils.secondsToTimestamp(file?.duration ?? 0)
: ""}
</div>
);
}
}
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
function maybeRenderInteractiveSpeedOverlay() {
return (
<div className="scene-interactive-speed-overlay">
{props.scene.interactive_speed ?? ""}
</div>
);
}
return "";
}
function onScrubberClick(timestamp: number) {
const link = props.queue
? props.queue.makeLink(props.scene.id, {
sceneIndex: props.index,
continue: cont,
start: timestamp,
})
: `/scenes/${props.scene.id}?t=${timestamp}`;
function filelessClass() {
if (!props.scene.files.length) {
return "fileless";
history.push(link);
}
return "";
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
return (
<>
<ScenePreview
image={props.scene.paths.screenshot ?? undefined}
video={props.scene.paths.preview ?? undefined}
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
vttPath={props.scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()}
</>
);
}
);
const cont = configuration?.interface.continuePlaylistDefault ?? false;
export const SceneCard = PatchComponent(
"SceneCard",
(props: ISceneCardProps) => {
const { configuration } = React.useContext(ConfigurationContext);
const sceneLink = props.queue
? props.queue.makeLink(props.scene.id, {
sceneIndex: props.index,
continue: cont,
})
: `/scenes/${props.scene.id}`;
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
[props.scene]
);
function onScrubberClick(timestamp: number) {
const link = props.queue
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
}
return "";
}
function filelessClass() {
if (!props.scene.files.length) {
return "fileless";
}
return "";
}
const cont = configuration?.interface.continuePlaylistDefault ?? false;
const sceneLink = props.queue
? props.queue.makeLink(props.scene.id, {
sceneIndex: props.index,
continue: cont,
start: timestamp,
})
: `/scenes/${props.scene.id}?t=${timestamp}`;
: `/scenes/${props.scene.id}`;
history.push(link);
return (
<GridCard
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink}
title={objectTitle(props.scene)}
linkClassName="scene-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}
duration={file?.duration ?? undefined}
interactiveHeatmap={
props.scene.interactive_speed
? props.scene.paths.interactive_heatmap ?? undefined
: undefined
}
image={<SceneCardImage {...props} />}
overlays={<SceneCardOverlays {...props} />}
details={<SceneCardDetails {...props} />}
popovers={<SceneCardPopovers {...props} />}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
}
return (
<GridCard
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink}
title={objectTitle(props.scene)}
linkClassName="scene-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}
duration={file?.duration ?? undefined}
interactiveHeatmap={
props.scene.interactive_speed
? props.scene.paths.interactive_heatmap ?? undefined
: undefined
}
image={
<>
<ScenePreview
image={props.scene.paths.screenshot ?? undefined}
video={props.scene.paths.preview ?? undefined}
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
vttPath={props.scene.paths.vtt ?? undefined}
onScrubberClick={onScrubberClick}
/>
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()}
</>
}
overlays={maybeRenderSceneStudioOverlay()}
details={
<div className="scene-card__details">
<span className="scene-card__date">{props.scene.date}</span>
<span className="file-path extra-scene-info">
{objectPath(props.scene)}
</span>
<TruncatedText
className="scene-card__description"
text={props.scene.details}
lineCount={3}
/>
</div>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
};
);