Scene ui improvements (#232)

* Move duration and resolution to overlay

* Improve display of portrait videos

* Condense filter controls

* Add performer images to scene tags

* Add studio overlay to scene cards

* Fade out scene overlays on hover

* CSS grid tweaks

* Align overlay to bottom of video preview

* Fix opacity value

* Fix performer thumbnails

* Show studio overlay on mouseover

* Correct display colour for display mode buttons

* Add scene zoom slider

* Add show studio as text option

* Move select all/none to more button
This commit is contained in:
WithoutPants 2019-12-06 04:24:22 +11:00 committed by Leopere
parent c14153ab5a
commit 12c7faab4e
14 changed files with 391 additions and 53 deletions

View File

@ -17,6 +17,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
wallShowTitle
maximumLoopDuration
autostartVideo
showStudioAsText
css
cssEnabled
}

View File

@ -66,6 +66,8 @@ input ConfigInterfaceInput {
maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player"""
autostartVideo: Boolean
"""If true, studio overlays will be shown as text instead of logo images"""
showStudioAsText: Boolean
"""Custom CSS"""
css: String
cssEnabled: Boolean
@ -80,6 +82,8 @@ type ConfigInterfaceResult {
maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player"""
autostartVideo: Boolean
"""If true, studio overlays will be shown as text instead of logo images"""
showStudioAsText: Boolean
"""Custom CSS"""
css: String
cssEnabled: Boolean

View File

@ -98,6 +98,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
config.Set(config.AutostartVideo, *input.AutostartVideo)
}
if input.ShowStudioAsText != nil {
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
}
css := ""
if input.CSS != nil {

View File

@ -53,6 +53,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
wallShowTitle := config.GetWallShowTitle()
maximumLoopDuration := config.GetMaximumLoopDuration()
autostartVideo := config.GetAutostartVideo()
showStudioAsText := config.GetShowStudioAsText()
css := config.GetCSS()
cssEnabled := config.GetCSSEnabled()
@ -61,6 +62,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
WallShowTitle: &wallShowTitle,
MaximumLoopDuration: &maximumLoopDuration,
AutostartVideo: &autostartVideo,
ShowStudioAsText: &showStudioAsText,
CSS: &css,
CSSEnabled: &cssEnabled,
}

View File

@ -35,6 +35,7 @@ const SoundOnPreview = "sound_on_preview"
const WallShowTitle = "wall_show_title"
const MaximumLoopDuration = "maximum_loop_duration"
const AutostartVideo = "autostart_video"
const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled"
// Logging options
@ -191,6 +192,11 @@ func GetAutostartVideo() bool {
return viper.GetBool(AutostartVideo)
}
func GetShowStudioAsText() bool {
viper.SetDefault(ShowStudioAsText, false)
return viper.GetBool(ShowStudioAsText)
}
func GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := viper.ConfigFileUsed()

View File

@ -22,6 +22,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>();
const [showStudioAsText, setShowStudioAsText] = useState<boolean>();
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>();
@ -30,6 +31,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
wallShowTitle,
maximumLoopDuration,
autostartVideo,
showStudioAsText,
css,
cssEnabled
});
@ -42,6 +44,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false);
setCSS(config.data.configuration.interface.css || "");
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
}
@ -78,6 +81,18 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
/>
</FormGroup>
<FormGroup
label="Scene List"
>
<Checkbox
checked={showStudioAsText}
label="Show Studios as text"
onChange={() => {
setShowStudioAsText(!showStudioAsText)
}}
/>
</FormGroup>
<FormGroup
label="Scene Player"
>

View File

@ -5,6 +5,7 @@ import {
FormGroup,
HTMLSelect,
InputGroup,
Tooltip,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
@ -188,7 +189,19 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
return (
<>
<Button onClick={() => onToggle()} active={isOpen} large={true}>Filter</Button>
<Tooltip
hoverOpenDelay={200}
content="Filter"
>
<Button
icon="filter"
onClick={() => onToggle()}
active={isOpen}
large={true}
>
</Button>
</Tooltip>
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
<div className="dialog-content">
{maybeRenderFilterSelect()}

View File

@ -9,6 +9,8 @@ import {
MenuItem,
Popover,
Tag,
Tooltip,
Slider,
} from "@blueprintjs/core";
import { debounce } from "lodash";
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
@ -25,6 +27,8 @@ interface IListFilterProps {
onChangeDisplayMode: (displayMode: DisplayMode) => void;
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onRemoveCriterion: (criterion: Criterion) => void;
zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void;
onSelectAll?: () => void;
onSelectNone?: () => void;
filter: ListFilterModel;
@ -111,13 +115,14 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
}
}
return props.filter.displayModeOptions.map((option) => (
<Button
key={option}
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
icon={getIcon(option)}
text={getLabel(option)}
/>
<Tooltip content={getLabel(option)} hoverOpenDelay={200}>
<Button
key={option}
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
icon={getIcon(option)}
/>
</Tooltip>
));
}
@ -150,23 +155,63 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
function renderSelectAll() {
if (props.onSelectAll) {
return <Button onClick={() => onSelectAll()} text="Select All"/>;
return <MenuItem onClick={() => onSelectAll()} text="Select All" />;
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return <Button onClick={() => onSelectNone()} text="Select None"/>;
return <MenuItem onClick={() => onSelectNone()} text="Select None" />;
}
}
function renderSelectAllNone() {
return (
<>
{renderSelectAll()}
{renderSelectNone()}
</>
);
function renderMore() {
let options = [];
options.push(renderSelectAll());
options.push(renderSelectNone());
options = options.filter((o) => !!o);
let menuItems = options as JSX.Element[];
function renderMoreOptions() {
return (
<>
{menuItems}
</>
)
}
if (menuItems.length > 0) {
return (
<Popover position="bottom">
<Button icon="more"/>
<Menu>{renderMoreOptions()}</Menu>
</Popover>
);
}
}
function onChangeZoom(v : number) {
if (props.onChangeZoom) {
props.onChangeZoom(v);
}
}
function maybeRenderZoom() {
if (props.onChangeZoom) {
return (
<span className="zoom-slider">
<Slider
min={0}
value={props.zoomIndex}
initialValue={props.zoomIndex}
max={3}
labelRenderer={false}
onChange={(v) => onChangeZoom(v)}
/>
</span>
);
}
}
function render() {
@ -188,18 +233,23 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
value={props.filter.itemsPerPage}
className="filter-item"
/>
<ControlGroup className="filter-item">
<AnchorButton
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
onClick={onChangeSortDirection}
>
{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
</AnchorButton>
<ButtonGroup className="filter-item">
<Popover position="bottom">
<Button large={true}>{props.filter.sortBy}</Button>
<Menu>{renderSortByOptions()}</Menu>
</Popover>
</ControlGroup>
<Tooltip
content={props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
hoverOpenDelay={200}
>
<Button
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
onClick={onChangeSortDirection}
/>
</Tooltip>
</ButtonGroup>
<AddFilter
filter={props.filter}
@ -212,8 +262,10 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
{renderDisplayModeOptions()}
</ButtonGroup>
{maybeRenderZoom()}
<ButtonGroup className="filter-item">
{renderSelectAllNone()}
{renderMore()}
</ButtonGroup>
</div>
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>

View File

@ -17,10 +17,13 @@ import { ColorUtils } from "../../utils/color";
import { TextUtils } from "../../utils/text";
import { TagLink } from "../Shared/TagLink";
import { SceneHelpers } from "./helpers";
import { ZoomUtils } from "../../utils/zoom";
import { StashService } from "../../core/StashService";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
}
@ -28,6 +31,8 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
const config = StashService.useConfiguration();
const showStudioAsText = !!config.data && !!config.data.configuration ? config.data.configuration.interface.showStudioAsText : false;
function maybeRenderRatingBanner() {
if (!props.scene.rating) { return; }
@ -38,6 +43,43 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
);
}
function maybeRenderSceneSpecsOverlay() {
return (
<div className={`scene-specs-overlay`}>
{!!props.scene.file.height ? <span className={`overlay-resolution`}> {TextUtils.resolution(props.scene.file.height)}</span> : undefined}
{props.scene.file.duration !== undefined && props.scene.file.duration >= 1 ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
</div>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) {
return;
}
let style: React.CSSProperties = {
backgroundImage: `url('${props.scene.studio.image_path}')`,
};
let text = "";
if (showStudioAsText) {
style = {};
text = props.scene.studio.name;
}
return (
<div className={`scene-studio-overlay`}>
<Link
to={`/studios/${props.scene.studio.id}`}
style={style}
>
{text}
</Link>
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) { return; }
@ -58,9 +100,20 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) { return; }
const performers = props.scene.performers.map((performer) => (
<TagLink key={performer.id} performer={performer} />
));
const performers = props.scene.performers.map((performer) => {
return (
<>
<div className="performer-tag-container">
<Link
to={`/performers/${performer.id}`}
className="performer-tag previewable image"
style={{backgroundImage: `url(${performer.image_path})`}}
></Link>
<TagLink key={performer.id} performer={performer} />
</div>
</>
);
});
return (
<Popover interactionKind={"hover"} position="bottom">
<Button
@ -119,11 +172,38 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
setPreviewPath("");
}
function isPortrait() {
let file = props.scene.file;
let width = file.width ? file.width : 0;
let height = file.height ? file.height : 0;
return height > width;
}
function getLinkClassName() {
let ret = "image previewable";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
function getVideoClassName() {
let ret = "preview";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
var shiftKey = false;
return (
<Card
className="grid-item"
className={"grid-item scene-card " + ZoomUtils.classForZoom(props.zoomIndex)}
elevation={Elevation.ONE}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
@ -134,11 +214,15 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
/>
<Link to={`/scenes/${props.scene.id}`} className="image previewable">
{maybeRenderRatingBanner()}
<video className="preview" loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
{!!previewPath ? <source src={previewPath} /> : ""}
</video>
<Link to={`/scenes/${props.scene.id}`} className={getLinkClassName()}>
<div className="video-container">
{maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()}
{maybeRenderSceneStudioOverlay()}
<video className={getVideoClassName()} loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
{!!previewPath ? <source src={previewPath} /> : ""}
</video>
</div>
</Link>
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
@ -149,16 +233,6 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
</div>
{maybeRenderPopoverButtonGroup()}
<Divider />
<span className="card-section centered">
{props.scene.file.size !== undefined ? TextUtils.fileSize(parseInt(props.scene.file.size, 10)) : ""}
&nbsp;|&nbsp;
{props.scene.file.duration !== undefined ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
&nbsp;|&nbsp;
{props.scene.file.width} x {props.scene.file.height}
</span>
{SceneHelpers.maybeRenderStudio(props.scene, 50, true)}
</Card>
);
};

View File

@ -17,6 +17,7 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
const listData = ListHook.useList({
filterMode: FilterMode.Scenes,
props,
zoomable: true,
renderContent,
renderSelectedOptions
});
@ -45,23 +46,24 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
);
}
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>) {
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>, zoomIndex: number) {
return (
<SceneCard
key={scene.id}
scene={scene}
zoomIndex={zoomIndex}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
/>
)
}
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) {
if (!result.data || !result.data.findScenes) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds))}
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds, zoomIndex))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {

View File

@ -21,7 +21,8 @@ export interface IListHookData {
export interface IListHookOptions {
filterMode: FilterMode;
props: IBaseProps;
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => JSX.Element | undefined;
zoomable?: boolean
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) => JSX.Element | undefined;
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
}
@ -31,6 +32,7 @@ export class ListHook {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
const [totalCount, setTotalCount] = useState<number>(0);
const [zoomIndex, setZoomIndex] = useState<number>(1);
// Update the filter when the query parameters change
useEffect(() => {
@ -254,6 +256,10 @@ export class ListHook {
setLastClickedId(undefined);
}
function onChangeZoom(newZoomIndex : number) {
setZoomIndex(newZoomIndex);
}
const template = (
<div>
<ListFilter
@ -266,12 +272,14 @@ export class ListHook {
onRemoveCriterion={onRemoveCriterion}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
zoomIndex={options.zoomable ? zoomIndex : undefined}
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
filter={filter}
/>
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{options.renderContent(result, filter, selectedIds)}
{options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}

View File

@ -38,7 +38,7 @@ code {
flex-flow: row wrap;
justify-content: center;
margin: $pt-grid-size $pt-grid-size 0 0;
padding: 0 calc(10%);
padding: 0 100px;
&.wall {
padding: 0;
@ -67,7 +67,7 @@ code {
.grid-item {
// flex: auto;
width: calc(25% - 1.5em);
width: 320px;
min-width: 185px;
margin: 0px 0 $pt-grid-size $pt-grid-size;
overflow: hidden;
@ -76,6 +76,47 @@ code {
width: calc(20%);
margin: 0;
}
&.zoom-0 {
width: 240px;
& .previewable {
max-height: 180px;
}
& .previewable.portrait {
height: 180px;
}
}
&.zoom-1 {
width: 320px;
& .previewable {
max-height: 240px;
}
& .previewable.portrait {
height: 240px;
}
}
&.zoom-2 {
width: 480px;
& .previewable {
max-height: 360px;
}
& .previewable.portrait {
height: 360px;
}
}
&.zoom-3 {
width: 640px;
& .previewable {
max-height: 480px;
}
& .previewable.portrait {
height: 480px;
}
}
}
.previewable {
@ -85,6 +126,11 @@ code {
width: calc(100% + 40px);
margin: -20px 0 0 -20px;
position: relative;
max-height: 240px;
}
.previewable.portrait {
height: 240px;
}
.grid-item label.card-select {
@ -95,12 +141,24 @@ code {
opacity: 0.5;
}
.video-container {
width: 100%;
height: 100%;
}
video.preview {
// height: 225px; // slows down the page
width: 100%;
// width: calc(100% + 40px);
// margin: -20px 0 0 -20px;
object-fit: cover;
margin: 0 auto;
display: block;
}
video.preview.portrait {
height: 100%;
width: auto;
}
.filter-item, .operation-item {
@ -161,6 +219,67 @@ video.preview {
text-align: center;
}
.scene-specs-overlay {
display: block;
position: absolute;
bottom: 1em;
right: .7em;
font-weight: 400;
color: #f5f8fa;
letter-spacing: -.03em;
text-shadow: 0 0 3px #000;
}
.scene-studio-overlay {
display: block;
position: absolute;
top: .7em;
right: .7em;
font-weight: 900;
width: 40%;
height: 20%;
opacity: 0.75;
z-index: 9;
}
.scene-studio-overlay a {
width: 100%;
height: 100%;
background-size: contain;
display: inline-block;
background-position: right top;
background-repeat: no-repeat;
letter-spacing: -.03em;
text-shadow: 0 0 3px #000;
text-align: right;
text-decoration: none;
color: #f5f8fa;
}
.overlay-resolution {
font-weight: 900;
text-transform: uppercase;
margin-right:.3em;
}
.scene-card {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
transition: opacity 0.5s;
}
}
.scene-card:hover {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
opacity: 0;
transition: opacity 0.5s;
}
.scene-studio-overlay:hover {
opacity: 0.75;
transition: opacity 0.5s;
}
}
#jwplayer-container {
margin: 10px auto;
width: 75%;
@ -223,6 +342,19 @@ span.block {
background-repeat: no-repeat !important;
}
.performer-tag-container {
margin: 5px;
}
.performer-tag.image {
height: 150px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
margin: 0 auto;
}
.studio.image {
height: 100px;
background-size: contain !important;
@ -331,4 +463,13 @@ span.block {
float: right;
height: 30px;
}
}
.zoom-slider {
margin: auto 5px;
width: 100px;
& .bp3-slider {
min-width: 100%;
}
}

View File

@ -18,7 +18,17 @@ export class TextUtils {
}
public static secondsToTimestamp(seconds: number): string {
return new Date(seconds * 1000).toISOString().substr(11, 8);
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
if (ret.startsWith("00")) {
// strip hours if under one hour
ret = ret.substr(3);
}
if (ret.startsWith("0")) {
// for duration under a minute, leave one leading zero
ret = ret.substr(1);
}
return ret;
}
public static fileNameFromPath(path: string): string {

6
ui/v2/src/utils/zoom.ts Normal file
View File

@ -0,0 +1,6 @@
export class ZoomUtils {
public static classForZoom(zoomIndex: number): string {
return "zoom-" + zoomIndex;
}
}