mirror of https://github.com/stashapp/stash.git
Scene queuing (#1214)
* Add missing localisation strings * Ignore container error in scene streams * Implement missing FindScenes by ID
This commit is contained in:
parent
496900df42
commit
d5e9030768
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -52,7 +52,6 @@ const Changelog: React.FC = () => {
|
|||
date="2021-03-29"
|
||||
openState={openState}
|
||||
setOpenState={setVersionOpenState}
|
||||
defaultOpen
|
||||
>
|
||||
<MarkdownPage page={V060} />
|
||||
</Version>
|
||||
|
|
|
@ -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.
|
|
@ -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()}
|
||||
/>
|
||||
<ScenePlayerScrubber
|
||||
scene={this.props.scene}
|
||||
|
|
|
@ -44,11 +44,14 @@ $scrubberHeight: 120px;
|
|||
.scene-tabs,
|
||||
.scene-player-container {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-player-container {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
$sceneTabWidth: 450px;
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
@ -60,6 +63,13 @@ $sceneTabWidth: 450px;
|
|||
&.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 15rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-divider {
|
||||
|
|
|
@ -64,10 +64,12 @@ export const ScenePreview: React.FC<IScenePreviewProps> = ({
|
|||
|
||||
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<ISceneCardProps> = (
|
||||
|
@ -286,13 +288,14 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
|
||||
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<ISceneCardProps> = (
|
|||
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<ISceneCardProps> = (
|
|||
return height > width;
|
||||
}
|
||||
|
||||
function zoomIndex() {
|
||||
if (!props.compact && props.zoomIndex !== undefined) {
|
||||
return `zoom-${props.zoomIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
return (
|
||||
<Card className={`scene-card zoom-${props.zoomIndex}`}>
|
||||
<Card className={`scene-card ${zoomIndex()}`}>
|
||||
<Form.Control
|
||||
type="checkbox"
|
||||
className="scene-card-check"
|
||||
|
@ -388,14 +400,16 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
</div>
|
||||
<div className="card-section">
|
||||
<h5 className="card-section-title">
|
||||
<TruncatedText
|
||||
text={
|
||||
props.scene.title
|
||||
? props.scene.title
|
||||
: TextUtils.fileNameFromPath(props.scene.path)
|
||||
}
|
||||
lineCount={2}
|
||||
/>
|
||||
<Link to={`/scenes/${props.scene.id}`}>
|
||||
<TruncatedText
|
||||
text={
|
||||
props.scene.title
|
||||
? props.scene.title
|
||||
: TextUtils.fileNameFromPath(props.scene.path)
|
||||
}
|
||||
lineCount={2}
|
||||
/>
|
||||
</Link>
|
||||
</h5>
|
||||
<span>{props.scene.date}</span>
|
||||
<p>
|
||||
|
|
|
@ -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<string>;
|
||||
zoomIndex: number;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
onSceneClick?: (id: string, index: number) => void;
|
||||
}
|
||||
|
||||
export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
|
||||
scenes,
|
||||
selectedIds,
|
||||
zoomIndex,
|
||||
onSelectChange,
|
||||
onSceneClick,
|
||||
}) => {
|
||||
function sceneClicked(sceneID: string, index: number) {
|
||||
if (onSceneClick) {
|
||||
onSceneClick(sceneID, index);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row justify-content-center">
|
||||
{scenes.map((scene, index) => (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
zoomIndex={zoomIndex}
|
||||
selecting={selectedIds.size > 0}
|
||||
selected={selectedIds.has(scene.id)}
|
||||
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
|
||||
onSelectChange(scene.id, selected, shiftKey)
|
||||
}
|
||||
onSceneClicked={() => sceneClicked(scene.id, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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<IPlaylistViewer> = ({
|
||||
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<HTMLAnchorElement, MouseEvent>,
|
||||
id: string
|
||||
) {
|
||||
onSceneClicked(id);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function lessClicked() {
|
||||
setLessLoading(true);
|
||||
onLessScenes();
|
||||
}
|
||||
|
||||
function moreClicked() {
|
||||
setMoreLoading(true);
|
||||
onMoreScenes();
|
||||
}
|
||||
|
||||
function renderPlaylistEntry(scene: GQL.SlimSceneDataFragment) {
|
||||
return (
|
||||
<li
|
||||
className={cx("my-2", { current: isCurrentScene(scene) })}
|
||||
key={scene.id}
|
||||
>
|
||||
<Link
|
||||
to={`/scenes/${scene.id}`}
|
||||
onClick={(e) => handleSceneClick(e, scene.id)}
|
||||
>
|
||||
<div className="ml-1 d-flex align-items-center">
|
||||
<div className="thumbnail-container">
|
||||
<img alt={scene.title ?? ""} src={scene.paths.screenshot ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="align-middle">
|
||||
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="queue-viewer">
|
||||
<div className="queue-controls">
|
||||
<div>
|
||||
{(currentIndex ?? 0) > 0 ? (
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="secondary"
|
||||
onClick={() => onPrevious()}
|
||||
>
|
||||
<Icon icon="step-backward" />
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{(currentIndex ?? 0) < (scenes ?? []).length - 1 ? (
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="secondary"
|
||||
onClick={() => onNext()}
|
||||
>
|
||||
<Icon icon="step-forward" />
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="secondary"
|
||||
onClick={() => onRandom()}
|
||||
>
|
||||
<Icon icon="random" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="queue-content">
|
||||
{(start ?? 0) > 1 ? (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button onClick={() => lessClicked()} disabled={lessLoading}>
|
||||
{!lessLoading ? (
|
||||
<Icon icon="chevron-up" />
|
||||
) : (
|
||||
<Spinner animation="border" role="status" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<ol start={start}>{(scenes ?? []).map(renderPlaylistEntry)}</ol>
|
||||
{hasMoreScenes ? (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button onClick={() => moreClicked()} disabled={moreLoading}>
|
||||
{!moreLoading ? (
|
||||
<Icon icon="chevron-down" />
|
||||
) : (
|
||||
<Spinner animation="border" role="status" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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<boolean>(false);
|
||||
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||
|
||||
const [sceneQueue, setSceneQueue] = useState<SceneQueue>(new SceneQueue());
|
||||
const [queueScenes, setQueueScenes] = useState<GQL.SlimSceneDataFragment[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
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 = () => {
|
|||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-details-panel">Details</Nav.Link>
|
||||
</Nav.Item>
|
||||
{(queueScenes ?? []).length > 0 ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-queue-panel">Queue</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-markers-panel">Markers</Nav.Link>
|
||||
</Nav.Item>
|
||||
|
@ -310,6 +467,20 @@ export const Scene: React.FC = () => {
|
|||
<Tab.Pane eventKey="scene-details-panel">
|
||||
<SceneDetailPanel scene={scene} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-queue-panel">
|
||||
<QueueViewer
|
||||
scenes={queueScenes}
|
||||
currentID={scene.id}
|
||||
onSceneClicked={(sceneID) => playScene(sceneID)}
|
||||
onNext={onQueueNext}
|
||||
onPrevious={onQueuePrevious}
|
||||
onRandom={onQueueRandom}
|
||||
start={queueStart}
|
||||
hasMoreScenes={queueHasMoreScenes()}
|
||||
onLessScenes={() => onQueueLessScenes()}
|
||||
onMoreScenes={() => onQueueMoreScenes()}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-markers-panel">
|
||||
<SceneMarkersPanel
|
||||
scene={scene}
|
||||
|
@ -414,13 +585,16 @@ export const Scene: React.FC = () => {
|
|||
</Button>
|
||||
</div>
|
||||
<div className={`scene-player-container ${collapsed ? "expanded" : ""}`}>
|
||||
<ScenePlayer
|
||||
className="w-100 m-sm-auto no-gutter"
|
||||
scene={scene}
|
||||
timestamp={timestamp}
|
||||
autoplay={autoplay}
|
||||
sceneStreams={sceneStreams?.sceneStreams ?? []}
|
||||
/>
|
||||
{!rerenderPlayer ? (
|
||||
<ScenePlayer
|
||||
className="w-100 m-sm-auto no-gutter"
|
||||
scene={scene}
|
||||
timestamp={timestamp}
|
||||
autoplay={autoplay}
|
||||
sceneStreams={sceneStreams?.sceneStreams ?? []}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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<ISceneList> = ({
|
|||
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<ISceneList> = ({
|
|||
persistState,
|
||||
});
|
||||
|
||||
async function playSelected(
|
||||
result: FindScenesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
// 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<ISceneList> = ({
|
|||
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<ISceneList> = ({
|
|||
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<string>) {
|
||||
if (isGenerateDialogOpen) {
|
||||
return (
|
||||
|
@ -171,25 +195,6 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function renderSceneCard(
|
||||
scene: SlimSceneDataFragment,
|
||||
selectedIds: Set<string>,
|
||||
zoomIndex: number
|
||||
) {
|
||||
return (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
zoomIndex={zoomIndex}
|
||||
selecting={selectedIds.size > 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<ISceneList> = ({
|
|||
}
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="row justify-content-center">
|
||||
{result.data.findScenes.scenes.map((scene) =>
|
||||
renderSceneCard(scene, selectedIds, zoomIndex)
|
||||
)}
|
||||
</div>
|
||||
<SceneCardsGrid
|
||||
scenes={result.data.findScenes.scenes}
|
||||
zoomIndex={zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={(id, selected, shiftKey) =>
|
||||
listData.onSelectChange(id, selected, shiftKey)
|
||||
}
|
||||
onSceneClick={(id, index) => sceneClicked(id, index, filter)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,6 +76,14 @@ export const queryFindScenes = (filter: ListFilterModel) =>
|
|||
},
|
||||
});
|
||||
|
||||
export const queryFindScenesByID = (sceneIDs: number[]) =>
|
||||
client.query<GQL.FindScenesQuery>({
|
||||
query: GQL.FindScenesDocument,
|
||||
variables: {
|
||||
scene_ids: sceneIDs,
|
||||
},
|
||||
});
|
||||
|
||||
export const useFindSceneMarkers = (filter: ListFilterModel) =>
|
||||
GQL.useFindSceneMarkersQuery({
|
||||
variables: {
|
||||
|
|
|
@ -13,5 +13,6 @@
|
|||
"tags": "Tags",
|
||||
"up-dir": "Up a directory",
|
||||
"favourite": "FAVOURITE",
|
||||
"sceneTagger": "Scene Tagger"
|
||||
"sceneTagger": "Scene Tagger",
|
||||
"donate": "Donate"
|
||||
}
|
||||
|
|
|
@ -13,5 +13,6 @@
|
|||
"tags": "Tags",
|
||||
"up-dir": "Up a directory",
|
||||
"favourite": "FAVORITE",
|
||||
"sceneTagger": "Scene Tagger"
|
||||
"sceneTagger": "Scene Tagger",
|
||||
"donate": "Donate"
|
||||
}
|
||||
|
|
|
@ -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<Criterion> = {
|
||||
|
@ -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.
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue