From d5e9030768ceac626d789b356bc26fc6d29d5510 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Mar 2021 14:36:11 +1100 Subject: [PATCH] Scene queuing (#1214) * Add missing localisation strings * Ignore container error in scene streams * Implement missing FindScenes by ID --- pkg/api/resolver_query_find_scene.go | 17 +- pkg/manager/scene.go | 7 +- .../src/components/Changelog/Changelog.tsx | 1 - .../src/components/Changelog/versions/v070.md | 7 +- .../components/ScenePlayer/ScenePlayer.tsx | 8 + .../src/components/ScenePlayer/styles.scss | 12 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 46 +++-- .../src/components/Scenes/SceneCardsGrid.tsx | 43 ++++ .../Scenes/SceneDetails/QueueViewer.tsx | 153 ++++++++++++++ .../components/Scenes/SceneDetails/Scene.tsx | 188 +++++++++++++++++- ui/v2.5/src/components/Scenes/SceneList.tsx | 85 ++++---- ui/v2.5/src/components/Scenes/styles.scss | 46 ++++- ui/v2.5/src/core/StashService.ts | 8 + ui/v2.5/src/locale/en-GB.json | 3 +- ui/v2.5/src/locale/en-US.json | 3 +- ui/v2.5/src/models/list-filter/filter.ts | 9 +- ui/v2.5/src/models/sceneQueue.ts | 116 +++++++++++ 17 files changed, 677 insertions(+), 75 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx create mode 100644 ui/v2.5/src/models/sceneQueue.ts diff --git a/pkg/api/resolver_query_find_scene.go b/pkg/api/resolver_query_find_scene.go index ae8eec249..44a111646 100644 --- a/pkg/api/resolver_query_find_scene.go +++ b/pkg/api/resolver_query_find_scene.go @@ -59,12 +59,25 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input models.SceneH return scene, nil } -func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIds []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) { +func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { - scenes, total, err := repo.Scene().Query(sceneFilter, filter) + var scenes []*models.Scene + var total int + var err error + + if len(sceneIDs) > 0 { + scenes, err = repo.Scene().FindMany(sceneIDs) + if err == nil { + total = len(scenes) + } + } else { + scenes, total, err = repo.Scene().Query(sceneFilter, filter) + } + if err != nil { return err } + ret = &models.FindScenesResultType{ Count: total, Scenes: scenes, diff --git a/pkg/manager/scene.go b/pkg/manager/scene.go index 52ff02e5f..d40d673e9 100644 --- a/pkg/manager/scene.go +++ b/pkg/manager/scene.go @@ -243,10 +243,9 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami if scene.AudioCodec.Valid { audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) } - container, err := GetSceneFileContainer(scene) - if err != nil { - return nil, err - } + + // don't care if we can't get the container + container, _ := GetSceneFileContainer(scene) if HasTranscode(scene, config.GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { label := "Direct stream" diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 127a7db4d..a00beacf7 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -52,7 +52,6 @@ const Changelog: React.FC = () => { date="2021-03-29" openState={openState} setOpenState={setVersionOpenState} - defaultOpen > diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index da685f461..d7f2c5451 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,3 +1,8 @@ +### ✨ New Features +* Added scene queue. + ### 🎨 Improvements -* Fix incorrect performer age calculation in UI. * Change performer text query to search by name and alias only. + +### 🐛 Bug fixes +* Fix incorrect performer age calculation in UI. \ No newline at end of file diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 038358f5d..054eb3f2e 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -15,6 +15,7 @@ interface IScenePlayerProps { onReady?: () => void; onSeeked?: () => void; onTime?: () => void; + onComplete?: () => void; config?: GQL.ConfigInterfaceDataFragment; } interface IScenePlayerState { @@ -142,6 +143,12 @@ export class ScenePlayerImpl extends React.Component< } } + private onComplete() { + if (this.props?.onComplete) { + this.props.onComplete(); + } + } + private onScrubberSeek(seconds: number) { this.player.seek(seconds); } @@ -307,6 +314,7 @@ export class ScenePlayerImpl extends React.Component< onReady={this.onReady} onSeeked={this.onSeeked} onTime={this.onTime} + onOneHundredPercent={() => this.onComplete()} /> = ({ interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; + compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + onSceneClicked?: () => void; } export const SceneCard: React.FC = ( @@ -286,13 +288,14 @@ export const SceneCard: React.FC = ( function maybeRenderPopoverButtonGroup() { if ( - props.scene.tags.length > 0 || - props.scene.performers.length > 0 || - props.scene.movies.length > 0 || - props.scene.scene_markers.length > 0 || - props.scene?.o_counter || - props.scene.galleries.length > 0 || - props.scene.organized + !props.compact && + (props.scene.tags.length > 0 || + props.scene.performers.length > 0 || + props.scene.movies.length > 0 || + props.scene.scene_markers.length > 0 || + props.scene?.o_counter || + props.scene.galleries.length > 0 || + props.scene.organized) ) { return ( <> @@ -319,6 +322,9 @@ export const SceneCard: React.FC = ( if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, shiftKey); event.preventDefault(); + } else if (props.onSceneClicked) { + props.onSceneClicked(); + event.preventDefault(); } } @@ -348,10 +354,16 @@ export const SceneCard: React.FC = ( return height > width; } + function zoomIndex() { + if (!props.compact && props.zoomIndex !== undefined) { + return `zoom-${props.zoomIndex}`; + } + } + let shiftKey = false; return ( - + = (
- + + +
{props.scene.date}

diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx new file mode 100644 index 000000000..8d9fb68c6 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneCard } from "./SceneCard"; + +interface ISceneCardsGrid { + scenes: GQL.SlimSceneDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSceneClick?: (id: string, index: number) => void; +} + +export const SceneCardsGrid: React.FC = ({ + scenes, + selectedIds, + zoomIndex, + onSelectChange, + onSceneClick, +}) => { + function sceneClicked(sceneID: string, index: number) { + if (onSceneClick) { + onSceneClick(sceneID, index); + } + } + + return ( +

+ {scenes.map((scene, index) => ( + 0} + selected={selectedIds.has(scene.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(scene.id, selected, shiftKey) + } + onSceneClicked={() => sceneClicked(scene.id, index)} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx new file mode 100644 index 000000000..8ecf615cd --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import cx from "classnames"; +import * as GQL from "src/core/generated-graphql"; +import { TextUtils } from "src/utils"; +import { Button, Spinner } from "react-bootstrap"; +import { Icon } from "src/components/Shared"; + +export interface IPlaylistViewer { + scenes?: GQL.SlimSceneDataFragment[]; + currentID?: string; + start?: number; + hasMoreScenes: boolean; + onSceneClicked: (id: string) => void; + onNext: () => void; + onPrevious: () => void; + onRandom: () => void; + onMoreScenes: () => void; + onLessScenes: () => void; +} + +export const QueueViewer: React.FC = ({ + scenes, + currentID, + start, + hasMoreScenes, + onNext, + onPrevious, + onRandom, + onSceneClicked, + onMoreScenes, + onLessScenes, +}) => { + const [lessLoading, setLessLoading] = useState(false); + const [moreLoading, setMoreLoading] = useState(false); + + const currentIndex = scenes?.findIndex((s) => s.id === currentID); + + useEffect(() => { + setLessLoading(false); + setMoreLoading(false); + }, [scenes]); + + function isCurrentScene(scene: GQL.SlimSceneDataFragment) { + return scene.id === currentID; + } + + function handleSceneClick( + event: React.MouseEvent, + id: string + ) { + onSceneClicked(id); + event.preventDefault(); + } + + function lessClicked() { + setLessLoading(true); + onLessScenes(); + } + + function moreClicked() { + setMoreLoading(true); + onMoreScenes(); + } + + function renderPlaylistEntry(scene: GQL.SlimSceneDataFragment) { + return ( +
  • + handleSceneClick(e, scene.id)} + > +
    +
    + {scene.title +
    +
    + + {scene.title ?? TextUtils.fileNameFromPath(scene.path)} + +
    +
    + +
  • + ); + } + + return ( +
    +
    +
    + {(currentIndex ?? 0) > 0 ? ( + + ) : ( + "" + )} + {(currentIndex ?? 0) < (scenes ?? []).length - 1 ? ( + + ) : ( + "" + )} + +
    +
    +
    + {(start ?? 0) > 1 ? ( +
    + +
    + ) : undefined} +
      {(scenes ?? []).map(renderPlaylistEntry)}
    + {hasMoreScenes ? ( +
    + +
    + ) : undefined} +
    +
    + ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 3c8ed2fec..599d2502e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -12,6 +12,8 @@ import { useSceneStreams, useSceneGenerateScreenshot, useSceneUpdate, + queryFindScenes, + queryFindScenesByID, } from "src/core/StashService"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; @@ -19,6 +21,10 @@ import { useToast } from "src/hooks"; import { ScenePlayer } from "src/components/ScenePlayer"; import { TextUtils, JWUtils } from "src/utils"; import Mousetrap from "mousetrap"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { FilterMode } from "src/models/list-filter/types"; +import { SceneQueue } from "src/models/sceneQueue"; +import { QueueViewer } from "./QueueViewer"; import { SceneMarkersPanel } from "./SceneMarkersPanel"; import { SceneFileInfoPanel } from "./SceneFileInfoPanel"; import { SceneEditPanel } from "./SceneEditPanel"; @@ -65,8 +71,59 @@ export const Scene: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); + const [sceneQueue, setSceneQueue] = useState(new SceneQueue()); + const [queueScenes, setQueueScenes] = useState( + [] + ); + + const [queueTotal, setQueueTotal] = useState(0); + const [queueStart, setQueueStart] = useState(1); + + const [rerenderPlayer, setRerenderPlayer] = useState(false); + const queryParams = queryString.parse(location.search); const autoplay = queryParams?.autoplay === "true"; + const currentQueueIndex = queueScenes.findIndex((s) => s.id === id); + + async function getQueueFilterScenes(filter: ListFilterModel) { + const query = await queryFindScenes(filter); + const { scenes, count } = query.data.findScenes; + setQueueScenes(scenes); + setQueueTotal(count); + setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1); + } + + async function getQueueScenes(sceneIDs: number[]) { + const query = await queryFindScenesByID(sceneIDs); + const { scenes, count } = query.data.findScenes; + setQueueScenes(scenes); + setQueueTotal(count); + setQueueStart(1); + } + + // HACK - jwplayer doesn't handle re-rendering when scene changes, so force + // a rerender by not drawing it + useEffect(() => { + if (rerenderPlayer) { + setRerenderPlayer(false); + } + }, [rerenderPlayer]); + + useEffect(() => { + setRerenderPlayer(true); + }, [id]); + + useEffect(() => { + setSceneQueue(SceneQueue.fromQueryParameters(location.search)); + }, [location.search]); + + useEffect(() => { + if (sceneQueue.query) { + getQueueFilterScenes(sceneQueue.query); + } else if (sceneQueue.sceneIDs) { + getQueueScenes(sceneQueue.sceneIDs); + } + }, [sceneQueue]); function getInitialTimestamp() { const params = queryString.parse(location.search); @@ -158,6 +215,99 @@ export const Scene: React.FC = () => { Toast.success({ content: "Generating screenshot" }); } + async function onQueueLessScenes() { + if (!sceneQueue.query || queueStart <= 1) { + return; + } + + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + const newStart = queueStart - filterCopy.itemsPerPage; + filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); + const query = await queryFindScenes(filterCopy); + const { scenes } = query.data.findScenes; + + // prepend scenes to scene list + const newScenes = scenes.concat(queueScenes); + setQueueScenes(newScenes); + setQueueStart(newStart); + } + + function queueHasMoreScenes() { + return queueStart + queueScenes.length - 1 < queueTotal; + } + + async function onQueueMoreScenes() { + if (!sceneQueue.query || !queueHasMoreScenes()) { + return; + } + + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + const newStart = queueStart + queueScenes.length; + filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); + const query = await queryFindScenes(filterCopy); + const { scenes } = query.data.findScenes; + + // append scenes to scene list + const newScenes = scenes.concat(queueScenes); + setQueueScenes(newScenes); + // don't change queue start + } + + function playScene(sceneID: string, page?: number) { + sceneQueue.playScene(history, sceneID, { + newPage: page, + autoPlay: true, + }); + } + + function onQueueNext() { + if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) { + playScene(queueScenes[currentQueueIndex + 1].id); + } + } + + function onQueuePrevious() { + if (currentQueueIndex > 0) { + playScene(queueScenes[currentQueueIndex - 1].id); + } + } + + async function onQueueRandom() { + if (sceneQueue.query) { + const { query } = sceneQueue; + const pages = Math.ceil(queueTotal / query.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + const index = Math.floor(Math.random() * query.itemsPerPage); + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + filterCopy.currentPage = page; + const queryResults = await queryFindScenes(filterCopy); + if (queryResults.data.findScenes.scenes.length > index) { + const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index]; + // navigate to the image player page + playScene(sceneID, page); + } + } else { + const index = Math.floor(Math.random() * queueTotal); + playScene(queueScenes[index].id); + } + } + + function onComplete() { + // load the next scene if we're autoplaying + if (autoplay) { + onQueueNext(); + } + } + function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { @@ -255,6 +405,13 @@ export const Scene: React.FC = () => { Details + {(queueScenes ?? []).length > 0 ? ( + + Queue + + ) : ( + "" + )} Markers @@ -310,6 +467,20 @@ export const Scene: React.FC = () => { + + playScene(sceneID)} + onNext={onQueueNext} + onPrevious={onQueuePrevious} + onRandom={onQueueRandom} + start={queueStart} + hasMoreScenes={queueHasMoreScenes()} + onLessScenes={() => onQueueLessScenes()} + onMoreScenes={() => onQueueMoreScenes()} + /> + {
    - + {!rerenderPlayer ? ( + + ) : undefined}
    ); diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index e0667e26a..7dc01c2c6 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -12,13 +12,14 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import Tagger from "src/components/Tagger"; +import { SceneQueue } from "src/models/sceneQueue"; import { WallPanel } from "../Wall/WallPanel"; -import { SceneCard } from "./SceneCard"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; +import { SceneCardsGrid } from "./SceneCardsGrid"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -35,6 +36,11 @@ export const SceneList: React.FC = ({ const [isExportAll, setIsExportAll] = useState(false); const otherOperations = [ + { + text: "Play selected", + onClick: playSelected, + isDisplayed: showWhenSelected, + }, { text: "Play Random", onClick: playRandom, @@ -85,6 +91,17 @@ export const SceneList: React.FC = ({ persistState, }); + async function playSelected( + result: FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + // populate queue and go to first scene + const sceneIDs = Array.from(selectedIds.values()); + const queue = SceneQueue.fromSceneIDList(sceneIDs); + queue.playScene(history, sceneIDs[0], { autoPlay: true }); + } + async function playRandom( result: FindScenesQueryResult, filter: ListFilterModel @@ -93,20 +110,18 @@ export const SceneList: React.FC = ({ if (result.data && result.data.findScenes) { const { count } = result.data.findScenes; - const index = Math.floor(Math.random() * count); + const pages = Math.ceil(count / filter.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + const index = Math.floor(Math.random() * filter.itemsPerPage); const filterCopy = _.cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindScenes(filterCopy); - if ( - singleResult && - singleResult.data && - singleResult.data.findScenes && - singleResult.data.findScenes.scenes.length === 1 - ) { - const { id } = singleResult!.data!.findScenes!.scenes[0]; - // navigate to the scene player page - history.push(`/scenes/${id}?autoplay=true`); + filterCopy.currentPage = page; + filterCopy.sortBy = "random"; + const queryResults = await queryFindScenes(filterCopy); + if (queryResults.data.findScenes.scenes.length > index) { + const { id } = queryResults!.data!.findScenes!.scenes[index]; + // navigate to the image player page + const queue = SceneQueue.fromListFilterModel(filterCopy, index); + queue.playScene(history, id, { autoPlay: true }); } } } @@ -125,6 +140,15 @@ export const SceneList: React.FC = ({ setIsExportDialogOpen(true); } + async function sceneClicked( + sceneId: string, + sceneIndex: number, + filter: ListFilterModel + ) { + const queue = SceneQueue.fromListFilterModel(filter, sceneIndex); + queue.playScene(history, sceneId); + } + function maybeRenderSceneGenerateDialog(selectedIds: Set) { if (isGenerateDialogOpen) { return ( @@ -171,25 +195,6 @@ export const SceneList: React.FC = ({ ); } - function renderSceneCard( - scene: SlimSceneDataFragment, - selectedIds: Set, - zoomIndex: number - ) { - return ( - 0} - selected={selectedIds.has(scene.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - listData.onSelectChange(scene.id, selected, shiftKey) - } - /> - ); - } - function renderScenes( result: FindScenesQueryResult, filter: ListFilterModel, @@ -201,11 +206,15 @@ export const SceneList: React.FC = ({ } if (filter.displayMode === DisplayMode.Grid) { return ( -
    - {result.data.findScenes.scenes.map((scene) => - renderSceneCard(scene, selectedIds, zoomIndex) - )} -
    + + listData.onSelectChange(id, selected, shiftKey) + } + onSceneClick={(id, index) => sceneClicked(id, index, filter)} + /> ); } if (filter.displayMode === DisplayMode.List) { diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e04288211..cb9c06619 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -159,6 +159,11 @@ textarea.scene-description { .scene-card, .gallery-card { + a { + color: $text-color; + text-decoration: none; + } + &-check { left: 0.5rem; margin-top: -12px; @@ -280,10 +285,15 @@ textarea.scene-description { } .scene-tabs { + display: flex; + flex-direction: column; max-height: calc(100vh - 4rem); - overflow-wrap: break-word; word-wrap: break-word; + + > div { + flex: 0 1 auto; + } } input[type="range"].filter-slider { @@ -573,3 +583,37 @@ input[type="range"].blue-slider { padding-left: 0; padding-right: 0.25rem; } + +#queue-viewer { + .queue-controls { + display: flex; + flex: 0 1 auto; + justify-content: flex-end; + } + + .thumbnail-container { + height: 50px; + margin-bottom: 5px; + margin-right: 0.75rem; + margin-top: 5px; + min-width: 100px; + width: 100px; + } + + img { + height: 100%; + object-fit: contain; + object-position: center; + width: 100%; + } + + a { + color: $text-color; + font-weight: 500; + text-decoration: none; + } + + .current { + background-color: $secondary; + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index b5639ac62..10832462d 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -76,6 +76,14 @@ export const queryFindScenes = (filter: ListFilterModel) => }, }); +export const queryFindScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindScenesDocument, + variables: { + scene_ids: sceneIDs, + }, + }); + export const useFindSceneMarkers = (filter: ListFilterModel) => GQL.useFindSceneMarkersQuery({ variables: { diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index 698445cc4..0e09f413c 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -13,5 +13,6 @@ "tags": "Tags", "up-dir": "Up a directory", "favourite": "FAVOURITE", - "sceneTagger": "Scene Tagger" + "sceneTagger": "Scene Tagger", + "donate": "Donate" } diff --git a/ui/v2.5/src/locale/en-US.json b/ui/v2.5/src/locale/en-US.json index fd6fc3098..d6ac76584 100644 --- a/ui/v2.5/src/locale/en-US.json +++ b/ui/v2.5/src/locale/en-US.json @@ -13,5 +13,6 @@ "tags": "Tags", "up-dir": "Up a directory", "favourite": "FAVORITE", - "sceneTagger": "Scene Tagger" + "sceneTagger": "Scene Tagger", + "donate": "Donate" } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e988057bb..afcb5aadf 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -390,7 +390,7 @@ export class ListFilterModel { return this.sortBy; } - public makeQueryParameters(): string { + public getQueryParameters() { const encodedCriteria: string[] = []; this.criteria.forEach((criterion) => { const encodedCriterion: Partial = { @@ -425,7 +425,12 @@ export class ListFilterModel { : undefined, c: encodedCriteria, }; - return queryString.stringify(result, { encode: false }); + + return result; + } + + public makeQueryParameters(): string { + return queryString.stringify(this.getQueryParameters(), { encode: false }); } // TODO: These don't support multiple of the same criteria, only the last one set is used. diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts new file mode 100644 index 000000000..feb0f9569 --- /dev/null +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -0,0 +1,116 @@ +import queryString from "query-string"; +import { RouteComponentProps } from "react-router-dom"; +import { ListFilterModel } from "./list-filter/filter"; +import { FilterMode } from "./list-filter/types"; + +interface IQueryParameters { + qsort?: string; + qsortd?: string; + qfq?: string; + qfp?: string; + qfc?: string[]; + qs?: string[]; +} + +export interface IPlaySceneOptions { + newPage?: number; + autoPlay?: boolean; +} + +export class SceneQueue { + public query?: ListFilterModel; + public sceneIDs?: number[]; + + public static fromListFilterModel( + filter: ListFilterModel, + currentSceneIndex?: number + ) { + const ret = new SceneQueue(); + + const filterCopy = Object.assign( + new ListFilterModel(filter.filterMode), + filter + ); + filterCopy.itemsPerPage = 40; + + // adjust page to be correct for the index + const filterIndex = + currentSceneIndex !== undefined + ? currentSceneIndex + (filter.currentPage - 1) * filter.itemsPerPage + : 0; + const newPage = Math.floor(filterIndex / filterCopy.itemsPerPage) + 1; + filterCopy.currentPage = newPage; + + ret.query = filterCopy; + return ret; + } + + public static fromSceneIDList(sceneIDs: string[]) { + const ret = new SceneQueue(); + ret.sceneIDs = sceneIDs.map((v) => Number(v)); + return ret; + } + + private makeQueryParameters(page?: number) { + if (this.query) { + const queryParams = this.query.getQueryParameters(); + const translatedParams = { + qfp: queryParams.p ?? 1, + qfc: queryParams.c, + qfq: queryParams.q, + qsort: queryParams.sortby, + qsortd: queryParams.sortdir, + }; + + if (page !== undefined) { + translatedParams.qfp = page; + } + + return queryString.stringify(translatedParams, { encode: false }); + } + + if (this.sceneIDs && this.sceneIDs.length > 0) { + const params = { + qs: this.sceneIDs, + }; + return queryString.stringify(params, { encode: false }); + } + + return ""; + } + + public static fromQueryParameters(params: string) { + const ret = new SceneQueue(); + const parsed = queryString.parse(params) as IQueryParameters; + const translated = { + sortby: parsed.qsort, + sortdir: parsed.qsortd, + q: parsed.qfq, + p: parsed.qfp, + c: parsed.qfc, + }; + + if (parsed.qfp) { + const query = new ListFilterModel( + FilterMode.Scenes, + translated as queryString.ParsedQuery + ); + ret.query = query; + } else if (parsed.qs) { + // must be scene list + ret.sceneIDs = parsed.qs.map((v) => Number(v)); + } + + return ret; + } + + public playScene( + history: RouteComponentProps["history"], + sceneID: string, + options?: IPlaySceneOptions + ) { + const paramStr = this.makeQueryParameters(options?.newPage); + const autoplayParam = options?.autoPlay ? "&autoplay=true" : ""; + history.push(`/scenes/${sceneID}?${paramStr}${autoplayParam}`); + } +}