From 9b7e20351a7790b5b8b98bacf527f35563d45359 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:04:12 +1100 Subject: [PATCH] Plugin api improvements (#5703) * Add ReactSelect to PluginApi.libraries * Make Performer tabs patchable * Make PerformerCard patchable * Use registration pattern for HoverPopover, TagLink and LoadingIndicator Initialising the components map to include these was causing an initialisation error. * Add showZero property to PopoverCountButton * Make TagCard patchable * Make ScenePage and ScenePlayer patchable * Pass properties to container components * Add example for scene tabs * Make FrontPage patchable * Add FrontPage example --- .../react-component/src/testReact.tsx | 44 +- .../src/components/FrontPage/FrontPage.tsx | 5 +- .../components/Performers/PerformerCard.tsx | 498 +++--- .../Performers/PerformerDetails/Performer.tsx | 464 +++--- .../PerformerGalleriesPanel.tsx | 25 +- .../PerformerDetails/PerformerGroupsPanel.tsx | 25 +- .../PerformerDetails/PerformerImagesPanel.tsx | 25 +- .../PerformerDetails/PerformerScenesPanel.tsx | 25 +- .../performerAppearsWithPanel.tsx | 41 +- .../components/ScenePlayer/ScenePlayer.tsx | 1333 +++++++++-------- .../components/Scenes/SceneDetails/Scene.tsx | 265 ++-- .../components/Shared/DetailsPage/Tabs.tsx | 20 +- .../src/components/Shared/HoverPopover.tsx | 132 +- .../components/Shared/LoadingIndicator.tsx | 41 +- .../components/Shared/PopoverCountButton.tsx | 6 + ui/v2.5/src/components/Shared/TagLink.tsx | 120 +- ui/v2.5/src/components/Tags/TagCard.tsx | 466 +++--- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 29 + ui/v2.5/src/patch.tsx | 17 +- ui/v2.5/src/pluginApi.d.ts | 24 + ui/v2.5/src/pluginApi.tsx | 2 + 21 files changed, 1884 insertions(+), 1723 deletions(-) diff --git a/pkg/plugin/examples/react-component/src/testReact.tsx b/pkg/plugin/examples/react-component/src/testReact.tsx index 70aba6539..d2733fb26 100644 --- a/pkg/plugin/examples/react-component/src/testReact.tsx +++ b/pkg/plugin/examples/react-component/src/testReact.tsx @@ -14,7 +14,11 @@ interface IPluginApi { Button: React.FC; Nav: React.FC & { Link: React.FC; + Item: React.FC; }; + Tab: React.FC & { + Pane: React.FC; + } }, FontAwesomeSolid: { faEthernet: any; @@ -45,7 +49,7 @@ interface IPluginApi { const React = PluginApi.React; const GQL = PluginApi.GQL; - const { Button } = PluginApi.libraries.Bootstrap; + const { Button, Nav, Tab } = PluginApi.libraries.Bootstrap; const { faEthernet } = PluginApi.libraries.FontAwesomeSolid; const { Link, @@ -144,6 +148,10 @@ interface IPluginApi { return <>{original({...props})}; }); + PluginApi.patch.instead("FrontPage", function (props: any, _: any, original: (props: any) => any) { + return <>

Hello from Test React!

{original({...props})}; + }); + const TestPage: React.FC = () => { const componentsToLoad = [ PluginApi.loadableComponents.SceneCard, @@ -237,5 +245,37 @@ interface IPluginApi { ) } ] - }) + }); + + PluginApi.patch.before("ScenePage.Tabs", function (props: any) { + return [ + { + children: ( + <> + {props.children} + + + Test React tab + + + + ), + }, + ]; + }); + + PluginApi.patch.before("ScenePage.TabContent", function (props: any) { + return [ + { + children: ( + <> + {props.children} + + Test React tab content {props.scene.id} + + + ), + }, + ]; + }); })(); \ No newline at end of file diff --git a/ui/v2.5/src/components/FrontPage/FrontPage.tsx b/ui/v2.5/src/components/FrontPage/FrontPage.tsx index d52f65db6..89b4db468 100644 --- a/ui/v2.5/src/components/FrontPage/FrontPage.tsx +++ b/ui/v2.5/src/components/FrontPage/FrontPage.tsx @@ -13,8 +13,9 @@ import { getFrontPageContent, } from "src/core/config"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { PatchComponent } from "src/patch"; -const FrontPage: React.FC = () => { +const FrontPage: React.FC = PatchComponent("FrontPage", () => { const intl = useIntl(); const Toast = useToast(); @@ -81,6 +82,6 @@ const FrontPage: React.FC = () => { ); -}; +}); export default FrontPage; diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index a5579d73e..28e08f78e 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -23,6 +23,7 @@ import { usePerformerUpdate } from "src/core/StashService"; import { ILabeledId } from "src/models/list-filter/types"; import ScreenUtils from "src/utils/screen"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; +import { PatchComponent } from "src/patch"; export interface IPerformerCardExtraCriteria { scenes?: ModifierCriterion[]; @@ -43,176 +44,109 @@ interface IPerformerCardProps { extraCriteria?: IPerformerCardExtraCriteria; } -export const PerformerCard: React.FC = ({ - performer, - containerWidth, - ageFromDate, - selecting, - selected, - zoomIndex, - onSelectedChanged, - extraCriteria, -}) => { - const intl = useIntl(); - const age = TextUtils.age( - performer.birthdate, - ageFromDate ?? performer.death_date - ); - const ageL10nId = ageFromDate - ? "media_info.performer_card.age_context" - : "media_info.performer_card.age"; - const ageL10String = intl.formatMessage({ - id: "years_old", - defaultMessage: "years old", - }); - const ageString = intl.formatMessage( - { id: ageL10nId }, - { age, years_old: ageL10String } - ); +const PerformerCardPopovers: React.FC = PatchComponent( + "PerformerCard.Popovers", + ({ performer, extraCriteria }) => { + function maybeRenderScenesPopoverButton() { + if (!performer.scene_count) return; - const [updatePerformer] = usePerformerUpdate(); - const [cardWidth, setCardWidth] = useState(); - - useEffect(() => { - if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) - return; - - let zoomValue = zoomIndex; - let preferredCardWidth: number; - switch (zoomValue) { - case 0: - preferredCardWidth = 240; - break; - case 1: - preferredCardWidth = 300; - break; - case 2: - preferredCardWidth = 375; - break; - case 3: - preferredCardWidth = 470; + return ( + + ); } - let fittedCardWidth = calculateCardWidth( - containerWidth, - preferredCardWidth! - ); - setCardWidth(fittedCardWidth); - }, [containerWidth, zoomIndex]); - function onToggleFavorite(v: boolean) { - if (performer.id) { - updatePerformer({ - variables: { - input: { - id: performer.id, - favorite: v, - }, - }, - }); + function maybeRenderImagesPopoverButton() { + if (!performer.image_count) return; + + return ( + + ); } - } - function maybeRenderScenesPopoverButton() { - if (!performer.scene_count) return; + function maybeRenderGalleriesPopoverButton() { + if (!performer.gallery_count) return; - return ( - - ); - } + return ( + + ); + } - function maybeRenderImagesPopoverButton() { - if (!performer.image_count) return; + function maybeRenderOCounter() { + if (!performer.o_counter) return; - return ( - - ); - } + return ( +
+ +
+ ); + } - function maybeRenderGalleriesPopoverButton() { - if (!performer.gallery_count) return; + function maybeRenderTagPopoverButton() { + if (performer.tags.length <= 0) return; - return ( - - ); - } + const popoverContent = performer.tags.map((tag) => ( + + )); - function maybeRenderOCounter() { - if (!performer.o_counter) return; + return ( + + + + ); + } - return ( -
- -
- ); - } + function maybeRenderGroupsPopoverButton() { + if (!performer.group_count) return; - function maybeRenderTagPopoverButton() { - if (performer.tags.length <= 0) return; + return ( + + ); + } - const popoverContent = performer.tags.map((tag) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderGroupsPopoverButton() { - if (!performer.group_count) return; - - return ( - - ); - } - - function maybeRenderPopoverButtonGroup() { if ( performer.scene_count || performer.image_count || @@ -235,85 +169,189 @@ export const PerformerCard: React.FC = ({ ); } - } - function maybeRenderRatingBanner() { - if (!performer.rating100) { - return; - } - return ; + return null; } +); - function maybeRenderFlag() { - if (performer.country) { - return ( - - - - {performer.country} - - - ); - } - } +const PerformerCardOverlays: React.FC = PatchComponent( + "PerformerCard.Overlays", + ({ performer }) => { + const [updatePerformer] = usePerformerUpdate(); - return ( - + function onToggleFavorite(v: boolean) { + if (performer.id) { + updatePerformer({ + variables: { + input: { + id: performer.id, + favorite: v, + }, + }, + }); } - title={ -
- {performer.name} - {performer.disambiguation && ( - - {` (${performer.disambiguation})`} + } + + function maybeRenderRatingBanner() { + if (!performer.rating100) { + return; + } + return ; + } + + function maybeRenderFlag() { + if (performer.country) { + return ( + + + + {performer.country} - )} -
+ + ); } - image={ - <> - {performer.name - + } + + return ( + <> + + {maybeRenderRatingBanner()} + {maybeRenderFlag()} + + ); + } +); + +const PerformerCardDetails: React.FC = PatchComponent( + "PerformerCard.Details", + ({ performer, ageFromDate }) => { + const intl = useIntl(); + const age = TextUtils.age( + performer.birthdate, + ageFromDate ?? performer.death_date + ); + const ageL10nId = ageFromDate + ? "media_info.performer_card.age_context" + : "media_info.performer_card.age"; + const ageL10String = intl.formatMessage({ + id: "years_old", + defaultMessage: "years old", + }); + const ageString = intl.formatMessage( + { id: ageL10nId }, + { age, years_old: ageL10String } + ); + + return ( + <> + {age !== 0 ? ( +
{ageString}
+ ) : ( + "" + )} + + ); + } +); + +const PerformerCardImage: React.FC = PatchComponent( + "PerformerCard.Image", + ({ performer }) => { + return ( + <> + {performer.name + + ); + } +); + +const PerformerCardTitle: React.FC = PatchComponent( + "PerformerCard.Title", + ({ performer }) => { + return ( +
+ {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
+ ); + } +); + +export const PerformerCard: React.FC = PatchComponent( + "PerformerCard", + (props) => { + const { + performer, + containerWidth, + selecting, + selected, + onSelectedChanged, + zoomIndex, + } = props; + + const [cardWidth, setCardWidth] = useState(); + + useEffect(() => { + if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) + return; + + let zoomValue = zoomIndex; + let preferredCardWidth: number; + switch (zoomValue) { + case 0: + preferredCardWidth = 240; + break; + case 1: + preferredCardWidth = 300; + break; + case 2: + preferredCardWidth = 375; + break; + case 3: + preferredCardWidth = 470; } - overlays={ - <> - - {maybeRenderRatingBanner()} - {maybeRenderFlag()} - - } - details={ - <> - {age !== 0 ? ( -
{ageString}
- ) : ( - "" - )} - - } - popovers={maybeRenderPopoverButtonGroup()} - selected={selected} - selecting={selecting} - onSelectedChanged={onSelectedChanged} - /> - ); -}; + let fittedCardWidth = calculateCardWidth( + containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [containerWidth, zoomIndex]); + + return ( + + } + title={} + image={} + overlays={} + details={} + popovers={} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 41a37b61d..c86960f37 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -45,6 +45,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; +import { PatchComponent } from "src/patch"; interface IProps { performer: GQL.PerformerDataFragment; @@ -200,272 +201,277 @@ const PerformerTabs: React.FC<{ ); }; -const PerformerPage: React.FC = ({ performer, tabKey }) => { - const Toast = useToast(); - const history = useHistory(); - const intl = useIntl(); +const PerformerPage: React.FC = PatchComponent( + "PerformerPage", + ({ performer, tabKey }) => { + const Toast = useToast(); + const history = useHistory(); + const intl = useIntl(); - // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); - const uiConfig = configuration?.ui; - const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; - const enableBackgroundImage = - uiConfig?.enablePerformerBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? true; - const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; + // Configuration settings + const { configuration } = React.useContext(ConfigurationContext); + const uiConfig = configuration?.ui; + const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; + const enableBackgroundImage = + uiConfig?.enablePerformerBackgroundImage ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; + const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; - const [collapsed, setCollapsed] = useState(!showAllDetails); - const [isEditing, setIsEditing] = useState(false); - const [image, setImage] = useState(); - const [encodingImage, setEncodingImage] = useState(false); - const loadStickyHeader = useLoadStickyHeader(); + const [collapsed, setCollapsed] = useState(!showAllDetails); + const [isEditing, setIsEditing] = useState(false); + const [image, setImage] = useState(); + const [encodingImage, setEncodingImage] = useState(false); + const loadStickyHeader = useLoadStickyHeader(); - const activeImage = useMemo(() => { - const performerImage = performer.image_path; - if (isEditing) { - if (image === null && performerImage) { - const performerImageURL = new URL(performerImage); - performerImageURL.searchParams.set("default", "true"); - return performerImageURL.toString(); - } else if (image) { - return image; + const activeImage = useMemo(() => { + const performerImage = performer.image_path; + if (isEditing) { + if (image === null && performerImage) { + const performerImageURL = new URL(performerImage); + performerImageURL.searchParams.set("default", "true"); + return performerImageURL.toString(); + } else if (image) { + return image; + } + } + return performerImage; + }, [image, isEditing, performer.image_path]); + + const lightboxImages = useMemo( + () => [{ paths: { thumbnail: activeImage, image: activeImage } }], + [activeImage] + ); + + const [updatePerformer] = usePerformerUpdate(); + const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); + + async function onAutoTag() { + try { + await mutateMetadataAutoTag({ performers: [performer.id] }); + Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); + } catch (e) { + Toast.error(e); } } - return performerImage; - }, [image, isEditing, performer.image_path]); - const lightboxImages = useMemo( - () => [{ paths: { thumbnail: activeImage, image: activeImage } }], - [activeImage] - ); + useRatingKeybinds( + true, + configuration?.ui.ratingSystemOptions?.type, + setRating + ); - const [updatePerformer] = usePerformerUpdate(); - const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); + // set up hotkeys + useEffect(() => { + Mousetrap.bind("e", () => toggleEditing()); + Mousetrap.bind("f", () => setFavorite(!performer.favorite)); + Mousetrap.bind(",", () => setCollapsed(!collapsed)); - async function onAutoTag() { - try { - await mutateMetadataAutoTag({ performers: [performer.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - - useRatingKeybinds( - true, - configuration?.ui.ratingSystemOptions?.type, - setRating - ); - - // set up hotkeys - useEffect(() => { - Mousetrap.bind("e", () => toggleEditing()); - Mousetrap.bind("f", () => setFavorite(!performer.favorite)); - Mousetrap.bind(",", () => setCollapsed(!collapsed)); - - return () => { - Mousetrap.unbind("e"); - Mousetrap.unbind("f"); - Mousetrap.unbind(","); - }; - }); - - async function onSave(input: GQL.PerformerCreateInput) { - await updatePerformer({ - variables: { - input: { - id: performer.id, - ...input, - }, - }, + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("f"); + Mousetrap.unbind(","); + }; }); - toggleEditing(false); - Toast.success( - intl.formatMessage( - { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() } - ) - ); - } - async function onDelete() { - try { - await deletePerformer({ variables: { id: performer.id } }); - } catch (e) { - Toast.error(e); - } - - // redirect to performers page - history.push("/performers"); - } - - function toggleEditing(value?: boolean) { - if (value !== undefined) { - setIsEditing(value); - } else { - setIsEditing((e) => !e); - } - setImage(undefined); - } - - function setFavorite(v: boolean) { - if (performer.id) { - updatePerformer({ + async function onSave(input: GQL.PerformerCreateInput) { + await updatePerformer({ variables: { input: { id: performer.id, - favorite: v, + ...input, }, }, }); + toggleEditing(false); + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(), + } + ) + ); } - } - function setRating(v: number | null) { - if (performer.id) { - updatePerformer({ - variables: { - input: { - id: performer.id, - rating100: v, + async function onDelete() { + try { + await deletePerformer({ variables: { id: performer.id } }); + } catch (e) { + Toast.error(e); + } + + // redirect to performers page + history.push("/performers"); + } + + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } + setImage(undefined); + } + + function setFavorite(v: boolean) { + if (performer.id) { + updatePerformer({ + variables: { + input: { + id: performer.id, + favorite: v, + }, }, - }, - }); + }); + } } - } - if (isDestroying) - return ( - - ); + function setRating(v: number | null) { + if (performer.id) { + updatePerformer({ + variables: { + input: { + id: performer.id, + rating100: v, + }, + }, + }); + } + } - const headerClassName = cx("detail-header", { - edit: isEditing, - collapsed, - "full-width": !collapsed && !compactExpandedDetails, - }); - - return ( -
- - {performer.name} - - -
- -
- - {!!activeImage && ( - - - - )} - + ); -
-
- + const headerClassName = cx("detail-header", { + edit: isEditing, + collapsed, + "full-width": !collapsed && !compactExpandedDetails, + }); + + return ( +
+ + {performer.name} + + +
+ +
+ + {!!activeImage && ( + + + + )} + + +
+
+ + {!isEditing && ( + setCollapsed(v)} + /> + )} + + setFavorite(v)} + /> + + + + + setRating(value)} + clickToRate + withoutContext + /> {!isEditing && ( - setCollapsed(v)} + fullWidth={!collapsed && !compactExpandedDetails} /> )} - - setFavorite(v)} + {isEditing ? ( + toggleEditing()} + setImage={setImage} + setEncodingImage={setEncodingImage} /> - - - - - setRating(value)} - clickToRate - withoutContext - /> + ) : ( + + + toggleEditing()} + onDelete={onDelete} + onAutoTag={onAutoTag} + autoTagDisabled={performer.ignore_auto_tag} + isNew={false} + isEditing={false} + onSave={() => {}} + onImageChange={() => {}} + classNames="mb-2" + customButtons={ +
+ +
+ } + >
+
+ + )} +
+
+
+
+ + {!isEditing && loadStickyHeader && ( + + )} + +
+
+
{!isEditing && ( - )} - {isEditing ? ( - toggleEditing()} - setImage={setImage} - setEncodingImage={setEncodingImage} - /> - ) : ( - - - toggleEditing()} - onDelete={onDelete} - onAutoTag={onAutoTag} - autoTagDisabled={performer.ignore_auto_tag} - isNew={false} - isEditing={false} - onSave={() => {}} - onImageChange={() => {}} - classNames="mb-2" - customButtons={ -
- -
- } - >
-
- - )}
- - {!isEditing && loadStickyHeader && ( - - )} - -
-
-
- {!isEditing && ( - - )} -
-
-
-
- ); -}; + ); + } +); const PerformerLoader: React.FC> = ({ location, diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index 4424ff740..5a9d0b81d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; +import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } -export const PerformerGalleriesPanel: React.FC = ({ - active, - performer, -}) => { - const filterHook = usePerformerFilterHook(performer); - return ( - - ); -}; +export const PerformerGalleriesPanel: React.FC = + PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => { + const filterHook = usePerformerFilterHook(performer); + return ( + + ); + }); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx index 4e75ef279..5475c1484 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx @@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql"; import { GroupList } from "src/components/Groups/GroupList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; +import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } -export const PerformerGroupsPanel: React.FC = ({ - active, - performer, -}) => { - const filterHook = usePerformerFilterHook(performer); - return ( - - ); -}; +export const PerformerGroupsPanel: React.FC = + PatchComponent("PerformerGroupsPanel", ({ active, performer }) => { + const filterHook = usePerformerFilterHook(performer); + return ( + + ); + }); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 40c2d88b8..7b088e5be 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql"; import { ImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; +import { PatchComponent } from "src/patch"; interface IPerformerImagesPanel { active: boolean; performer: GQL.PerformerDataFragment; } -export const PerformerImagesPanel: React.FC = ({ - active, - performer, -}) => { - const filterHook = usePerformerFilterHook(performer); - return ( - - ); -}; +export const PerformerImagesPanel: React.FC = + PatchComponent("PerformerImagesPanel", ({ active, performer }) => { + const filterHook = usePerformerFilterHook(performer); + return ( + + ); + }); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index 5eca04f6c..9bdd4553f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; +import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } -export const PerformerScenesPanel: React.FC = ({ - active, - performer, -}) => { - const filterHook = usePerformerFilterHook(performer); - return ( - - ); -}; +export const PerformerScenesPanel: React.FC = + PatchComponent("PerformerScenesPanel", ({ active, performer }) => { + const filterHook = usePerformerFilterHook(performer); + return ( + + ); + }); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx index 8806bd3ab..913d16625 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -3,33 +3,32 @@ import * as GQL from "src/core/generated-graphql"; import { PerformerList } from "src/components/Performers/PerformerList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; +import { PatchComponent } from "src/patch"; interface IPerformerDetailsProps { active: boolean; performer: GQL.PerformerDataFragment; } -export const PerformerAppearsWithPanel: React.FC = ({ - active, - performer, -}) => { - const performerValue = { - id: performer.id, - label: performer.name ?? `Performer ${performer.id}`, - }; +export const PerformerAppearsWithPanel: React.FC = + PatchComponent("PerformerAppearsWithPanel", ({ active, performer }) => { + const performerValue = { + id: performer.id, + label: performer.name ?? `Performer ${performer.id}`, + }; - const extraCriteria = { - performer: performerValue, - }; + const extraCriteria = { + performer: performerValue, + }; - const filterHook = usePerformerFilterHook(performer); + const filterHook = usePerformerFilterHook(performer); - return ( - - ); -}; + return ( + + ); + }); diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 0cb14a1d8..5749f6331 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -46,6 +46,7 @@ import airplay from "@silvermine/videojs-airplay"; import chromecast from "@silvermine/videojs-chromecast"; import abLoopPlugin from "videojs-abloop"; import ScreenUtils from "src/utils/screen"; +import { PatchComponent } from "src/patch"; // register videojs plugins airplay(videojs); @@ -210,706 +211,712 @@ interface IScenePlayerProps { onPrevious: () => void; } -export const ScenePlayer: React.FC = ({ - scene, - hideScrubberOverride, - autoplay, - permitLoop = true, - initialTimestamp: _initialTimestamp, - sendSetTimestamp, - onComplete, - onNext, - onPrevious, -}) => { - const { configuration } = useContext(ConfigurationContext); - const interfaceConfig = configuration?.interface; - const uiConfig = configuration?.ui; - const videoRef = useRef(null); - const [_player, setPlayer] = useState(); - const sceneId = useRef(); - const [sceneSaveActivity] = useSceneSaveActivity(); - const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); +export const ScenePlayer: React.FC = PatchComponent( + "ScenePlayer", + ({ + scene, + hideScrubberOverride, + autoplay, + permitLoop = true, + initialTimestamp: _initialTimestamp, + sendSetTimestamp, + onComplete, + onNext, + onPrevious, + }) => { + const { configuration } = useContext(ConfigurationContext); + const interfaceConfig = configuration?.interface; + const uiConfig = configuration?.ui; + const videoRef = useRef(null); + const [_player, setPlayer] = useState(); + const sceneId = useRef(); + const [sceneSaveActivity] = useSceneSaveActivity(); + const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); - const [time, setTime] = useState(0); - const [ready, setReady] = useState(false); + const [time, setTime] = useState(0); + const [ready, setReady] = useState(false); - const { - interactive: interactiveClient, - uploadScript, - currentScript, - initialised: interactiveInitialised, - state: interactiveState, - } = React.useContext(InteractiveContext); + const { + interactive: interactiveClient, + uploadScript, + currentScript, + initialised: interactiveInitialised, + state: interactiveState, + } = React.useContext(InteractiveContext); - const [fullscreen, setFullscreen] = useState(false); - const [showScrubber, setShowScrubber] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [showScrubber, setShowScrubber] = useState(false); - const started = useRef(false); - const auto = useRef(false); - const interactiveReady = useRef(false); - const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; - const trackActivity = uiConfig?.trackActivity ?? true; - const vrTag = uiConfig?.vrTag ?? undefined; + const started = useRef(false); + const auto = useRef(false); + const interactiveReady = useRef(false); + const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; + const trackActivity = uiConfig?.trackActivity ?? true; + const vrTag = uiConfig?.vrTag ?? undefined; - useScript( - "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1", - uiConfig?.enableChromecast - ); - - const file = useMemo( - () => (scene.files.length > 0 ? scene.files[0] : undefined), - [scene] - ); - - const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0; - const looping = useMemo( - () => - !!file?.duration && - permitLoop && - maxLoopDuration !== 0 && - file.duration < maxLoopDuration, - [file, permitLoop, maxLoopDuration] - ); - - const getPlayer = useCallback(() => { - if (!_player) return null; - if (_player.isDisposed()) return null; - return _player; - }, [_player]); - - useEffect(() => { - if (hideScrubberOverride || fullscreen) { - setShowScrubber(false); - return; - } - - const onResize = () => { - const show = window.innerHeight >= 450 && !ScreenUtils.isMobile(); - setShowScrubber(show); - }; - onResize(); - - window.addEventListener("resize", onResize); - - return () => window.removeEventListener("resize", onResize); - }, [hideScrubberOverride, fullscreen]); - - useEffect(() => { - sendSetTimestamp((value: number) => { - const player = getPlayer(); - if (player && value >= 0) { - if (player.hasStarted() && player.paused()) { - player.currentTime(value); - } else { - player.play()?.then(() => { - player.currentTime(value); - }); - } - } - }); - }, [sendSetTimestamp, getPlayer]); - - // Initialize VideoJS player - useEffect(() => { - const options: VideoJsPlayerOptions = { - id: VIDEO_PLAYER_ID, - controls: true, - controlBar: { - pictureInPictureToggle: false, - volumePanel: { - inline: false, - }, - chaptersButton: false, - }, - html5: { - dash: { - updateSettings: [ - { - streaming: { - buffer: { - bufferTimeAtTopQuality: 30, - bufferTimeAtTopQualityLongForm: 30, - }, - gaps: { - jumpGaps: false, - jumpLargeGaps: false, - }, - }, - }, - ], - }, - }, - nativeControlsForTouch: false, - playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], - inactivityTimeout: 2000, - preload: "none", - playsinline: true, - techOrder: ["chromecast", "html5"], - userActions: { - hotkeys: function (this: VideoJsPlayer, event) { - handleHotkeys(this, event); - }, - }, - plugins: { - airPlay: {}, - chromecast: {}, - vttThumbnails: { - showTimestamp: true, - }, - markers: {}, - sourceSelector: {}, - persistVolume: {}, - bigButtons: {}, - seekButtons: { - forward: 10, - back: 10, - }, - skipButtons: {}, - trackActivity: {}, - vrMenu: {}, - abLoopPlugin: { - start: 0, - end: false, - enabled: false, - loopIfBeforeStart: true, - loopIfAfterEnd: true, - pauseAfterLooping: false, - pauseBeforeLooping: false, - createButtons: uiConfig?.showAbLoopControls ?? false, - }, - }, - }; - - const videoEl = document.createElement("video-js"); - videoEl.setAttribute("data-vjs-player", "true"); - videoEl.setAttribute("crossorigin", "anonymous"); - videoEl.classList.add("vjs-big-play-centered"); - videoRef.current!.appendChild(videoEl); - - const vjs = videojs(videoEl, options); - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const settings = (vjs as any).textTrackSettings; - settings.setValues({ - backgroundColor: "#000", - backgroundOpacity: "0.5", - }); - settings.updateDisplay(); - - vjs.focus(); - setPlayer(vjs); - - // Video player destructor - return () => { - vjs.dispose(); - videoEl.remove(); - setPlayer(undefined); - - // reset sceneId to force reload sources - sceneId.current = undefined; - }; - // empty deps - only init once - // showAbLoopControls is necessary to re-init the player when the config changes - }, [uiConfig?.showAbLoopControls]); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - const skipButtons = player.skipButtons(); - skipButtons.setForwardHandler(onNext); - skipButtons.setBackwardHandler(onPrevious); - }, [getPlayer, onNext, onPrevious]); - - useEffect(() => { - if (scene.interactive && interactiveInitialised) { - interactiveReady.current = false; - uploadScript(scene.paths.funscript || "").then(() => { - interactiveReady.current = true; - }); - } - }, [ - uploadScript, - interactiveInitialised, - scene.interactive, - scene.paths.funscript, - ]); - - // play the script if video started before script upload finished - useEffect(() => { - if (interactiveState !== ConnectionState.Ready) return; - const player = getPlayer(); - if (!player || player.paused()) return; - interactiveClient.ensurePlaying(player.currentTime()); - }, [interactiveState, getPlayer, interactiveClient]); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - const vrMenu = player.vrMenu(); - - let showButton = false; - - if (vrTag) { - showButton = scene.tags.some((tag) => vrTag === tag.name); - } - - vrMenu.setShowButton(showButton); - }, [getPlayer, scene, vrTag]); - - // Player event handlers - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - function canplay(this: VideoJsPlayer) { - // if we're seeking before starting, don't set the initial timestamp - // when starting from the beginning, there is a small delay before the event - // is triggered, so we can't just check if the time is 0 - if (this.currentTime() >= 0.1) { - return; - } - } - - function playing(this: VideoJsPlayer) { - // This still runs even if autoplay failed on Safari, - // only set flag if actually playing - if (!started.current && !this.paused()) { - started.current = true; - } - } - - function loadstart(this: VideoJsPlayer) { - setReady(true); - } - - function fullscreenchange(this: VideoJsPlayer) { - setFullscreen(this.isFullscreen()); - } - - player.on("canplay", canplay); - player.on("playing", playing); - player.on("loadstart", loadstart); - player.on("fullscreenchange", fullscreenchange); - - return () => { - player.off("canplay", canplay); - player.off("playing", playing); - player.off("loadstart", loadstart); - player.off("fullscreenchange", fullscreenchange); - }; - }, [getPlayer]); - - // delay before second play event after a play event to adjust for video player issues - const DELAY_FOR_SECOND_PLAY_MS = 1000; - const playingTimer = useRef(); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - function playing(this: VideoJsPlayer) { - if (scene.interactive && interactiveReady.current) { - interactiveClient.play(this.currentTime()); - // trigger a second script play event to adjust for video player issues - clearTimeout(playingTimer.current); - playingTimer.current = setTimeout(() => { - if (this.paused()) return; - interactiveClient.play(this.currentTime()); - }, DELAY_FOR_SECOND_PLAY_MS); - } - } - - function pause(this: VideoJsPlayer) { - interactiveClient.pause(); - } - - function timeupdate(this: VideoJsPlayer) { - if (this.paused()) return; - setTime(this.currentTime()); - } - - player.on("playing", playing); - player.on("pause", pause); - player.on("timeupdate", timeupdate); - - return () => { - player.off("playing", playing); - player.off("pause", pause); - player.off("timeupdate", timeupdate); - clearTimeout(playingTimer.current); - }; - }, [getPlayer, interactiveClient, scene]); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - // don't re-initialise the player unless the scene has changed - if (!file || scene.id === sceneId.current) return; - - sceneId.current = scene.id; - - setReady(false); - - // reset on new scene - player.trackActivity().reset(); - - // always stop the interactive client on initialisation - interactiveClient.pause(); - - const isSafari = UAParser().browser.name?.includes("Safari"); - const isLandscape = file.height && file.width && file.width > file.height; - const mobileUiOptions = { - fullscreen: { - enterOnRotate: true, - exitOnRotate: true, - lockOnRotate: true, - lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled - ? false - : isLandscape, - }, - touchControls: { - disabled: true, - }, - }; - if (!isSafari) { - player.mobileUi(mobileUiOptions); - } - - function isDirect(src: URL) { - return ( - src.pathname.endsWith("/stream") || - src.pathname.endsWith("/stream.mpd") || - src.pathname.endsWith("/stream.m3u8") - ); - } - - const { duration } = file; - const sourceSelector = player.sourceSelector(); - sourceSelector.setSources( - scene.sceneStreams - .filter((stream) => { - const src = new URL(stream.url); - const isFileTranscode = !isDirect(src); - - return !(isFileTranscode && isSafari); - }) - .map((stream) => { - const src = new URL(stream.url); - - return { - src: stream.url, - type: stream.mime_type ?? undefined, - label: stream.label ?? undefined, - offset: !isDirect(src), - duration, - }; - }) + useScript( + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1", + uiConfig?.enableChromecast ); - function getDefaultLanguageCode() { - let languageCode = window.navigator.language; + const file = useMemo( + () => (scene.files.length > 0 ? scene.files[0] : undefined), + [scene] + ); - if (languageCode.indexOf("-") !== -1) { - languageCode = languageCode.split("-")[0]; + const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0; + const looping = useMemo( + () => + !!file?.duration && + permitLoop && + maxLoopDuration !== 0 && + file.duration < maxLoopDuration, + [file, permitLoop, maxLoopDuration] + ); + + const getPlayer = useCallback(() => { + if (!_player) return null; + if (_player.isDisposed()) return null; + return _player; + }, [_player]); + + useEffect(() => { + if (hideScrubberOverride || fullscreen) { + setShowScrubber(false); + return; } - if (languageCode.indexOf("_") !== -1) { - languageCode = languageCode.split("_")[0]; - } + const onResize = () => { + const show = window.innerHeight >= 450 && !ScreenUtils.isMobile(); + setShowScrubber(show); + }; + onResize(); - return languageCode; - } + window.addEventListener("resize", onResize); - if (scene.captions && scene.captions.length > 0) { - const languageCode = getDefaultLanguageCode(); - let hasDefault = false; + return () => window.removeEventListener("resize", onResize); + }, [hideScrubberOverride, fullscreen]); - for (let caption of scene.captions) { - const lang = caption.language_code; - let label = lang; - if (languageMap.has(lang)) { - label = languageMap.get(lang)!; + useEffect(() => { + sendSetTimestamp((value: number) => { + const player = getPlayer(); + if (player && value >= 0) { + if (player.hasStarted() && player.paused()) { + player.currentTime(value); + } else { + player.play()?.then(() => { + player.currentTime(value); + }); + } } + }); + }, [sendSetTimestamp, getPlayer]); - label = label + " (" + caption.caption_type + ")"; - const setAsDefault = !hasDefault && languageCode == lang; - if (setAsDefault) { - hasDefault = true; - } - sourceSelector.addTextTrack( - { - src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`, - kind: "captions", - srclang: lang, - label: label, - default: setAsDefault, + // Initialize VideoJS player + useEffect(() => { + const options: VideoJsPlayerOptions = { + id: VIDEO_PLAYER_ID, + controls: true, + controlBar: { + pictureInPictureToggle: false, + volumePanel: { + inline: false, }, - false + chaptersButton: false, + }, + html5: { + dash: { + updateSettings: [ + { + streaming: { + buffer: { + bufferTimeAtTopQuality: 30, + bufferTimeAtTopQualityLongForm: 30, + }, + gaps: { + jumpGaps: false, + jumpLargeGaps: false, + }, + }, + }, + ], + }, + }, + nativeControlsForTouch: false, + playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + inactivityTimeout: 2000, + preload: "none", + playsinline: true, + techOrder: ["chromecast", "html5"], + userActions: { + hotkeys: function (this: VideoJsPlayer, event) { + handleHotkeys(this, event); + }, + }, + plugins: { + airPlay: {}, + chromecast: {}, + vttThumbnails: { + showTimestamp: true, + }, + markers: {}, + sourceSelector: {}, + persistVolume: {}, + bigButtons: {}, + seekButtons: { + forward: 10, + back: 10, + }, + skipButtons: {}, + trackActivity: {}, + vrMenu: {}, + abLoopPlugin: { + start: 0, + end: false, + enabled: false, + loopIfBeforeStart: true, + loopIfAfterEnd: true, + pauseAfterLooping: false, + pauseBeforeLooping: false, + createButtons: uiConfig?.showAbLoopControls ?? false, + }, + }, + }; + + const videoEl = document.createElement("video-js"); + videoEl.setAttribute("data-vjs-player", "true"); + videoEl.setAttribute("crossorigin", "anonymous"); + videoEl.classList.add("vjs-big-play-centered"); + videoRef.current!.appendChild(videoEl); + + const vjs = videojs(videoEl, options); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const settings = (vjs as any).textTrackSettings; + settings.setValues({ + backgroundColor: "#000", + backgroundOpacity: "0.5", + }); + settings.updateDisplay(); + + vjs.focus(); + setPlayer(vjs); + + // Video player destructor + return () => { + vjs.dispose(); + videoEl.remove(); + setPlayer(undefined); + + // reset sceneId to force reload sources + sceneId.current = undefined; + }; + // empty deps - only init once + // showAbLoopControls is necessary to re-init the player when the config changes + }, [uiConfig?.showAbLoopControls]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + const skipButtons = player.skipButtons(); + skipButtons.setForwardHandler(onNext); + skipButtons.setBackwardHandler(onPrevious); + }, [getPlayer, onNext, onPrevious]); + + useEffect(() => { + if (scene.interactive && interactiveInitialised) { + interactiveReady.current = false; + uploadScript(scene.paths.funscript || "").then(() => { + interactiveReady.current = true; + }); + } + }, [ + uploadScript, + interactiveInitialised, + scene.interactive, + scene.paths.funscript, + ]); + + // play the script if video started before script upload finished + useEffect(() => { + if (interactiveState !== ConnectionState.Ready) return; + const player = getPlayer(); + if (!player || player.paused()) return; + interactiveClient.ensurePlaying(player.currentTime()); + }, [interactiveState, getPlayer, interactiveClient]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + const vrMenu = player.vrMenu(); + + let showButton = false; + + if (vrTag) { + showButton = scene.tags.some((tag) => vrTag === tag.name); + } + + vrMenu.setShowButton(showButton); + }, [getPlayer, scene, vrTag]); + + // Player event handlers + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + function canplay(this: VideoJsPlayer) { + // if we're seeking before starting, don't set the initial timestamp + // when starting from the beginning, there is a small delay before the event + // is triggered, so we can't just check if the time is 0 + if (this.currentTime() >= 0.1) { + return; + } + } + + function playing(this: VideoJsPlayer) { + // This still runs even if autoplay failed on Safari, + // only set flag if actually playing + if (!started.current && !this.paused()) { + started.current = true; + } + } + + function loadstart(this: VideoJsPlayer) { + setReady(true); + } + + function fullscreenchange(this: VideoJsPlayer) { + setFullscreen(this.isFullscreen()); + } + + player.on("canplay", canplay); + player.on("playing", playing); + player.on("loadstart", loadstart); + player.on("fullscreenchange", fullscreenchange); + + return () => { + player.off("canplay", canplay); + player.off("playing", playing); + player.off("loadstart", loadstart); + player.off("fullscreenchange", fullscreenchange); + }; + }, [getPlayer]); + + // delay before second play event after a play event to adjust for video player issues + const DELAY_FOR_SECOND_PLAY_MS = 1000; + const playingTimer = useRef(); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + function playing(this: VideoJsPlayer) { + if (scene.interactive && interactiveReady.current) { + interactiveClient.play(this.currentTime()); + // trigger a second script play event to adjust for video player issues + clearTimeout(playingTimer.current); + playingTimer.current = setTimeout(() => { + if (this.paused()) return; + interactiveClient.play(this.currentTime()); + }, DELAY_FOR_SECOND_PLAY_MS); + } + } + + function pause(this: VideoJsPlayer) { + interactiveClient.pause(); + } + + function timeupdate(this: VideoJsPlayer) { + if (this.paused()) return; + setTime(this.currentTime()); + } + + player.on("playing", playing); + player.on("pause", pause); + player.on("timeupdate", timeupdate); + + return () => { + player.off("playing", playing); + player.off("pause", pause); + player.off("timeupdate", timeupdate); + clearTimeout(playingTimer.current); + }; + }, [getPlayer, interactiveClient, scene]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + // don't re-initialise the player unless the scene has changed + if (!file || scene.id === sceneId.current) return; + + sceneId.current = scene.id; + + setReady(false); + + // reset on new scene + player.trackActivity().reset(); + + // always stop the interactive client on initialisation + interactiveClient.pause(); + + const isSafari = UAParser().browser.name?.includes("Safari"); + const isLandscape = file.height && file.width && file.width > file.height; + const mobileUiOptions = { + fullscreen: { + enterOnRotate: true, + exitOnRotate: true, + lockOnRotate: true, + lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled + ? false + : isLandscape, + }, + touchControls: { + disabled: true, + }, + }; + if (!isSafari) { + player.mobileUi(mobileUiOptions); + } + + function isDirect(src: URL) { + return ( + src.pathname.endsWith("/stream") || + src.pathname.endsWith("/stream.mpd") || + src.pathname.endsWith("/stream.m3u8") ); } - } - auto.current = - autoplay || - (interfaceConfig?.autostartVideo ?? false) || - _initialTimestamp > 0; + const { duration } = file; + const sourceSelector = player.sourceSelector(); + sourceSelector.setSources( + scene.sceneStreams + .filter((stream) => { + const src = new URL(stream.url); + const isFileTranscode = !isDirect(src); - const alwaysStartFromBeginning = - uiConfig?.alwaysStartFromBeginning ?? false; - const resumeTime = scene.resume_time ?? 0; + return !(isFileTranscode && isSafari); + }) + .map((stream) => { + const src = new URL(stream.url); - let startPosition = _initialTimestamp; - if ( - !startPosition && - !alwaysStartFromBeginning && - file.duration > resumeTime - ) { - startPosition = resumeTime; - } + return { + src: stream.url, + type: stream.mime_type ?? undefined, + label: stream.label ?? undefined, + offset: !isDirect(src), + duration, + }; + }) + ); - setTime(startPosition); + function getDefaultLanguageCode() { + let languageCode = window.navigator.language; - player.load(); - player.focus(); + if (languageCode.indexOf("-") !== -1) { + languageCode = languageCode.split("-")[0]; + } - player.ready(() => { - player.vttThumbnails().src(scene.paths.vtt ?? null); + if (languageCode.indexOf("_") !== -1) { + languageCode = languageCode.split("_")[0]; + } - if (startPosition) { - player.currentTime(startPosition); + return languageCode; } - }); - started.current = false; - }, [ - getPlayer, - file, - scene, - interactiveClient, - autoplay, - interfaceConfig?.autostartVideo, - uiConfig?.alwaysStartFromBeginning, - uiConfig?.disableMobileMediaAutoRotateEnabled, - _initialTimestamp, - ]); + if (scene.captions && scene.captions.length > 0) { + const languageCode = getDefaultLanguageCode(); + let hasDefault = false; - useEffect(() => { - return () => { - // stop the interactive client on unmount - interactiveClient.pause(); - }; - }, [interactiveClient]); + for (let caption of scene.captions) { + const lang = caption.language_code; + let label = lang; + if (languageMap.has(lang)) { + label = languageMap.get(lang)!; + } - const loadMarkers = useCallback(() => { - const player = getPlayer(); - if (!player) return; - - const markerData = scene.scene_markers.map((marker) => ({ - title: getMarkerTitle(marker), - seconds: marker.seconds, - end_seconds: marker.end_seconds ?? null, - primaryTag: marker.primary_tag, - })); - - const markers = player!.markers(); - - const uniqueTagNames = markerData - .map((marker) => marker.primaryTag.name) - .filter((value, index, self) => self.indexOf(value) === index); - - // Wait for colors - markers.findColors(uniqueTagNames); - - const showRangeTags = - !ScreenUtils.isMobile() && (uiConfig?.showRangeMarkers ?? true); - const timestampMarkers: IMarker[] = []; - const rangeMarkers: IMarker[] = []; - - if (!showRangeTags) { - for (const marker of markerData) { - timestampMarkers.push(marker); + label = label + " (" + caption.caption_type + ")"; + const setAsDefault = !hasDefault && languageCode == lang; + if (setAsDefault) { + hasDefault = true; + } + sourceSelector.addTextTrack( + { + src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`, + kind: "captions", + srclang: lang, + label: label, + default: setAsDefault, + }, + false + ); + } } - } else { - for (const marker of markerData) { - if (marker.end_seconds === null) { + + auto.current = + autoplay || + (interfaceConfig?.autostartVideo ?? false) || + _initialTimestamp > 0; + + const alwaysStartFromBeginning = + uiConfig?.alwaysStartFromBeginning ?? false; + const resumeTime = scene.resume_time ?? 0; + + let startPosition = _initialTimestamp; + if ( + !startPosition && + !alwaysStartFromBeginning && + file.duration > resumeTime + ) { + startPosition = resumeTime; + } + + setTime(startPosition); + + player.load(); + player.focus(); + + player.ready(() => { + player.vttThumbnails().src(scene.paths.vtt ?? null); + + if (startPosition) { + player.currentTime(startPosition); + } + }); + + started.current = false; + }, [ + getPlayer, + file, + scene, + interactiveClient, + autoplay, + interfaceConfig?.autostartVideo, + uiConfig?.alwaysStartFromBeginning, + uiConfig?.disableMobileMediaAutoRotateEnabled, + _initialTimestamp, + ]); + + useEffect(() => { + return () => { + // stop the interactive client on unmount + interactiveClient.pause(); + }; + }, [interactiveClient]); + + const loadMarkers = useCallback(() => { + const player = getPlayer(); + if (!player) return; + + const markerData = scene.scene_markers.map((marker) => ({ + title: getMarkerTitle(marker), + seconds: marker.seconds, + end_seconds: marker.end_seconds ?? null, + primaryTag: marker.primary_tag, + })); + + const markers = player!.markers(); + + const uniqueTagNames = markerData + .map((marker) => marker.primaryTag.name) + .filter((value, index, self) => self.indexOf(value) === index); + + // Wait for colors + markers.findColors(uniqueTagNames); + + const showRangeTags = + !ScreenUtils.isMobile() && (uiConfig?.showRangeMarkers ?? true); + const timestampMarkers: IMarker[] = []; + const rangeMarkers: IMarker[] = []; + + if (!showRangeTags) { + for (const marker of markerData) { timestampMarkers.push(marker); + } + } else { + for (const marker of markerData) { + if (marker.end_seconds === null) { + timestampMarkers.push(marker); + } else { + rangeMarkers.push(marker); + } + } + } + + requestAnimationFrame(() => { + markers.addDotMarkers(timestampMarkers); + markers.addRangeMarkers(rangeMarkers); + }); + }, [getPlayer, scene, uiConfig]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + if (scene.paths.screenshot) { + player.poster(scene.paths.screenshot); + } else { + player.poster(""); + } + + // Define the event handler outside the useEffect + const handleLoadMetadata = () => { + loadMarkers(); + }; + + // Ensure markers are added after player is fully ready and sources are loaded + if (player.readyState() >= 1) { + loadMarkers(); + } else { + player.on("loadedmetadata", handleLoadMetadata); + } + + return () => { + player.off("loadedmetadata", handleLoadMetadata); + const markers = player!.markers(); + markers.clearMarkers(); + }; + }, [getPlayer, scene, loadMarkers]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + async function saveActivity(resumeTime: number, playDuration: number) { + if (!scene.id) return; + + await sceneSaveActivity({ + variables: { + id: scene.id, + playDuration, + resume_time: resumeTime, + }, + }); + } + + async function incrementPlayCount() { + if (!scene.id) return; + + await sceneIncrementPlayCount({ + variables: { + id: scene.id, + }, + }); + } + + const activity = player.trackActivity(); + activity.saveActivity = saveActivity; + activity.incrementPlayCount = incrementPlayCount; + activity.minimumPlayPercent = minimumPlayPercent; + activity.setEnabled(trackActivity); + }, [ + getPlayer, + scene, + vrTag, + trackActivity, + minimumPlayPercent, + sceneIncrementPlayCount, + sceneSaveActivity, + ]); + + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + player.loop(looping); + interactiveClient.setLooping(looping); + }, [getPlayer, interactiveClient, looping]); + + useEffect(() => { + const player = getPlayer(); + if (!player || !ready || !auto.current) { + return; + } + + // check if we're waiting for the interactive client + if ( + scene.interactive && + interactiveClient.handyKey && + currentScript !== scene.paths.funscript + ) { + return; + } + + player.play(); + auto.current = false; + }, [getPlayer, scene, ready, interactiveClient, currentScript]); + + // Attach handler for onComplete event + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + player.on("ended", onComplete); + + return () => player.off("ended"); + }, [getPlayer, onComplete]); + + function onScrubberScroll() { + if (started.current) { + getPlayer()?.pause(); + } + } + + function onScrubberSeek(seconds: number) { + if (started.current) { + getPlayer()?.currentTime(seconds); + } else { + setTime(seconds); + } + } + + // Override spacebar to always pause/play + function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { + const player = getPlayer(); + if (!player) return; + + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + if (event.key == " ") { + event.preventDefault(); + event.stopPropagation(); + if (player.paused()) { + player.play(); } else { - rangeMarkers.push(marker); + player.pause(); } } } - requestAnimationFrame(() => { - markers.addDotMarkers(timestampMarkers); - markers.addRangeMarkers(rangeMarkers); - }); - }, [getPlayer, scene, uiConfig]); + const isPortrait = + file && file.height && file.width && file.height > file.width; - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - if (scene.paths.screenshot) { - player.poster(scene.paths.screenshot); - } else { - player.poster(""); - } - - // Define the event handler outside the useEffect - const handleLoadMetadata = () => { - loadMarkers(); - }; - - // Ensure markers are added after player is fully ready and sources are loaded - if (player.readyState() >= 1) { - loadMarkers(); - } else { - player.on("loadedmetadata", handleLoadMetadata); - } - - return () => { - player.off("loadedmetadata", handleLoadMetadata); - const markers = player!.markers(); - markers.clearMarkers(); - }; - }, [getPlayer, scene, loadMarkers]); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - async function saveActivity(resumeTime: number, playDuration: number) { - if (!scene.id) return; - - await sceneSaveActivity({ - variables: { - id: scene.id, - playDuration, - resume_time: resumeTime, - }, - }); - } - - async function incrementPlayCount() { - if (!scene.id) return; - - await sceneIncrementPlayCount({ - variables: { - id: scene.id, - }, - }); - } - - const activity = player.trackActivity(); - activity.saveActivity = saveActivity; - activity.incrementPlayCount = incrementPlayCount; - activity.minimumPlayPercent = minimumPlayPercent; - activity.setEnabled(trackActivity); - }, [ - getPlayer, - scene, - vrTag, - trackActivity, - minimumPlayPercent, - sceneIncrementPlayCount, - sceneSaveActivity, - ]); - - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - player.loop(looping); - interactiveClient.setLooping(looping); - }, [getPlayer, interactiveClient, looping]); - - useEffect(() => { - const player = getPlayer(); - if (!player || !ready || !auto.current) { - return; - } - - // check if we're waiting for the interactive client - if ( - scene.interactive && - interactiveClient.handyKey && - currentScript !== scene.paths.funscript - ) { - return; - } - - player.play(); - auto.current = false; - }, [getPlayer, scene, ready, interactiveClient, currentScript]); - - // Attach handler for onComplete event - useEffect(() => { - const player = getPlayer(); - if (!player) return; - - player.on("ended", onComplete); - - return () => player.off("ended"); - }, [getPlayer, onComplete]); - - function onScrubberScroll() { - if (started.current) { - getPlayer()?.pause(); - } + return ( +
+
+ {scene.interactive && + (interactiveState !== ConnectionState.Ready || + getPlayer()?.paused()) && } + {file && showScrubber && ( + + )} +
+ ); } - - function onScrubberSeek(seconds: number) { - if (started.current) { - getPlayer()?.currentTime(seconds); - } else { - setTime(seconds); - } - } - - // Override spacebar to always pause/play - function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { - const player = getPlayer(); - if (!player) return; - - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { - return; - } - if (event.key == " ") { - event.preventDefault(); - event.stopPropagation(); - if (player.paused()) { - player.play(); - } else { - player.pause(); - } - } - } - - const isPortrait = - file && file.height && file.width && file.height > file.width; - - return ( -
-
- {scene.interactive && - (interactiveState !== ConnectionState.Ready || - getPlayer()?.paused()) && } - {file && showScrubber && ( - - )} -
- ); -}; +); export default ScenePlayer; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 4a0c67ff1..d3e314154 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -50,6 +50,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -153,24 +154,31 @@ interface ISceneParams { id: string; } -const ScenePage: React.FC = ({ - scene, - setTimestamp, - queueScenes, - onQueueNext, - onQueuePrevious, - onQueueRandom, - onQueueSceneClicked, - onDelete, - continuePlaylist, - queueHasMoreScenes, - onQueueMoreScenes, - onQueueLessScenes, - queueStart, - collapsed, - setCollapsed, - setContinuePlaylist, -}) => { +const ScenePageTabs = PatchContainerComponent("ScenePage.Tabs"); +const ScenePageTabContent = PatchContainerComponent( + "ScenePage.TabContent" +); + +const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { + const { + scene, + setTimestamp, + queueScenes, + onQueueNext, + onQueuePrevious, + onQueueRandom, + onQueueSceneClicked, + onDelete, + continuePlaylist, + queueHasMoreScenes, + onQueueMoreScenes, + onQueueLessScenes, + queueStart, + collapsed, + setCollapsed, + setContinuePlaylist, + } = props; + const Toast = useToast(); const intl = useIntl(); const [updateScene] = useSceneUpdate(); @@ -423,126 +431,133 @@ const ScenePage: React.FC = ({ >
- - - - - - - - - - - - - {scene.galleries.length >= 1 && ( - - - {scene.galleries.length === 1 && ( - - )} + + + - )} - - - - - - - - setIsDeleteAlertOpen(true)} - /> - - - - + + + + + + + + + + {scene.galleries.length >= 1 && ( + + + {scene.galleries.length === 1 && ( + + )} + + )} + + + + + + + + setIsDeleteAlertOpen(true)} + /> + + + + + ); @@ -657,7 +672,7 @@ const ScenePage: React.FC = ({ /> ); -}; +}); const SceneLoader: React.FC> = ({ location, diff --git a/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx b/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx index a29b2b5bf..7001ae9f0 100644 --- a/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx +++ b/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx @@ -2,19 +2,23 @@ import { FormattedMessage } from "react-intl"; import { Counter } from "../Counter"; import { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; +import { PatchComponent } from "src/patch"; export const TabTitleCounter: React.FC<{ messageID: string; count: number; abbreviateCounter: boolean; -}> = ({ messageID, count, abbreviateCounter }) => { - return ( - <> - - - - ); -}; +}> = PatchComponent( + "TabTitleCounter", + ({ messageID, count, abbreviateCounter }) => { + return ( + <> + + + + ); + } +); export function useTabKey(props: { tabKey: string | undefined; diff --git a/ui/v2.5/src/components/Shared/HoverPopover.tsx b/ui/v2.5/src/components/Shared/HoverPopover.tsx index fbc10ffe0..013da7472 100644 --- a/ui/v2.5/src/components/Shared/HoverPopover.tsx +++ b/ui/v2.5/src/components/Shared/HoverPopover.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { Overlay, Popover, OverlayProps } from "react-bootstrap"; +import { PatchComponent } from "src/patch"; interface IHoverPopover { enterDelay?: number; @@ -12,72 +13,75 @@ interface IHoverPopover { target?: React.RefObject; } -export const HoverPopover: React.FC = ({ - enterDelay = 200, - leaveDelay = 200, - content, - children, - className, - placement = "top", - onOpen, - onClose, - target, -}) => { - const [show, setShow] = useState(false); - const triggerRef = useRef(null); - const enterTimer = useRef(); - const leaveTimer = useRef(); +export const HoverPopover: React.FC = PatchComponent( + "HoverPopover", + ({ + enterDelay = 200, + leaveDelay = 200, + content, + children, + className, + placement = "top", + onOpen, + onClose, + target, + }) => { + const [show, setShow] = useState(false); + const triggerRef = useRef(null); + const enterTimer = useRef(); + const leaveTimer = useRef(); - const handleMouseEnter = useCallback(() => { - window.clearTimeout(leaveTimer.current); - enterTimer.current = window.setTimeout(() => { - setShow(true); - onOpen?.(); - }, enterDelay); - }, [enterDelay, onOpen]); - - const handleMouseLeave = useCallback(() => { - window.clearTimeout(enterTimer.current); - leaveTimer.current = window.setTimeout(() => { - setShow(false); - onClose?.(); - }, leaveDelay); - }, [leaveDelay, onClose]); - - useEffect( - () => () => { - window.clearTimeout(enterTimer.current); + const handleMouseEnter = useCallback(() => { window.clearTimeout(leaveTimer.current); - }, - [] - ); + enterTimer.current = window.setTimeout(() => { + setShow(true); + onOpen?.(); + }, enterDelay); + }, [enterDelay, onOpen]); - return ( - <> -
- {children} -
- {triggerRef.current && ( - { + window.clearTimeout(enterTimer.current); + leaveTimer.current = window.setTimeout(() => { + setShow(false); + onClose?.(); + }, leaveDelay); + }, [leaveDelay, onClose]); + + useEffect( + () => () => { + window.clearTimeout(enterTimer.current); + window.clearTimeout(leaveTimer.current); + }, + [] + ); + + return ( + <> +
- + {triggerRef.current && ( + - {content} - - - )} - - ); -}; + + {content} + + + )} + + ); + } +); diff --git a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx index e5294ec3e..dd97cbc17 100644 --- a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx +++ b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Spinner } from "react-bootstrap"; import cx from "classnames"; import { useIntl } from "react-intl"; +import { PatchComponent } from "src/patch"; interface ILoadingProps { message?: JSX.Element | string; @@ -13,24 +14,26 @@ interface ILoadingProps { const CLASSNAME = "LoadingIndicator"; const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; -export const LoadingIndicator: React.FC = ({ - message, - inline = false, - small = false, - card = false, -}) => { - const intl = useIntl(); +export const LoadingIndicator: React.FC = PatchComponent( + "LoadingIndicator", + ({ message, inline = false, small = false, card = false }) => { + const intl = useIntl(); - const text = intl.formatMessage({ id: "loading.generic" }); + const text = intl.formatMessage({ id: "loading.generic" }); - return ( -
- - {text} - - {message !== "" && ( -

{message ?? text}

- )} -
- ); -}; + return ( +
+ + {text} + + {message !== "" && ( +

{message ?? text}

+ )} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index 10152f78a..79a36bd9d 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -53,6 +53,7 @@ interface IProps { url: string; type: PopoverLinkType; count: number; + showZero?: boolean; } export const PopoverCountButton: React.FC = ({ @@ -60,9 +61,14 @@ export const PopoverCountButton: React.FC = ({ url, type, count, + showZero = true, }) => { const intl = useIntl(); + if (!showZero && count === 0) { + return null; + } + // TODO - refactor - create SceneIcon, ImageIcon etc components function getIcon() { switch (type) { diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 63352aaa6..a59ac83cb 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -13,6 +13,7 @@ import { Placement } from "react-bootstrap/esm/Overlay"; import { faFolderTree } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "../Shared/Icon"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; type SceneMarkerFragment = Pick & { scene: Pick; @@ -243,66 +244,69 @@ interface ITagLinkProps { hierarchyTooltipID?: string; } -export const TagLink: React.FC = ({ - tag, - linkType = "scene", - className, - hoverPlacement, - showHierarchyIcon = false, - hierarchyTooltipID, -}) => { - const link = useMemo(() => { - switch (linkType) { - case "scene": - return NavUtils.makeTagScenesUrl(tag); - case "performer": - return NavUtils.makeTagPerformersUrl(tag); - case "studio": - return NavUtils.makeTagStudiosUrl(tag); - case "gallery": - return NavUtils.makeTagGalleriesUrl(tag); - case "image": - return NavUtils.makeTagImagesUrl(tag); - case "group": - return NavUtils.makeTagGroupsUrl(tag); - case "scene_marker": - return NavUtils.makeTagSceneMarkersUrl(tag); - case "details": - return NavUtils.makeTagUrl(tag.id ?? ""); - } - }, [tag, linkType]); +export const TagLink: React.FC = PatchComponent( + "TagLink", + ({ + tag, + linkType = "scene", + className, + hoverPlacement, + showHierarchyIcon = false, + hierarchyTooltipID, + }) => { + const link = useMemo(() => { + switch (linkType) { + case "scene": + return NavUtils.makeTagScenesUrl(tag); + case "performer": + return NavUtils.makeTagPerformersUrl(tag); + case "studio": + return NavUtils.makeTagStudiosUrl(tag); + case "gallery": + return NavUtils.makeTagGalleriesUrl(tag); + case "image": + return NavUtils.makeTagImagesUrl(tag); + case "group": + return NavUtils.makeTagGroupsUrl(tag); + case "scene_marker": + return NavUtils.makeTagSceneMarkersUrl(tag); + case "details": + return NavUtils.makeTagUrl(tag.id ?? ""); + } + }, [tag, linkType]); - const title = tag.name || ""; + const title = tag.name || ""; - const tooltip = useMemo(() => { - if (!hierarchyTooltipID) { - return <>; - } + const tooltip = useMemo(() => { + if (!hierarchyTooltipID) { + return <>; + } + + return ( + + + + ); + }, [hierarchyTooltipID]); return ( - - - + + + {title} + {showHierarchyIcon && ( + + + | + + + + )} + + ); - }, [hierarchyTooltipID]); - - return ( - - - {title} - {showHierarchyIcon && ( - - - | - - - - )} - - - ); -}; + } +); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index ef5cef559..448569523 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -1,3 +1,4 @@ +import { PatchComponent } from "src/patch"; import { Button, ButtonGroup } from "react-bootstrap"; import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; @@ -12,6 +13,7 @@ import { Icon } from "../Shared/Icon"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { useTagUpdate } from "src/core/StashService"; + interface IProps { tag: GQL.TagDataFragment; containerWidth?: number; @@ -21,16 +23,226 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const TagCard: React.FC = ({ - tag, - containerWidth, - zoomIndex, - selecting, - selected, - onSelectedChanged, -}) => { +const TagCardPopovers: React.FC = PatchComponent( + "TagCard.Popovers", + ({ tag }) => { + return ( + <> +
+ + + + + + + + + + + ); + } +); + +const TagCardOverlays: React.FC = PatchComponent( + "TagCard.Overlays", + ({ tag }) => { + const [updateTag] = useTagUpdate(); + + function renderFavoriteIcon() { + return ( + e.preventDefault()}> + + + ); + } + + function onToggleFavorite(v: boolean) { + if (tag.id) { + updateTag({ + variables: { + input: { + id: tag.id, + favorite: v, + }, + }, + }); + } + } + + return <>{renderFavoriteIcon()}; + } +); + +const TagCardDetails: React.FC = PatchComponent( + "TagCard.Details", + ({ tag }) => { + function maybeRenderDescription() { + if (tag.description) { + return ( + + ); + } + } + + function maybeRenderParents() { + if (tag.parents.length === 1) { + const parent = tag.parents[0]; + return ( +
+ {parent.name}, + }} + /> +
+ ); + } + + if (tag.parents.length > 1) { + return ( +
+ + {tag.parents.length}  + + + ), + }} + /> +
+ ); + } + } + + function maybeRenderChildren() { + if (tag.children.length > 0) { + return ( +
+ + {tag.children.length}  + + + ), + }} + /> +
+ ); + } + } + + return ( + <> + {maybeRenderDescription()} + {maybeRenderParents()} + {maybeRenderChildren()} + + ); + } +); + +const TagCardImage: React.FC = PatchComponent( + "TagCard.Image", + ({ tag }) => { + return ( + <> + {tag.name} + + ); + } +); + +const TagCardTitle: React.FC = PatchComponent( + "TagCard.Title", + ({ tag }) => { + return <>{tag.name ?? ""}; + } +); + +export const TagCard: React.FC = PatchComponent("TagCard", (props) => { + const { + tag, + containerWidth, + zoomIndex, + selecting, + selected, + onSelectedChanged, + } = props; const [cardWidth, setCardWidth] = useState(); - const [updateTag] = useTagUpdate(); useEffect(() => { if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) return; @@ -57,244 +269,20 @@ export const TagCard: React.FC = ({ setCardWidth(fittedCardWidth); }, [containerWidth, zoomIndex]); - function maybeRenderDescription() { - if (tag.description) { - return ( - - ); - } - } - function renderFavoriteIcon() { - return ( - e.preventDefault()}> - - - ); - } - - function onToggleFavorite(v: boolean) { - if (tag.id) { - updateTag({ - variables: { - input: { - id: tag.id, - favorite: v, - }, - }, - }); - } - } - function maybeRenderParents() { - if (tag.parents.length === 1) { - const parent = tag.parents[0]; - return ( -
- {parent.name}, - }} - /> -
- ); - } - - if (tag.parents.length > 1) { - return ( -
- - {tag.parents.length}  - - - ), - }} - /> -
- ); - } - } - - function maybeRenderChildren() { - if (tag.children.length > 0) { - return ( -
- - {tag.children.length}  - - - ), - }} - /> -
- ); - } - } - - function maybeRenderScenesPopoverButton() { - if (!tag.scene_count) return; - - return ( - - ); - } - - function maybeRenderSceneMarkersPopoverButton() { - if (!tag.scene_marker_count) return; - - return ( - - ); - } - - function maybeRenderImagesPopoverButton() { - if (!tag.image_count) return; - - return ( - - ); - } - - function maybeRenderGalleriesPopoverButton() { - if (!tag.gallery_count) return; - - return ( - - ); - } - - function maybeRenderPerformersPopoverButton() { - if (!tag.performer_count) return; - - return ( - - ); - } - - function maybeRenderStudiosPopoverButton() { - if (!tag.studio_count) return; - - return ( - - ); - } - - function maybeRenderGroupsPopoverButton() { - if (!tag.group_count) return; - - return ( - - ); - } - - function maybeRenderPopoverButtonGroup() { - if (tag) { - return ( - <> -
- - {maybeRenderScenesPopoverButton()} - {maybeRenderImagesPopoverButton()} - {maybeRenderGalleriesPopoverButton()} - {maybeRenderGroupsPopoverButton()} - {maybeRenderSceneMarkersPopoverButton()} - {maybeRenderPerformersPopoverButton()} - {maybeRenderStudiosPopoverButton()} - - - ); - } - } - return ( } linkClassName="tag-card-header" - image={ - {tag.name} - } - details={ - <> - {maybeRenderDescription()} - {maybeRenderParents()} - {maybeRenderChildren()} - - } - overlays={<>{renderFavoriteIcon()}} - popovers={maybeRenderPopoverButtonGroup()} + image={} + details={} + overlays={} + popovers={} selected={selected} selecting={selecting} onSelectedChanged={onSelectedChanged} /> ); -}; +}); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 760dfef54..f76b9e939 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -31,6 +31,7 @@ This namespace contains the generated graphql client interface. This is a low-le - `FontAwesomeSolid` - `Mousetrap` - `MousetrapPause` +- `ReactSelect` ### `register` @@ -147,21 +148,36 @@ Returns `void`. - `CountrySelect` - `DateInput` - `FolderSelect` +- `FrontPage` - `GalleryIDSelect` - `GallerySelect` - `GallerySelect.sort` +- `HoverPopover` - `Icon` - `ImageDetailPanel` +- `LoadingIndicator` - `ModalSetting` - `GroupIDSelect` - `GroupSelect` - `GroupSelect.sort` - `NumberSetting` +- `PerformerAppearsWithPanel` +- `PerformerCard` +- `PerformerCard.Details` +- `PerformerCard.Image` +- `PerformerCard.Overlays` +- `PerformerCard.Popovers` +- `PerformerCard.Title` - `PerformerDetailsPanel` - `PerformerDetailsPanel.DetailGroup` - `PerformerIDSelect` +- `PerformerPage` - `PerformerSelect` - `PerformerSelect.sort` +- `PerformerGalleriesPanel` +- `PerformerGroupsPanel` +- `PerformerImagesPanel` +- `PerformerScenesPanel` - `PluginRoutes` - `SceneCard` - `SceneCard.Details` @@ -169,6 +185,10 @@ Returns `void`. - `SceneCard.Overlays` - `SceneCard.Popovers` - `SceneIDSelect` +- `ScenePage` +- `ScenePage.Tabs` +- `ScenePage.TabContent` +- `ScenePlayer` - `SceneSelect` - `SceneSelect.sort` - `SelectSetting` @@ -179,6 +199,15 @@ Returns `void`. - `StudioIDSelect` - `StudioSelect` - `StudioSelect.sort` +- `TabTitleCounter` +- `TagCard` +- `TagCard.Details` +- `TagCard.Image` +- `TagCard.Overlays` +- `TagCard.Popovers` +- `TagCard.Title` +- `TagLink` +- `TabTitleCounter` - `TagIDSelect` - `TagSelect` - `TagSelect.sort` diff --git a/ui/v2.5/src/patch.tsx b/ui/v2.5/src/patch.tsx index 7f329b89a..548993a07 100644 --- a/ui/v2.5/src/patch.tsx +++ b/ui/v2.5/src/patch.tsx @@ -1,13 +1,6 @@ import React from "react"; -import { HoverPopover } from "./components/Shared/HoverPopover"; -import { TagLink } from "./components/Shared/TagLink"; -import { LoadingIndicator } from "./components/Shared/LoadingIndicator"; -export const components: Record = { - HoverPopover, - TagLink, - LoadingIndicator, -}; +export let components: Record = {}; const beforeFns: Record = {}; const insteadFns: Record = {}; @@ -118,10 +111,12 @@ export function PatchComponent( } // patches a component and registers it in the pluginapi components object -export function PatchContainerComponent( +export function PatchContainerComponent( component: string -): React.FC> { - const fn = (props: React.PropsWithChildren<{}>) => { +): React.FC> { + const fn: React.FC> = ( + props: React.PropsWithChildren + ) => { return <>{props.children}; }; diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 21629dda5..d1c79b3bb 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -616,6 +616,7 @@ declare namespace PluginApi { const FontAwesomeSolid: typeof import("@fortawesome/free-solid-svg-icons"); const Intl: typeof import("react-intl"); const Mousetrap: typeof import("mousetrap"); + const ReactSelect: typeof import("react-select"); // @ts-expect-error import { MousetrapStatic } from "mousetrap"; @@ -688,6 +689,29 @@ declare namespace PluginApi { StringListSetting: React.FC; ConstantSetting: React.FC; SceneFileInfoPanel: React.FC; + PerformerPage: React.FC; + PerformerAppearsWithPanel: React.FC; + PerformerGalleriesPanel: React.FC; + PerformerGroupsPanel: React.FC; + PerformerScenesPanel: React.FC; + PerformerImagesPanel: React.FC; + TabTitleCounter: React.FC; + PerformerCard: React.FC; + "PerformerCard.Popovers": React.FC; + "PerformerCard.Details": React.FC; + "PerformerCard.Overlays": React.FC; + "PerformerCard.Image": React.FC; + "PerformerCard.Title": React.FC; + "TagCard.Popovers": React.FC; + "TagCard.Details": React.FC; + "TagCard.Overlays": React.FC; + "TagCard.Image": React.FC; + "TagCard.Title": React.FC; + ScenePage: React.FC; + "ScenePage.Tabs": React.FC; + "ScenePage.TabContent": React.FC; + ScenePlayer: React.FC; + FrontPage: React.FC; }; type PatchableComponentNames = keyof typeof components | string; namespace utils { diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index ddb5f4871..f94502fc4 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -11,6 +11,7 @@ import * as Bootstrap from "react-bootstrap"; import * as Intl from "react-intl"; import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons"; import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons"; +import * as ReactSelect from "react-select"; import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; @@ -73,6 +74,7 @@ export const PluginApi = { FontAwesomeSolid, Mousetrap, MousetrapPause, + ReactSelect, }, register: { // register a route to be added to the main router