Scene queuing (#1214)

* Add missing localisation strings
* Ignore container error in scene streams
* Implement missing FindScenes by ID
This commit is contained in:
WithoutPants 2021-03-31 14:36:11 +11:00 committed by GitHub
parent 496900df42
commit d5e9030768
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 677 additions and 75 deletions

View File

@ -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,

View File

@ -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"

View File

@ -52,7 +52,6 @@ const Changelog: React.FC = () => {
date="2021-03-29"
openState={openState}
setOpenState={setVersionOpenState}
defaultOpen
>
<MarkdownPage page={V060} />
</Version>

View File

@ -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.

View File

@ -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}

View File

@ -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 {

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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: {

View File

@ -13,5 +13,6 @@
"tags": "Tags",
"up-dir": "Up a directory",
"favourite": "FAVOURITE",
"sceneTagger": "Scene Tagger"
"sceneTagger": "Scene Tagger",
"donate": "Donate"
}

View File

@ -13,5 +13,6 @@
"tags": "Tags",
"up-dir": "Up a directory",
"favourite": "FAVORITE",
"sceneTagger": "Scene Tagger"
"sceneTagger": "Scene Tagger",
"donate": "Donate"
}

View File

@ -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.

View File

@ -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}`);
}
}