-
+
+
+
{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 ?? 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()}
+ />
+
{