import React, { useCallback, useEffect, useMemo } from "react"; import cloneDeep from "lodash-es/cloneDeep"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue"; import { SceneWallPanel } from "./SceneWallPanel"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { SceneCardsGrid } from "./SceneCardsGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { useConfigurationContext } from "src/hooks/Config"; import { faPencil, faPlay, faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { View } from "../List/views"; import { FileSize } from "../Shared/FileSize"; import { LoadedContent } from "../List/PagedList"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; import { OperationDropdown, OperationDropdownItem, } from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; import { Sidebar, SidebarPane, SidebarPaneContent, SidebarStateContext, useSidebarState, } from "../Shared/Sidebar"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers"; import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios"; import { TagsCriterionOption } from "src/models/list-filter/criteria/tags"; import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; import cx from "classnames"; import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { DurationCriterionOption, PerformerAgeCriterionOption, } from "src/models/list-filter/scenes"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { PatchContainerComponent } from "src/patch"; import { Pagination } from "../List/Pagination"; import { Button, ButtonGroup } from "react-bootstrap"; import { Icon } from "../Shared/Icon"; import useFocus from "src/utils/focus"; import { FilteredListToolbar2, ToolbarFilterSection, ToolbarSelectionSection, } from "../List/ListToolbar"; import { ListResultsHeader } from "../List/ListResultsHeader"; import { useZoomKeybinds } from "../List/ZoomSlider"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; const size = result?.data?.findScenes?.filesize; if (!duration && !size) { return; } const separator = duration && size ? " - " : ""; return (  ( {duration ? ( {TextUtils.secondsAsTimeString(duration, 3)} ) : undefined} {separator} {size ? ( ) : undefined} ) ); } function usePlayScene() { const history = useHistory(); const { configuration: config } = useConfigurationContext(); const cont = config?.interface.continuePlaylistDefault ?? false; const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; const playScene = useCallback( (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { history.push( queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) ); }, [history, cont, autoPlay] ); return playScene; } function usePlaySelected(selectedIds: Set) { const playScene = usePlayScene(); const playSelected = useCallback(() => { // populate queue and go to first scene const sceneIDs = Array.from(selectedIds.values()); const queue = SceneQueue.fromSceneIDList(sceneIDs); playScene(queue, sceneIDs[0]); }, [selectedIds, playScene]); return playSelected; } function usePlayFirst() { const playScene = usePlayScene(); const playFirst = useCallback( (queue: SceneQueue, sceneID: string, index: number) => { // populate queue and go to first scene playScene(queue, sceneID, { sceneIndex: index }); }, [playScene] ); return playFirst; } function usePlayRandom(filter: ListFilterModel, count: number) { const playScene = usePlayScene(); const playRandom = useCallback(async () => { // query for a random scene if (count === 0) { return; } const pages = Math.ceil(count / filter.itemsPerPage); const page = Math.floor(Math.random() * pages) + 1; const indexMax = Math.min(filter.itemsPerPage, count); const index = Math.floor(Math.random() * indexMax); const filterCopy = cloneDeep(filter); filterCopy.currentPage = page; filterCopy.sortBy = "random"; const queryResults = await queryFindScenes(filterCopy); const scene = queryResults.data.findScenes.scenes[index]; if (scene) { // navigate to the image player page const queue = SceneQueue.fromListFilterModel(filterCopy); playScene(queue, scene.id, { sceneIndex: index }); } }, [filter, count, playScene]); return playRandom; } function useAddKeybinds(filter: ListFilterModel, count: number) { const playRandom = usePlayRandom(filter, count); useEffect(() => { Mousetrap.bind("p r", () => { playRandom(); }); return () => { Mousetrap.unbind("p r"); }; }, [playRandom]); } const SceneList: React.FC<{ scenes: GQL.SlimSceneDataFragment[]; filter: ListFilterModel; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; }> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } if (filter.displayMode === DisplayMode.Grid) { return ( ); } if (filter.displayMode === DisplayMode.List) { return ( ); } if (filter.displayMode === DisplayMode.Wall) { return ( ); } if (filter.displayMode === DisplayMode.Tagger) { return ; } return null; }; const ScenesFilterSidebarSections = PatchContainerComponent( "FilteredSceneList.SidebarSections" ); const SidebarContent: React.FC<{ filter: ListFilterModel; setFilter: (filter: ListFilterModel) => void; filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; count?: number; focus?: ReturnType; }> = ({ filter, setFilter, filterHook, view, showEditFilter, sidebarOpen, onClose, count, focus, }) => { const showResultsId = count !== undefined ? "actions.show_count_results" : "actions.show_results"; const hideStudios = view === View.StudioScenes; return ( <> {!hideStudios && ( } data-type={StudiosCriterionOption.type} option={StudiosCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} sectionID="studios" /> )} } data-type={PerformersCriterionOption.type} option={PerformersCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} sectionID="performers" /> } data-type={TagsCriterionOption.type} option={TagsCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} sectionID="tags" /> } data-type={RatingCriterionOption.type} option={RatingCriterionOption} filter={filter} setFilter={setFilter} sectionID="rating" /> } option={DurationCriterionOption} filter={filter} setFilter={setFilter} sectionID="duration" /> } data-type={HasMarkersCriterionOption.type} option={HasMarkersCriterionOption} filter={filter} setFilter={setFilter} sectionID="hasMarkers" /> } data-type={OrganizedCriterionOption.type} option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} sectionID="organized" /> } option={PerformerAgeCriterionOption} filter={filter} setFilter={setFilter} sectionID="performer_age" />
); }; interface IOperations { text: string; onClick: () => void; isDisplayed?: () => boolean; className?: string; } const SceneListOperations: React.FC<{ items: number; hasSelection: boolean; operations: IOperations[]; onEdit: () => void; onDelete: () => void; onPlay: () => void; onCreateNew: () => void; }> = ({ items, hasSelection, operations, onEdit, onDelete, onPlay, onCreateNew, }) => { const intl = useIntl(); return (
{!!items && ( )} {!hasSelection && ( )} {hasSelection && ( <> )} {operations.map((o) => { if (o.isDisplayed && !o.isDisplayed()) { return null; } return ( ); })}
); }; interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; view?: View; alterQuery?: boolean; fromGroupId?: string; } export const FilteredSceneList = (props: IFilteredScenes) => { const intl = useIntl(); const history = useHistory(); const searchFocus = useFocus(); const [, setSearchFocus] = searchFocus; const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States const { showSidebar, setShowSidebar, loading: sidebarStateLoading, sectionOpen, setSectionOpen, } = useSidebarState(view); const { filterState, queryResult, modalState, listSelect, showEditFilter } = useFilteredItemList({ filterStateProps: { filterMode: GQL.FilterMode.Scenes, defaultSort, view, useURL: alterQuery, }, queryResultProps: { useResult: useFindScenes, getCount: (r) => r.data?.findScenes.count ?? 0, getItems: (r) => r.data?.findScenes.scenes ?? [], filterHook, }, }); const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; const { selectedIds, selectedItems, onSelectChange, onSelectAll, onSelectNone, hasSelection, } = listSelect; const { modal, showModal, closeModal } = modalState; // Utility hooks const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ filter, setFilter, }); useAddKeybinds(filter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, }); useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { onEdit?.(); } }); Mousetrap.bind("d d", () => { if (hasSelection) { onDelete?.(); } }); return () => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, result, }); const metadataByline = useMemo(() => { if (cachedResult.loading) return null; return renderMetadataByline(cachedResult) ?? null; }, [cachedResult]); const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); const playRandom = usePlayRandom(effectiveFilter, totalCount); const playSelected = usePlaySelected(selectedIds); const playFirst = usePlayFirst(); function onCreateNew() { history.push("/scenes/new"); } function onPlay() { if (items.length === 0) { return; } // if there are selected items, play those if (hasSelection) { playSelected(); return; } // otherwise, play the first item in the list const sceneID = items[0].id; playFirst(queue, sceneID, 0); } function onExport(all: boolean) { showModal( closeModal()} /> ); } function onMerge() { const selected = selectedItems.map((s) => { return { id: s.id, title: objectTitle(s), }; }) ?? []; showModal( { closeModal(); if (mergedID) { history.push(`/scenes/${mergedID}`); } }} show /> ); } function onEdit() { showModal( ); } function onDelete() { showModal( ); } const otherOperations = [ { text: intl.formatMessage({ id: "actions.play" }), onClick: () => onPlay(), isDisplayed: () => items.length > 0, className: "play-item", }, { text: intl.formatMessage( { id: "actions.create_entity" }, { entityType: intl.formatMessage({ id: "scene" }) } ), onClick: () => onCreateNew(), isDisplayed: () => !hasSelection, className: "create-new-item", }, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), isDisplayed: () => totalCount > 0, }, { text: intl.formatMessage({ id: "actions.select_none" }), onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, isDisplayed: () => totalCount > 1, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, onClick: () => showModal( closeModal()} /> ), isDisplayed: () => hasSelection, }, { text: `${intl.formatMessage({ id: "actions.identify" })}…`, onClick: () => showModal( closeModal()} /> ), isDisplayed: () => hasSelection, }, { text: `${intl.formatMessage({ id: "actions.merge" })}…`, onClick: () => onMerge(), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), onClick: () => onExport(true), }, ]; // render if (sidebarStateLoading) return null; const operations = ( ); return (
{modal} setShowSidebar(false)}> setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} focus={searchFocus} /> setShowSidebar(!showSidebar)} onEditCriterion={(c) => showEditFilter(c?.criterionOption.type) } onRemoveCriterion={removeCriterion} onRemoveAllCriterion={() => clearAllCriteria(true)} onEditSearchTerm={() => { setShowSidebar(true); setSearchFocus(true); }} onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm()) } view={view} /> } selectionSection={ setShowSidebar(!showSidebar)} onSelectAll={() => onSelectAll()} onSelectNone={() => onSelectNone()} operations={operations} /> } operationSection={operations} /> setFilter(newFilter)} /> {totalCount > filter.itemsPerPage && (
)}
); }; export default FilteredSceneList;