mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
FR: Add Duration Slider to Sidebar Filters (#6264)
--------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
c99825a453
commit
e3b3fbbf63
6 changed files with 561 additions and 1 deletions
360
ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx
Normal file
360
ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
import { CriterionOption } from "../../../models/list-filter/criteria/criterion";
|
||||
import { DurationCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
|
||||
interface ISidebarFilter {
|
||||
title?: React.ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
// Duration presets in seconds
|
||||
const DURATION_PRESETS = [
|
||||
{ id: "0-5", label: "0-5 min", min: 0, max: 300 },
|
||||
{ id: "5-10", label: "5-10 min", min: 300, max: 600 },
|
||||
{ id: "10-20", label: "10-20 min", min: 600, max: 1200 },
|
||||
{ id: "20-40", label: "20-40 min", min: 1200, max: 2400 },
|
||||
{ id: "40+", label: "40+ min", min: 2400, max: null },
|
||||
];
|
||||
|
||||
const MAX_DURATION = 7200; // 2 hours in seconds for the slider
|
||||
const MAX_LABEL = "2+ hrs"; // Display label for maximum duration
|
||||
|
||||
// Custom step values: 0, 2min (120s), 5min (300s), then 5 minute intervals
|
||||
const DURATION_STEPS = [
|
||||
0, 120, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000, 3300, 3600,
|
||||
3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 6900, 7200,
|
||||
];
|
||||
|
||||
// Snap a value to the nearest valid step
|
||||
function snapToStep(value: number): number {
|
||||
if (value <= 0) return 0;
|
||||
if (value >= MAX_DURATION) return MAX_DURATION;
|
||||
|
||||
// Find the closest step
|
||||
let closest = DURATION_STEPS[0];
|
||||
let minDiff = Math.abs(value - closest);
|
||||
|
||||
for (const step of DURATION_STEPS) {
|
||||
const diff = Math.abs(value - step);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = step;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
export const SidebarDurationFilter: React.FC<ISidebarFilter> = ({
|
||||
title,
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const criteria = filter.criteriaFor(option.type) as DurationCriterion[];
|
||||
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||
|
||||
// Get current values from criterion
|
||||
const currentMin = criterion?.value?.value ?? 0;
|
||||
const currentMax = criterion?.value?.value2 ?? MAX_DURATION;
|
||||
|
||||
const [sliderMin, setSliderMin] = useState(currentMin);
|
||||
const [sliderMax, setSliderMax] = useState(currentMax);
|
||||
const [minInput, setMinInput] = useState(
|
||||
currentMin === 0 ? "0m" : TextUtils.secondsAsTimeString(currentMin)
|
||||
);
|
||||
const [maxInput, setMaxInput] = useState(
|
||||
currentMax >= MAX_DURATION
|
||||
? MAX_LABEL
|
||||
: TextUtils.secondsAsTimeString(currentMax)
|
||||
);
|
||||
|
||||
// Reset slider when criterion is removed externally (via filter tag X)
|
||||
useEffect(() => {
|
||||
if (!criterion) {
|
||||
setSliderMin(0);
|
||||
setSliderMax(MAX_DURATION);
|
||||
setMinInput("0m");
|
||||
setMaxInput(MAX_LABEL);
|
||||
}
|
||||
}, [criterion]);
|
||||
|
||||
// Determine which preset is selected
|
||||
const selectedPreset = useMemo(() => {
|
||||
if (!criterion) return null;
|
||||
|
||||
// Check if current values match any preset
|
||||
for (const preset of DURATION_PRESETS) {
|
||||
if (preset.max === null) {
|
||||
// For "40+ min" preset
|
||||
if (
|
||||
criterion.modifier === CriterionModifier.GreaterThan &&
|
||||
criterion.value.value === preset.min
|
||||
) {
|
||||
return preset.id;
|
||||
}
|
||||
} else {
|
||||
// For range presets
|
||||
if (
|
||||
criterion.modifier === CriterionModifier.Between &&
|
||||
criterion.value.value === preset.min &&
|
||||
criterion.value.value2 === preset.max
|
||||
) {
|
||||
return preset.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a custom range or custom GreaterThan
|
||||
if (
|
||||
criterion.modifier === CriterionModifier.Between ||
|
||||
criterion.modifier === CriterionModifier.GreaterThan
|
||||
) {
|
||||
return "custom";
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [criterion]);
|
||||
|
||||
const options: Option[] = useMemo(() => {
|
||||
return DURATION_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
className: "duration-preset",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const selected: Option[] = useMemo(() => {
|
||||
if (!selectedPreset) return [];
|
||||
if (selectedPreset === "custom") return [];
|
||||
|
||||
const preset = DURATION_PRESETS.find((p) => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
return [
|
||||
{
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
className: "duration-preset",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}, [selectedPreset]);
|
||||
|
||||
function onSelectPreset(item: Option) {
|
||||
const preset = DURATION_PRESETS.find((p) => p.id === item.id);
|
||||
if (!preset) return;
|
||||
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
|
||||
if (preset.max === null) {
|
||||
// "40+ min" - use GreaterThan
|
||||
newCriterion.modifier = CriterionModifier.GreaterThan;
|
||||
newCriterion.value.value = preset.min;
|
||||
newCriterion.value.value2 = undefined;
|
||||
} else {
|
||||
// Range preset - use Between
|
||||
newCriterion.modifier = CriterionModifier.Between;
|
||||
newCriterion.value.value = preset.min;
|
||||
newCriterion.value.value2 = preset.max;
|
||||
}
|
||||
|
||||
setSliderMin(preset.min);
|
||||
setSliderMax(preset.max ?? MAX_DURATION);
|
||||
setMinInput(
|
||||
preset.min === 0 ? "0m" : TextUtils.secondsAsTimeString(preset.min)
|
||||
);
|
||||
setMaxInput(
|
||||
preset.max === null
|
||||
? MAX_LABEL
|
||||
: TextUtils.secondsAsTimeString(preset.max)
|
||||
);
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
function onUnselectPreset() {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
setSliderMin(0);
|
||||
setSliderMax(MAX_DURATION);
|
||||
setMinInput("0m");
|
||||
setMaxInput(MAX_LABEL);
|
||||
}
|
||||
|
||||
// Parse time input (supports formats like "10", "1:30", "1:30:00", "2+ hrs")
|
||||
function parseTimeInput(input: string): number | null {
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
|
||||
if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) {
|
||||
return MAX_DURATION;
|
||||
}
|
||||
|
||||
// Try to parse as pure number (minutes)
|
||||
const minutesOnly = parseFloat(trimmed);
|
||||
if (!isNaN(minutesOnly) && trimmed.indexOf(":") === -1) {
|
||||
return Math.round(minutesOnly * 60);
|
||||
}
|
||||
|
||||
// Parse HH:MM:SS or MM:SS format
|
||||
const parts = trimmed.split(":").map((p) => parseInt(p));
|
||||
if (parts.some(isNaN)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
// MM:SS
|
||||
return parts[0] * 60 + parts[1];
|
||||
} else if (parts.length === 3) {
|
||||
// HH:MM:SS
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debounced filter update
|
||||
function updateFilter(min: number, max: number) {
|
||||
// If slider is at full range (0 to max), remove the filter entirely
|
||||
if (min === 0 && max >= MAX_DURATION) {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
return;
|
||||
}
|
||||
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
|
||||
// If max is at MAX_DURATION (but min > 0), use GreaterThan
|
||||
if (max >= MAX_DURATION) {
|
||||
newCriterion.modifier = CriterionModifier.GreaterThan;
|
||||
newCriterion.value.value = min;
|
||||
newCriterion.value.value2 = undefined;
|
||||
} else {
|
||||
newCriterion.modifier = CriterionModifier.Between;
|
||||
newCriterion.value.value = min;
|
||||
newCriterion.value.value2 = max;
|
||||
}
|
||||
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
const updateFilterDebounceMS = 300;
|
||||
const debounceUpdateFilter = useDebounce(
|
||||
updateFilter,
|
||||
updateFilterDebounceMS
|
||||
);
|
||||
|
||||
function handleSliderChange(min: number, max: number) {
|
||||
if (min < 0 || max > MAX_DURATION || min >= max) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSliderMin(min);
|
||||
setSliderMax(max);
|
||||
setMinInput(min === 0 ? "0m" : TextUtils.secondsAsTimeString(min));
|
||||
setMaxInput(
|
||||
max >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(max)
|
||||
);
|
||||
|
||||
debounceUpdateFilter(min, max);
|
||||
}
|
||||
|
||||
function handleMinInputChange(value: string) {
|
||||
setMinInput(value);
|
||||
}
|
||||
|
||||
function handleMaxInputChange(value: string) {
|
||||
setMaxInput(value);
|
||||
}
|
||||
|
||||
function handleMinInputBlur() {
|
||||
const parsed = parseTimeInput(minInput);
|
||||
if (parsed !== null && parsed >= 0 && parsed < sliderMax) {
|
||||
handleSliderChange(parsed, sliderMax);
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
setMinInput(
|
||||
sliderMin === 0 ? "0m" : TextUtils.secondsAsTimeString(sliderMin)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMaxInputBlur() {
|
||||
const parsed = parseTimeInput(maxInput);
|
||||
if (parsed !== null && parsed > sliderMin && parsed <= MAX_DURATION) {
|
||||
handleSliderChange(sliderMin, parsed);
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
setMaxInput(
|
||||
sliderMax >= MAX_DURATION
|
||||
? MAX_LABEL
|
||||
: TextUtils.secondsAsTimeString(sliderMax)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const customSlider = (
|
||||
<DoubleRangeInput
|
||||
className="duration-slider"
|
||||
minInput={
|
||||
<input
|
||||
type="text"
|
||||
className="duration-label-input"
|
||||
value={minInput}
|
||||
onChange={(e) => handleMinInputChange(e.target.value)}
|
||||
onBlur={handleMinInputBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="0:00"
|
||||
/>
|
||||
}
|
||||
maxInput={
|
||||
<input
|
||||
type="text"
|
||||
className="duration-label-input"
|
||||
value={maxInput}
|
||||
onChange={(e) => handleMaxInputChange(e.target.value)}
|
||||
onBlur={handleMaxInputBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder={MAX_LABEL}
|
||||
/>
|
||||
}
|
||||
min={0}
|
||||
max={MAX_DURATION}
|
||||
value={[sliderMin, sliderMax]}
|
||||
onChange={(vals) => {
|
||||
handleSliderChange(snapToStep(vals[0]), snapToStep(vals[1]));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelectPreset}
|
||||
onUnselect={onUnselectPreset}
|
||||
selected={selected}
|
||||
singleValue
|
||||
preCandidates={selectedPreset === null ? customSlider : undefined}
|
||||
preSelected={
|
||||
selectedPreset === "custom" || selectedPreset ? customSlider : undefined
|
||||
}
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1400,3 +1400,33 @@ input[type="range"].zoom-slider {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration slider styles
|
||||
.duration-slider {
|
||||
padding: 0.5rem 0 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.duration-label-input {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: $text-color;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.25rem;
|
||||
width: 4rem;
|
||||
|
||||
&:hover {
|
||||
border-color: $secondary;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $primary;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.duration-preset {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ import { RatingCriterionOption } from "src/models/list-filter/criteria/rating";
|
|||
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
|
||||
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
|
||||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { DurationCriterionOption } from "src/models/list-filter/scenes";
|
||||
import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter";
|
||||
import {
|
||||
FilteredSidebarHeader,
|
||||
useFilteredSidebarKeybinds,
|
||||
|
|
@ -320,6 +322,13 @@ const SidebarContent: React.FC<{
|
|||
setFilter={setFilter}
|
||||
sectionID="rating"
|
||||
/>
|
||||
<SidebarDurationFilter
|
||||
title={<FormattedMessage id="duration" />}
|
||||
option={DurationCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="duration"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
data-type={OrganizedCriterionOption.type}
|
||||
|
|
|
|||
61
ui/v2.5/src/components/Shared/DoubleRangeInput.tsx
Normal file
61
ui/v2.5/src/components/Shared/DoubleRangeInput.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from "react";
|
||||
|
||||
export const DoubleRangeInput: React.FC<{
|
||||
className?: string;
|
||||
minInput: React.ReactNode;
|
||||
maxInput: React.ReactNode;
|
||||
min?: number;
|
||||
max: number;
|
||||
value: [number, number];
|
||||
onChange(value: [number, number]): void;
|
||||
}> = ({
|
||||
className = "",
|
||||
minInput,
|
||||
maxInput,
|
||||
min = 0,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const minValue = value[0];
|
||||
const maxValue = value[1];
|
||||
|
||||
return (
|
||||
<div className={`double-range-input ${className}`}>
|
||||
<div className="double-range-input-labels">
|
||||
{minInput}
|
||||
{maxInput}
|
||||
</div>
|
||||
<div className="double-range-sliders">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
value={minValue}
|
||||
onChange={(e) => {
|
||||
const rawValue = parseInt(e.target.value);
|
||||
if (rawValue < maxValue) {
|
||||
onChange([rawValue, maxValue]);
|
||||
}
|
||||
}}
|
||||
className="double-range-slider double-range-slider-min"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
value={maxValue}
|
||||
onChange={(e) => {
|
||||
const rawValue = parseInt(e.target.value);
|
||||
if (rawValue > minValue) {
|
||||
onChange([minValue, rawValue]);
|
||||
}
|
||||
}}
|
||||
className="double-range-slider double-range-slider-max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -949,3 +949,100 @@ $sticky-header-height: calc(50px + 3.3rem);
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration slider styles
|
||||
.duration-slider-container {
|
||||
padding: 0.5rem 0 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.double-range-input-labels {
|
||||
color: $text-color;
|
||||
display: flex;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
|
||||
.duration-label-input {
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.double-range-sliders {
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.double-range-slider {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
background-color: $primary;
|
||||
border: 2px solid $primary;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
height: 18px;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
appearance: none;
|
||||
background-color: $primary;
|
||||
border: 2px solid $primary;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
height: 18px;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
appearance: none;
|
||||
background-color: $primary;
|
||||
border: 2px solid $primary;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
height: 18px;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.double-range-slider-min {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input[type="range"].double-range-slider-max {
|
||||
z-index: 2;
|
||||
|
||||
// combining these into one rule doesn't work for some reason
|
||||
&::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-ms-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ const displayModeOptions = [
|
|||
DisplayMode.Tagger,
|
||||
];
|
||||
|
||||
export const DurationCriterionOption =
|
||||
createDurationCriterionOption("duration");
|
||||
|
||||
const criterionOptions = [
|
||||
createStringCriterionOption("title"),
|
||||
createStringCriterionOption("code", "scene_code"),
|
||||
|
|
@ -98,7 +101,7 @@ const criterionOptions = [
|
|||
createMandatoryNumberCriterionOption("bitrate"),
|
||||
createStringCriterionOption("video_codec"),
|
||||
createStringCriterionOption("audio_codec"),
|
||||
createDurationCriterionOption("duration"),
|
||||
DurationCriterionOption,
|
||||
createDurationCriterionOption("resume_time"),
|
||||
createDurationCriterionOption("play_duration"),
|
||||
createMandatoryNumberCriterionOption("play_count"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue