From cf5d60f51181b45481b3d2cd36ba1a2587ac8bbc Mon Sep 17 00:00:00 2001 From: GammelSami <39372285+GammelSami@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:18:39 +0100 Subject: [PATCH] Added loop feature for markers + AB prefill (#6510) * add loop feature for markers + AB prefill * chore(ui): type ab loop plugin access --- ui/v2.5/src/components/ScenePlayer/util.ts | 22 +++++++- .../Scenes/SceneDetails/PrimaryTags.tsx | 25 ++++++++-- .../components/Scenes/SceneDetails/Scene.tsx | 50 ++++++++++++++++++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 38 +++++++++++--- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 3 ++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts index 8c6fb8010..21ed99b62 100644 --- a/ui/v2.5/src/components/ScenePlayer/util.ts +++ b/ui/v2.5/src/components/ScenePlayer/util.ts @@ -1,7 +1,27 @@ -import videojs from "video.js"; +import videojs, { VideoJsPlayer } from "video.js"; export const VIDEO_PLAYER_ID = "VideoJsPlayer"; export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID); export const getPlayerPosition = () => getPlayer()?.currentTime(); + +export type AbLoopOptions = { + start: number; + end: number | false; + enabled?: boolean; +}; + +export type AbLoopPluginApi = { + getOptions: () => AbLoopOptions; + setOptions: (options: AbLoopOptions) => void; +}; + +export const getAbLoopPlugin = () => { + const player = getPlayer(); + if (!player) return null; + const { abLoopPlugin } = player as VideoJsPlayer & { + abLoopPlugin?: AbLoopPluginApi; + }; + return abLoopPlugin ?? null; +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index 11c805ec6..d5a32fc31 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -4,18 +4,24 @@ import * as GQL from "src/core/generated-graphql"; import { Button, Badge, Card } from "react-bootstrap"; import TextUtils from "src/utils/text"; import { markerTitle } from "src/core/markers"; +import { useConfigurationContext } from "src/hooks/Config"; interface IPrimaryTags { sceneMarkers: GQL.SceneMarkerDataFragment[]; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; + onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; onEdit: (marker: GQL.SceneMarkerDataFragment) => void; } export const PrimaryTags: React.FC = ({ sceneMarkers, onClickMarker, + onLoopMarker, onEdit, }) => { + const { configuration } = useConfigurationContext(); + const showAbLoopControls = configuration?.ui?.showAbLoopControls; + if (!sceneMarkers?.length) return
; const primaryTagNames: Record = {}; @@ -52,10 +58,21 @@ export const PrimaryTags: React.FC = ({
-
- {TextUtils.formatTimestampRange( - marker.seconds, - marker.end_seconds ?? undefined +
+
+ {TextUtils.formatTimestampRange( + marker.seconds, + marker.end_seconds ?? undefined + )} +
+ {showAbLoopControls && marker.end_seconds != null && ( + )}
{tags}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 7c9b178c1..435b9dce2 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -32,7 +32,10 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import Mousetrap from "mousetrap"; import { OrganizedButton } from "./OrganizedButton"; import { useConfigurationContext } from "src/hooks/Config"; -import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { + getAbLoopPlugin, + getPlayerPosition, +} from "src/components/ScenePlayer/util"; import { faEllipsisV, faChevronRight, @@ -311,9 +314,53 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { }; function onClickMarker(marker: GQL.SceneMarkerDataFragment) { + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + const start = opts?.start; + const end = opts?.end; + + const hasLoopRange = + opts?.enabled && + typeof start === "number" && + typeof end === "number" && + Number.isFinite(start) && + Number.isFinite(end); + + if ( + abLoopPlugin && + opts && + hasLoopRange && + (marker.seconds < Math.min(start as number, end as number) || + marker.seconds > Math.max(start as number, end as number)) + ) { + abLoopPlugin.setOptions({ + ...opts, + enabled: false, + }); + } + setTimestamp(marker.seconds); } + function onLoopMarker(marker: GQL.SceneMarkerDataFragment) { + if (marker.end_seconds == null) return; + + setTimestamp(marker.seconds); + const start = Math.min(marker.seconds, marker.end_seconds); + const end = Math.max(marker.seconds, marker.end_seconds); + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + + if (opts && abLoopPlugin) { + abLoopPlugin.setOptions({ + ...opts, + start, + end, + enabled: true, + }); + } + } + async function onRescan() { await mutateMetadataScan({ paths: [objectPath(scene)], @@ -561,6 +608,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index ef1a2e7e1..cbb2ad4bb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -11,7 +11,10 @@ import { } from "src/core/StashService"; import { DurationInput } from "src/components/Shared/DurationInput"; import { MarkerTitleSuggest } from "src/components/Shared/Select"; -import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { + getAbLoopPlugin, + getPlayerPosition, +} from "src/components/ScenePlayer/util"; import { useToast } from "src/hooks/Toast"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; @@ -61,16 +64,39 @@ export const SceneMarkerForm: React.FC = ({ }); // useMemo to only run getPlayerPosition when the input marker actually changes - const initialValues = useMemo( - () => ({ + const initialValues = useMemo(() => { + if (!marker) { + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + const start = opts?.start; + const end = opts?.end; + const hasAbLoop = Number.isFinite(start); + + if (hasAbLoop) { + const current = Math.round(getPlayerPosition() ?? 0); + const rawEnd = + Number.isFinite(end) && (end as number) > 0 ? (end as number) : null; + const endSeconds = + rawEnd !== null ? rawEnd : Math.max(start as number, current); + + return { + title: "", + seconds: start as number, + end_seconds: endSeconds, + primary_tag_id: "", + tag_ids: [], + }; + } + } + + return { title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), end_seconds: marker?.end_seconds ?? null, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], - }), - [marker] - ); + }; + }, [marker]); type InputValues = yup.InferType; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index 331c58c78..28a6e4d98 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -11,12 +11,14 @@ interface ISceneMarkersPanelProps { sceneId: string; isVisible: boolean; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; + onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; } export const SceneMarkersPanel: React.FC = ({ sceneId, isVisible, onClickMarker, + onLoopMarker, }) => { const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ variables: { id: sceneId }, @@ -70,6 +72,7 @@ export const SceneMarkersPanel: React.FC = ({