From e3b3fbbf630dbafd1f26325e1943af280e1e7335 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:12:06 -0800 Subject: [PATCH] FR: Add Duration Slider to Sidebar Filters (#6264) --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../List/Filters/SidebarDurationFilter.tsx | 360 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 30 ++ ui/v2.5/src/components/Scenes/SceneList.tsx | 9 + .../components/Shared/DoubleRangeInput.tsx | 61 +++ ui/v2.5/src/components/Shared/styles.scss | 97 +++++ ui/v2.5/src/models/list-filter/scenes.ts | 5 +- 6 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx create mode 100644 ui/v2.5/src/components/Shared/DoubleRangeInput.tsx diff --git a/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx new file mode 100644 index 000000000..ff4b780af --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx @@ -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 = ({ + 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 = ( + handleMinInputChange(e.target.value)} + onBlur={handleMinInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder="0:00" + /> + } + maxInput={ + 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 ( + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index df50430a2..1b5b4c6e1 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -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; +} diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3936be6f2..14baf7188 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -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" /> + } + option={DurationCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="duration" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx b/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx new file mode 100644 index 000000000..1d8e7fbfe --- /dev/null +++ b/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx @@ -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 ( +
+
+ {minInput} + {maxInput} +
+
+ { + const rawValue = parseInt(e.target.value); + if (rawValue < maxValue) { + onChange([rawValue, maxValue]); + } + }} + className="double-range-slider double-range-slider-min" + /> + { + const rawValue = parseInt(e.target.value); + if (rawValue > minValue) { + onChange([minValue, rawValue]); + } + }} + className="double-range-slider double-range-slider-max" + /> +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index faaa00c53..8eaa3b90a 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -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; + } +} diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 8bd3918f9..09c60e483 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -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"),