Added loop feature for markers + AB prefill (#6510)

* add loop feature for markers + AB prefill
* chore(ui): type ab loop plugin access
This commit is contained in:
GammelSami 2026-02-04 00:18:39 +01:00 committed by GitHub
parent b76edffc5d
commit cf5d60f511
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 126 additions and 12 deletions

View file

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

View file

@ -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<IPrimaryTags> = ({
sceneMarkers,
onClickMarker,
onLoopMarker,
onEdit,
}) => {
const { configuration } = useConfigurationContext();
const showAbLoopControls = configuration?.ui?.showAbLoopControls;
if (!sceneMarkers?.length) return <div />;
const primaryTagNames: Record<string, string> = {};
@ -52,10 +58,21 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
<FormattedMessage id="actions.edit" />
</Button>
</div>
<div>
{TextUtils.formatTimestampRange(
marker.seconds,
marker.end_seconds ?? undefined
<div className="d-flex align-items-center">
<div>
{TextUtils.formatTimestampRange(
marker.seconds,
marker.end_seconds ?? undefined
)}
</div>
{showAbLoopControls && marker.end_seconds != null && (
<Button
variant="link"
className="ml-2 p-0"
onClick={() => onLoopMarker(marker)}
>
Loop
</Button>
)}
</div>
<div className="card-section centered">{tags}</div>

View file

@ -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<IProps> = 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<IProps> = PatchComponent("ScenePage", (props) => {
<SceneMarkersPanel
sceneId={scene.id}
onClickMarker={onClickMarker}
onLoopMarker={onLoopMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>

View file

@ -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<ISceneMarkerForm> = ({
});
// 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<typeof schema>;

View file

@ -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<ISceneMarkersPanelProps> = ({
sceneId,
isVisible,
onClickMarker,
onLoopMarker,
}) => {
const { data, loading } = GQL.useFindSceneMarkerTagsQuery({
variables: { id: sceneId },
@ -70,6 +72,7 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ({
<PrimaryTags
sceneMarkers={sceneMarkers}
onClickMarker={onClickMarker}
onLoopMarker={onLoopMarker}
onEdit={onOpenEditor}
/>
</div>