From 044ec2ba3764c9a167e64f9fe3497f9f91dd653a Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Thu, 18 Dec 2025 04:52:56 +0530 Subject: [PATCH 1/7] Add SceneSegment model and repository interfaces --- pkg/models/model_scene_segment.go | 51 ++++++++++++++++++++++++++ pkg/models/repository_scene_segment.go | 35 ++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 pkg/models/model_scene_segment.go create mode 100644 pkg/models/repository_scene_segment.go diff --git a/pkg/models/model_scene_segment.go b/pkg/models/model_scene_segment.go new file mode 100644 index 0000000000..250f3eb654 --- /dev/null +++ b/pkg/models/model_scene_segment.go @@ -0,0 +1,51 @@ +package models + +import ( + "fmt" + "time" +) + +type SceneSegment struct { + ID int `json:"id"` + SceneID int `json:"scene_id"` + Title string `json:"title"` + StartSeconds float64 `json:"start_seconds"` + EndSeconds float64 `json:"end_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SceneSegmentPartial struct { + ID int + SceneID int + Title OptionalString + StartSeconds OptionalFloat64 + EndSeconds OptionalFloat64 + CreatedAt OptionalTime + UpdatedAt OptionalTime +} + +func NewSceneSegment() SceneSegment { + currentTime := time.Now() + return SceneSegment{ + CreatedAt: currentTime, + UpdatedAt: currentTime, + } +} + +func (s *SceneSegment) LoadRelationships(r SceneSegmentReader) error { + return nil +} + +func (s *SceneSegment) Validate() error { + if s.Title == "" { + return fmt.Errorf("title is required") + } + if s.StartSeconds < 0 { + return fmt.Errorf("start_seconds must be >= 0") + } + if s.EndSeconds <= s.StartSeconds { + return fmt.Errorf("end_seconds must be > start_seconds") + } + return nil +} diff --git a/pkg/models/repository_scene_segment.go b/pkg/models/repository_scene_segment.go new file mode 100644 index 0000000000..1632ce78c4 --- /dev/null +++ b/pkg/models/repository_scene_segment.go @@ -0,0 +1,35 @@ +package models + +type SceneSegmentReader interface { + Find(id int) (*SceneSegment, error) + FindMany(ids []int) ([]*SceneSegment, error) + FindBySceneID(sceneID int) ([]*SceneSegment, error) + All() ([]*SceneSegment, error) +} + +type SceneSegmentWriter interface { + Create(newSegment *SceneSegment) error + Update(id int, updatedSegment SceneSegmentPartial) error + Destroy(id int) error +} + +type SceneSegmentCreator interface { + Create(newSegment *SceneSegment) error +} + +type SceneSegmentUpdater interface { + Update(id int, updatedSegment SceneSegmentPartial) error +} + +type SceneSegmentDestroyer interface { + Destroy(id int) error +} + +type SceneSegmentFinder interface { + SceneSegmentReader +} + +type SceneSegmentReaderWriter interface { + SceneSegmentReader + SceneSegmentWriter +} From 92f8e5bc7dc5cd48a301ea67b91284e4387681c1 Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sat, 20 Dec 2025 22:04:19 +0530 Subject: [PATCH 2/7] feat: implement scene segments feature for issue #3530 --- graphql/schema/types/scene-segment.graphql | 24 ++ internal/api/resolver_model_scene_segment.go | 18 ++ .../api/resolver_mutation_scene_segment.go | 85 ++++++ internal/api/resolver_query_scene_segment.go | 43 +++ .../migrations/76_scene_segments.down.sql | 1 + .../migrations/76_scene_segments.up.sql | 12 + pkg/sqlite/scene_segment.go | 249 ++++++++++++++++++ .../Scenes/SceneDetails/SceneSegmentForm.tsx | 226 ++++++++++++++++ .../SceneDetails/SceneSegmentsPanel.tsx | 123 +++++++++ 9 files changed, 781 insertions(+) create mode 100644 graphql/schema/types/scene-segment.graphql create mode 100644 internal/api/resolver_model_scene_segment.go create mode 100644 internal/api/resolver_mutation_scene_segment.go create mode 100644 internal/api/resolver_query_scene_segment.go create mode 100644 pkg/sqlite/migrations/76_scene_segments.down.sql create mode 100644 pkg/sqlite/migrations/76_scene_segments.up.sql create mode 100644 pkg/sqlite/scene_segment.go create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx diff --git a/graphql/schema/types/scene-segment.graphql b/graphql/schema/types/scene-segment.graphql new file mode 100644 index 0000000000..7c3c7de29e --- /dev/null +++ b/graphql/schema/types/scene-segment.graphql @@ -0,0 +1,24 @@ +type SceneSegment { + id: ID! + title: String! + scene_id: ID! + scene: Scene! + start_seconds: Float! + end_seconds: Float! + created_at: Time! + updated_at: Time! +} + +input SceneSegmentCreateInput { + title: String! + scene_id: ID! + start_seconds: Float! + end_seconds: Float! +} + +input SceneSegmentUpdateInput { + id: ID! + title: String + start_seconds: Float + end_seconds: Float +} diff --git a/internal/api/resolver_model_scene_segment.go b/internal/api/resolver_model_scene_segment.go new file mode 100644 index 0000000000..9bc1658771 --- /dev/null +++ b/internal/api/resolver_model_scene_segment.go @@ -0,0 +1,18 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *sceneSegmentResolver) Scene(ctx context.Context, obj *models.SceneSegment) (ret *models.Scene, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Scene.Find(ctx, obj.SceneID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_scene_segment.go b/internal/api/resolver_mutation_scene_segment.go new file mode 100644 index 0000000000..ec08fc1a1a --- /dev/null +++ b/internal/api/resolver_mutation_scene_segment.go @@ -0,0 +1,85 @@ +package api + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *mutationResolver) SceneSegmentCreate(ctx context.Context, input SceneSegmentCreateInput) (*models.SceneSegment, error) { + sceneID, err := strconv.Atoi(input.SceneID) + if err != nil { + return nil, fmt.Errorf("converting scene id: %w", err) + } + + // Populate a new scene segment from the input + newSegment := models.NewSceneSegment() + + newSegment.Title = strings.TrimSpace(input.Title) + newSegment.SceneID = sceneID + newSegment.StartSeconds = input.StartSeconds + newSegment.EndSeconds = input.EndSeconds + + // Validate + if err := newSegment.Validate(); err != nil { + return nil, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.SceneSegment + + return qb.Create(ctx, &newSegment) + }); err != nil { + return nil, err + } + + return &newSegment, nil +} + +func (r *mutationResolver) SceneSegmentUpdate(ctx context.Context, input SceneSegmentUpdateInput) (*models.SceneSegment, error) { + segmentID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate scene segment from the input + updatedSegment := models.SceneSegmentPartial{} + + updatedSegment.Title = translator.optionalString(input.Title, "title") + updatedSegment.StartSeconds = translator.optionalFloat64(input.StartSeconds, "start_seconds") + updatedSegment.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds") + + var ret *models.SceneSegment + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.SceneSegment + + ret, err = qb.UpdatePartial(ctx, segmentID, updatedSegment) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *mutationResolver) SceneSegmentDestroy(ctx context.Context, id string) (bool, error) { + segmentID, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.repository.SceneSegment.Destroy(ctx, segmentID) + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/api/resolver_query_scene_segment.go b/internal/api/resolver_query_scene_segment.go new file mode 100644 index 0000000000..1956d3d6f7 --- /dev/null +++ b/internal/api/resolver_query_scene_segment.go @@ -0,0 +1,43 @@ +package api + +import ( + "context" + "fmt" + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) FindSceneSegment(ctx context.Context, id string) (*models.SceneSegment, error) { + segmentID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + var segment *models.SceneSegment + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + segment, err = r.repository.SceneSegment.Find(ctx, segmentID) + return err + }); err != nil { + return nil, err + } + + return segment, nil +} + +func (r *queryResolver) FindSceneSegments(ctx context.Context, sceneID string) ([]*models.SceneSegment, error) { + sid, err := strconv.Atoi(sceneID) + if err != nil { + return nil, fmt.Errorf("converting scene id: %w", err) + } + + var segments []*models.SceneSegment + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + segments, err = r.repository.SceneSegment.FindBySceneID(ctx, sid) + return err + }); err != nil { + return nil, err + } + + return segments, nil +} diff --git a/pkg/sqlite/migrations/76_scene_segments.down.sql b/pkg/sqlite/migrations/76_scene_segments.down.sql new file mode 100644 index 0000000000..1b0abbcf64 --- /dev/null +++ b/pkg/sqlite/migrations/76_scene_segments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `scene_segments`; diff --git a/pkg/sqlite/migrations/76_scene_segments.up.sql b/pkg/sqlite/migrations/76_scene_segments.up.sql new file mode 100644 index 0000000000..9bca5cc558 --- /dev/null +++ b/pkg/sqlite/migrations/76_scene_segments.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE `scene_segments` ( + `id` integer not null primary key autoincrement, + `scene_id` integer not null, + `title` varchar(255) not null, + `start_seconds` float not null, + `end_seconds` float not null, + `created_at` datetime not null, + `updated_at` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +CREATE INDEX `scene_segments_scene_id` on `scene_segments` (`scene_id`); diff --git a/pkg/sqlite/scene_segment.go b/pkg/sqlite/scene_segment.go new file mode 100644 index 0000000000..28e44fc68d --- /dev/null +++ b/pkg/sqlite/scene_segment.go @@ -0,0 +1,249 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/jmoiron/sqlx" + + "github.com/stashapp/stash/pkg/models" +) + +const ( + sceneSegmentTable = "scene_segments" + sceneSegmentIDColumn = "scene_segment_id" +) + +type sceneSegmentRow struct { + ID int `db:"id" goqu:"skipinsert"` + SceneID int `db:"scene_id"` + Title string `db:"title"` + StartSeconds float64 `db:"start_seconds"` + EndSeconds float64 `db:"end_seconds"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` +} + +func (r *sceneSegmentRow) fromSceneSegment(o models.SceneSegment) { + r.ID = o.ID + r.SceneID = o.SceneID + r.Title = o.Title + r.StartSeconds = o.StartSeconds + r.EndSeconds = o.EndSeconds + r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} + r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} +} + +func (r *sceneSegmentRow) resolve() *models.SceneSegment { + ret := &models.SceneSegment{ + ID: r.ID, + SceneID: r.SceneID, + Title: r.Title, + StartSeconds: r.StartSeconds, + EndSeconds: r.EndSeconds, + CreatedAt: r.CreatedAt.Timestamp, + UpdatedAt: r.UpdatedAt.Timestamp, + } + + return ret +} + +type sceneSegmentRowRecord struct { + updateRecord +} + +func (r *sceneSegmentRowRecord) fromPartial(o models.SceneSegmentPartial) { + r.setInt("scene_id", o.SceneID) + r.setString("title", o.Title) + r.setFloat64("start_seconds", o.StartSeconds) + r.setFloat64("end_seconds", o.EndSeconds) + r.setTimestamp("created_at", o.CreatedAt) + r.setTimestamp("updated_at", o.UpdatedAt) +} + +type sceneSegmentRepositoryType struct { + repository + scenes repository +} + +var ( + sceneSegmentRepository = sceneSegmentRepositoryType{ + repository: repository{ + tableName: sceneSegmentTable, + idColumn: idColumn, + }, + scenes: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + } +) + +type SceneSegmentStore struct{} + +func NewSceneSegmentStore() *SceneSegmentStore { + return &SceneSegmentStore{} +} + +func (qb *SceneSegmentStore) table() exp.IdentifierExpression { + return sceneSegmentTableMgr.table +} + +func (qb *SceneSegmentStore) selectDataset() *goqu.SelectDataset { + return dialect.From(qb.table()).Select(qb.table().All()) +} + +func (qb *SceneSegmentStore) Create(ctx context.Context, newObject *models.SceneSegment) error { + var r sceneSegmentRow + r.fromSceneSegment(*newObject) + + id, err := sceneSegmentTableMgr.insertID(ctx, r) + if err != nil { + return err + } + + updated, err := qb.find(ctx, id) + if err != nil { + return fmt.Errorf("finding after create: %w", err) + } + + *newObject = *updated + + return nil +} + +func (qb *SceneSegmentStore) UpdatePartial(ctx context.Context, id int, partial models.SceneSegmentPartial) (*models.SceneSegment, error) { + r := sceneSegmentRowRecord{ + updateRecord{ + Record: make(exp.Record), + }, + } + + r.fromPartial(partial) + + if len(r.Record) > 0 { + if err := sceneSegmentTableMgr.updateByID(ctx, id, r.Record); err != nil { + return nil, err + } + } + + return qb.find(ctx, id) +} + +func (qb *SceneSegmentStore) Update(ctx context.Context, updatedObject *models.SceneSegment) error { + var r sceneSegmentRow + r.fromSceneSegment(*updatedObject) + + if err := sceneSegmentTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + return err + } + + return nil +} + +func (qb *SceneSegmentStore) Destroy(ctx context.Context, id int) error { + return sceneSegmentRepository.destroyExisting(ctx, []int{id}) +} + +// returns nil, nil if not found +func (qb *SceneSegmentStore) Find(ctx context.Context, id int) (*models.SceneSegment, error) { + ret, err := qb.find(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ret, err +} + +func (qb *SceneSegmentStore) FindMany(ctx context.Context, ids []int) ([]*models.SceneSegment, error) { + ret := make([]*models.SceneSegment, len(ids)) + + table := qb.table() + q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids)) + unsorted, err := qb.getMany(ctx, q) + if err != nil { + return nil, err + } + + for _, s := range unsorted { + i := slices.Index(ids, s.ID) + ret[i] = s + } + + for i := range ret { + if ret[i] == nil { + return nil, fmt.Errorf("scene segment with id %d not found", ids[i]) + } + } + + return ret, nil +} + +// returns nil, sql.ErrNoRows if not found +func (qb *SceneSegmentStore) find(ctx context.Context, id int) (*models.SceneSegment, error) { + q := qb.selectDataset().Where(sceneSegmentTableMgr.byID(id)) + + ret, err := qb.get(ctx, q) + if err != nil { + return nil, err + } + + return ret, nil +} + +// returns nil, sql.ErrNoRows if not found +func (qb *SceneSegmentStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SceneSegment, error) { + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, sql.ErrNoRows + } + + return ret[0], nil +} + +func (qb *SceneSegmentStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SceneSegment, error) { + const single = false + var ret []*models.SceneSegment + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var f sceneSegmentRow + if err := r.StructScan(&f); err != nil { + return err + } + + s := f.resolve() + + ret = append(ret, s) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (qb *SceneSegmentStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneSegment, error) { + table := qb.table() + q := qb.selectDataset(). + Prepared(true). + Where(table.Col("scene_id").Eq(sceneID)). + Order(table.Col("start_seconds").Asc()) + + return qb.getMany(ctx, q) +} + +func (qb *SceneSegmentStore) Count(ctx context.Context) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(qb.table()) + return count(ctx, q) +} + +func (qb *SceneSegmentStore) All(ctx context.Context) ([]*models.SceneSegment, error) { + return qb.getMany(ctx, qb.selectDataset()) +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx new file mode 100644 index 0000000000..4a36a95626 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx @@ -0,0 +1,226 @@ +import React, { useMemo } from "react"; +import { Button, Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useFormik } from "formik"; +import * as yup from "yup"; +import * as GQL from "src/core/generated-graphql"; +import { + useSceneSegmentCreate, + useSceneSegmentUpdate, + useSceneSegmentDestroy, +} from "src/core/StashService"; +import { DurationInput } from "src/components/Shared/DurationInput"; +import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { useToast } from "src/hooks/Toast"; +import isEqual from "lodash-es/isEqual"; +import { formikUtils } from "src/utils/form"; +import { yupFormikValidate } from "src/utils/yup"; + +interface ISceneSegmentForm { + sceneID: string; + segment?: GQL.SceneSegmentDataFragment; + onClose: () => void; +} + +export const SceneSegmentForm: React.FC = ({ + sceneID, + segment, + onClose, +}) => { + const intl = useIntl(); + + const [sceneSegmentCreate] = useSceneSegmentCreate(); + const [sceneSegmentUpdate] = useSceneSegmentUpdate(); + const [sceneSegmentDestroy] = useSceneSegmentDestroy(); + const Toast = useToast(); + + const isNew = segment === undefined; + + const schema = yup.object({ + title: yup.string().required("Title is required"), + start_seconds: yup.number().min(0, "Start time must be >= 0").required(), + end_seconds: yup + .number() + .min(0, "End time must be >= 0") + .required() + .test( + "is-greater-than-start", + "End time must be greater than start time", + function (value) { + return value > this.parent.start_seconds; + } + ), + }); + + const initialValues = useMemo( + () => ({ + title: segment?.title ?? "", + start_seconds: segment?.start_seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: segment?.end_seconds ?? Math.round(getPlayerPosition() ?? 0) + 60, + }), + [segment] + ); + + type InputValues = yup.InferType; + + const formik = useFormik({ + initialValues, + enableReinitialize: true, + validate: yupFormikValidate(schema), + onSubmit: (values) => onSave(schema.cast(values)), + }); + + async function onSave(input: InputValues) { + try { + if (isNew) { + await sceneSegmentCreate({ + variables: { + input: { + scene_id: sceneID, + ...input, + }, + }, + }); + } else { + await sceneSegmentUpdate({ + variables: { + input: { + id: segment.id, + ...input, + }, + }, + }); + } + } catch (e) { + Toast.error(e); + } finally { + onClose(); + } + } + + async function onDelete() { + if (isNew) return; + + try { + await sceneSegmentDestroy({ variables: { id: segment.id } }); + } catch (e) { + Toast.error(e); + } finally { + onClose(); + } + } + + const splitProps = { + labelProps: { + column: true, + sm: 3, + }, + fieldProps: { + sm: 9, + }, + }; + + const { renderField } = formikUtils(intl, formik, splitProps); + + function renderTitleField() { + const title = intl.formatMessage({ id: "title" }); + const control = ( + + ); + + return renderField("title", title, control); + } + + function renderStartTimeField() { + const { error } = formik.getFieldMeta("start_seconds"); + + const title = intl.formatMessage({ id: "time_start", defaultMessage: "Start Time" }); + const control = ( + formik.setFieldValue("start_seconds", v)} + onReset={() => + formik.setFieldValue("start_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + ); + + return renderField("start_seconds", title, control); + } + + function renderEndTimeField() { + const { error } = formik.getFieldMeta("end_seconds"); + + const title = intl.formatMessage({ id: "time_end" }); + const control = ( + <> + formik.setFieldValue("end_seconds", v)} + onReset={() => + formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + {formik.touched.end_seconds && formik.errors.end_seconds && ( + + {formik.errors.end_seconds} + + )} + + ); + + return renderField("end_seconds", title, control); + } + + return ( +
+
+

+ {isNew ? ( + + ) : ( + + )} +

+ {renderTitleField()} + {renderStartTimeField()} + {renderEndTimeField()} +
+
+
+ + + {!isNew && ( + + )} +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx new file mode 100644 index 0000000000..1f3331b269 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import Mousetrap from "mousetrap"; +import * as GQL from "src/core/generated-graphql"; +import { SceneSegmentForm } from "./SceneSegmentForm"; + +interface ISceneSegmentsPanelProps { + sceneId: string; + isVisible: boolean; +} + +export const SceneSegmentsPanel: React.FC = ({ + sceneId, + isVisible, +}) => { + const { data, loading, refetch } = GQL.useFindSceneSegmentsQuery({ + variables: { scene_id: sceneId }, + }); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingSegment, setEditingSegment] = + useState(); + + // set up hotkeys + useEffect(() => { + if (!isVisible) return; + + Mousetrap.bind("s", () => onOpenEditor()); + + return () => { + Mousetrap.unbind("s"); + }; + }); + + if (loading) return null; + + function onOpenEditor(segment?: GQL.SceneSegmentDataFragment) { + setIsEditorOpen(true); + setEditingSegment(segment ?? undefined); + } + + const closeEditor = () => { + setEditingSegment(undefined); + setIsEditorOpen(false); + refetch(); + }; + + if (isEditorOpen) + return ( + + ); + + const segments = data?.findSceneSegments ?? []; + + return ( +
+

+ +

+ + + {segments.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + {segments.map((segment) => { + const duration = segment.end_seconds - segment.start_seconds; + return ( + + + + + + + + ); + })} + +
TitleStartEndDurationActions
{segment.title}{formatTime(segment.start_seconds)}{formatTime(segment.end_seconds)}{formatTime(duration)} + +
+
+ )} +
+ ); +}; + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export default SceneSegmentsPanel; From 45ab166d23da3938d949fe81b516b8b6b76291d1 Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sun, 21 Dec 2025 11:22:03 +0530 Subject: [PATCH 3/7] fix: add missing resolver files for scene segments --- internal/api/resolver.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 061d0e1a9b..35a8ee6ba6 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -69,6 +69,9 @@ func (r *Resolver) Image() ImageResolver { func (r *Resolver) SceneMarker() SceneMarkerResolver { return &sceneMarkerResolver{r} } +func (r *Resolver) SceneSegment() SceneSegmentResolver { + return &sceneSegmentResolver{r} +} func (r *Resolver) Studio() StudioResolver { return &studioResolver{r} } @@ -120,6 +123,7 @@ type galleryChapterResolver struct{ *Resolver } type performerResolver struct{ *Resolver } type sceneResolver struct{ *Resolver } type sceneMarkerResolver struct{ *Resolver } +type sceneSegmentResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } From e3ef87ada6c8150c41ad2b893ac268d64988ff45 Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sun, 21 Dec 2025 13:24:40 +0530 Subject: [PATCH 4/7] fix: add all missing scene segment integration files --- graphql/schema/schema.graphql | 9 +++++++++ graphql/schema/types/scene.graphql | 1 + internal/api/resolver_model_scene.go | 11 +++++++++++ pkg/models/model_scene_segment.go | 2 +- pkg/models/repository.go | 1 + pkg/sqlite/database.go | 4 +++- pkg/sqlite/tables.go | 5 +++++ ui/v2.5/src/core/StashService.ts | 16 ++++++++++++++++ 8 files changed, 47 insertions(+), 2 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 8936b8a347..d9d86f5b95 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -68,6 +68,11 @@ type Query { ids: [ID!] ): FindSceneMarkersResultType! + "Find scene segments by scene ID" + findSceneSegments(scene_id: ID!): [SceneSegment!]! + "Find a single scene segment by ID" + findSceneSegment(id: ID!): SceneSegment + findImage(id: ID, checksum: String): Image "A function which queries Scene objects" @@ -338,6 +343,10 @@ type Mutation { sceneMarkerDestroy(id: ID!): Boolean! sceneMarkersDestroy(ids: [ID!]!): Boolean! + sceneSegmentCreate(input: SceneSegmentCreateInput!): SceneSegment + sceneSegmentUpdate(input: SceneSegmentUpdateInput!): SceneSegment + sceneSegmentDestroy(id: ID!): Boolean! + sceneAssignFile(input: AssignSceneFileInput!): Boolean! imageUpdate(input: ImageUpdateInput!): Image diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index eca01d15ed..79191def45 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -71,6 +71,7 @@ type Scene { files: [VideoFile!]! paths: ScenePathsType! # Resolver scene_markers: [SceneMarker!]! + scene_segments: [SceneSegment!]! galleries: [Gallery!]! studio: Studio groups: [SceneGroup!]! diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 2600c9538a..aabb7738ed 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -142,6 +142,17 @@ func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (re return ret, nil } +func (r *sceneResolver) SceneSegments(ctx context.Context, obj *models.Scene) (ret []*models.SceneSegment, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.SceneSegment.FindBySceneID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret []*models.VideoCaption, err error) { primaryFile, err := r.getPrimaryFile(ctx, obj) if err != nil { diff --git a/pkg/models/model_scene_segment.go b/pkg/models/model_scene_segment.go index 250f3eb654..c56062ef66 100644 --- a/pkg/models/model_scene_segment.go +++ b/pkg/models/model_scene_segment.go @@ -17,7 +17,7 @@ type SceneSegment struct { type SceneSegmentPartial struct { ID int - SceneID int + SceneID OptionalInt Title OptionalString StartSeconds OptionalFloat64 EndSeconds OptionalFloat64 diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 9bd1e8cad4..c75256e124 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -24,6 +24,7 @@ type Repository struct { Performer PerformerReaderWriter Scene SceneReaderWriter SceneMarker SceneMarkerReaderWriter + SceneSegment SceneSegmentReaderWriter Studio StudioReaderWriter Tag TagReaderWriter SavedFilter SavedFilterReaderWriter diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0ea3d71700..c90bd8d263 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 75 +var appSchemaVersion uint = 76 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -74,6 +74,7 @@ type storeRepository struct { GalleryChapter *GalleryChapterStore Scene *SceneStore SceneMarker *SceneMarkerStore + SceneSegment *SceneSegmentStore Performer *PerformerStore SavedFilter *SavedFilterStore Studio *StudioStore @@ -109,6 +110,7 @@ func NewDatabase() *Database { Folder: folderStore, Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), + SceneSegment: NewSceneSegmentStore(), Image: NewImageStore(r), Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7cddf25ccc..184f27bfcd 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -164,6 +164,11 @@ var ( idColumn: goqu.T(sceneMarkerTable).Col(idColumn), } + sceneSegmentTableMgr = &table{ + table: goqu.T(sceneSegmentTable), + idColumn: goqu.T(sceneSegmentTable).Col(idColumn), + } + sceneMarkersTagsTableMgr = &joinTable{ table: table{ table: sceneMarkersTagsJoinTable, diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index c4f3d4732e..576b5145b4 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1556,6 +1556,22 @@ export const useSceneMarkersDestroy = ( }, }); +// Scene Segment mutations +export const useSceneSegmentCreate = () => + GQL.useSceneSegmentCreateMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + +export const useSceneSegmentUpdate = () => + GQL.useSceneSegmentUpdateMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + +export const useSceneSegmentDestroy = () => + GQL.useSceneSegmentDestroyMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + const galleryMutationImpactedTypeFields = { Scene: ["galleries"], Performer: ["gallery_count", "performer_count"], From 0018fbea2d1d54b8e8db4a23acb116c6d13ad4ea Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sun, 21 Dec 2025 14:02:19 +0530 Subject: [PATCH 5/7] fix: add context parameter to all SceneSegment interface methods --- pkg/models/repository_scene_segment.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/models/repository_scene_segment.go b/pkg/models/repository_scene_segment.go index 1632ce78c4..569b7c0432 100644 --- a/pkg/models/repository_scene_segment.go +++ b/pkg/models/repository_scene_segment.go @@ -1,28 +1,30 @@ package models +import "context" + type SceneSegmentReader interface { - Find(id int) (*SceneSegment, error) - FindMany(ids []int) ([]*SceneSegment, error) - FindBySceneID(sceneID int) ([]*SceneSegment, error) - All() ([]*SceneSegment, error) + Find(ctx context.Context, id int) (*SceneSegment, error) + FindMany(ctx context.Context, ids []int) ([]*SceneSegment, error) + FindBySceneID(ctx context.Context, sceneID int) ([]*SceneSegment, error) + All(ctx context.Context) ([]*SceneSegment, error) } type SceneSegmentWriter interface { - Create(newSegment *SceneSegment) error - Update(id int, updatedSegment SceneSegmentPartial) error - Destroy(id int) error + Create(ctx context.Context, newSegment *SceneSegment) error + UpdatePartial(ctx context.Context, id int, updatedSegment SceneSegmentPartial) (*SceneSegment, error) + Destroy(ctx context.Context, id int) error } type SceneSegmentCreator interface { - Create(newSegment *SceneSegment) error + Create(ctx context.Context, newSegment *SceneSegment) error } type SceneSegmentUpdater interface { - Update(id int, updatedSegment SceneSegmentPartial) error + UpdatePartial(ctx context.Context, id int, updatedSegment SceneSegmentPartial) (*SceneSegment, error) } type SceneSegmentDestroyer interface { - Destroy(id int) error + Destroy(ctx context.Context, id int) error } type SceneSegmentFinder interface { From 862f039aa475d230636159ef402380f4a7be9cd0 Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sun, 21 Dec 2025 15:03:36 +0530 Subject: [PATCH 6/7] fix: run gofmt on all scene segment files --- FINISH_BOUNTY.ps1 | 28 +++++++++++++++++++++++++ PUSH_TO_GITHUB.ps1 | 41 +++++++++++++++++++++++++++++++++++++ pkg/sqlite/scene_segment.go | 4 ++-- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 FINISH_BOUNTY.ps1 create mode 100644 PUSH_TO_GITHUB.ps1 diff --git a/FINISH_BOUNTY.ps1 b/FINISH_BOUNTY.ps1 new file mode 100644 index 0000000000..dea7ddb412 --- /dev/null +++ b/FINISH_BOUNTY.ps1 @@ -0,0 +1,28 @@ +# FAST FINISH - Copy & Run in NEW PowerShell + +cd C:\Users\Admin\Desktop\myrepo\stashapp + +# 1. Setup (30 sec) +if (!(Test-Path "ui\v2.5\build")) { mkdir ui\v2.5\build } +echo $null > ui\v2.5\build\index.html + +# 2. Install gqlgen (10 sec) +go install github.com/99designs/gqlgen@latest + +# 3. Generate GraphQL (20 sec) +go generate ./cmd/stash + +# 4. Build backend (1 min) +go build ./cmd/stash + +# 5. Install npm packages (3 min) +cd ui\v2.5 +npm install + +# 6. Build frontend (5 min) +npm run build + +# 7. Done! +cd ..\.. +Write-Host "✅ BUILD COMPLETE! Run: .\stash.exe" -ForegroundColor Green +Write-Host "Then test & submit PR for $450!" -ForegroundColor Yellow diff --git a/PUSH_TO_GITHUB.ps1 b/PUSH_TO_GITHUB.ps1 new file mode 100644 index 0000000000..41c0b55e46 --- /dev/null +++ b/PUSH_TO_GITHUB.ps1 @@ -0,0 +1,41 @@ +# Stashapp Branch Creation and Push Script +# Run this in PowerShell + +cd C:\Users\Admin\Desktop\myrepo\stashapp + +Write-Host "=== STEP 1: Current Git Status ===" -ForegroundColor Cyan +git status + +Write-Host "`n=== STEP 2: Current Branch ===" -ForegroundColor Cyan +git branch + +Write-Host "`n=== STEP 3: Remote Configuration ===" -ForegroundColor Cyan +git remote -v + +Write-Host "`n=== STEP 4: Creating feature/scene-segments branch ===" -ForegroundColor Cyan +git checkout -b feature/scene-segments 2>&1 + +Write-Host "`n=== STEP 5: Adding scene segment files ===" -ForegroundColor Cyan +git add pkg/models/model_scene_segment.go +git add pkg/models/repository_scene_segment.go +git add pkg/sqlite/scene_segment.go +git add pkg/sqlite/migrations/76_scene_segments.up.sql +git add pkg/sqlite/migrations/76_scene_segments.down.sql +git add graphql/schema/types/scene-segment.graphql +git add internal/api/resolver_model_scene_segment.go +git add internal/api/resolver_mutation_scene_segment.go +git add internal/api/resolver_query_scene_segment.go +git add ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx +git add ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx + +Write-Host "`n=== STEP 6: Checking what will be committed ===" -ForegroundColor Cyan +git status + +Write-Host "`n=== STEP 7: Committing changes ===" -ForegroundColor Cyan +git commit -m "feat: implement scene segments feature for issue #3530" + +Write-Host "`n=== STEP 8: Pushing to GitHub ===" -ForegroundColor Cyan +git push -u origin feature/scene-segments + +Write-Host "`n=== DONE! ===" -ForegroundColor Green +Write-Host "Check https://github.com/SBALAVIGNESH123/stash/tree/feature/scene-segments" -ForegroundColor Yellow diff --git a/pkg/sqlite/scene_segment.go b/pkg/sqlite/scene_segment.go index 28e44fc68d..1610ef558b 100644 --- a/pkg/sqlite/scene_segment.go +++ b/pkg/sqlite/scene_segment.go @@ -15,7 +15,7 @@ import ( ) const ( - sceneSegmentTable = "scene_segments" + sceneSegmentTable = "scene_segments" sceneSegmentIDColumn = "scene_segment_id" ) @@ -235,7 +235,7 @@ func (qb *SceneSegmentStore) FindBySceneID(ctx context.Context, sceneID int) ([] Prepared(true). Where(table.Col("scene_id").Eq(sceneID)). Order(table.Col("start_seconds").Asc()) - + return qb.getMany(ctx, q) } From 34afa7e165737ead749bf418e29e40ff466d4a20 Mon Sep 17 00:00:00 2001 From: SBALAVIGNESH123 Date: Sun, 21 Dec 2025 15:55:11 +0530 Subject: [PATCH 7/7] fix: add missing UI GraphQL files for scene segments and fix formatting --- ui/v2.5/graphql/data/scene-segment.graphql | 14 + .../graphql/mutations/scene-segment.graphql | 15 + ui/v2.5/graphql/queries/scene-segment.graphql | 11 + .../Scenes/SceneDetails/SceneSegmentForm.tsx | 401 +++++++++--------- .../SceneDetails/SceneSegmentsPanel.tsx | 191 +++++---- 5 files changed, 343 insertions(+), 289 deletions(-) create mode 100644 ui/v2.5/graphql/data/scene-segment.graphql create mode 100644 ui/v2.5/graphql/mutations/scene-segment.graphql create mode 100644 ui/v2.5/graphql/queries/scene-segment.graphql diff --git a/ui/v2.5/graphql/data/scene-segment.graphql b/ui/v2.5/graphql/data/scene-segment.graphql new file mode 100644 index 0000000000..c70014b1b3 --- /dev/null +++ b/ui/v2.5/graphql/data/scene-segment.graphql @@ -0,0 +1,14 @@ +fragment SceneSegmentData on SceneSegment { + id + title + scene_id + start_seconds + end_seconds + created_at + updated_at + + scene { + id + title + } +} diff --git a/ui/v2.5/graphql/mutations/scene-segment.graphql b/ui/v2.5/graphql/mutations/scene-segment.graphql new file mode 100644 index 0000000000..ab74bafaf4 --- /dev/null +++ b/ui/v2.5/graphql/mutations/scene-segment.graphql @@ -0,0 +1,15 @@ +mutation SceneSegmentCreate($input: SceneSegmentCreateInput!) { + sceneSegmentCreate(input: $input) { + ...SceneSegmentData + } +} + +mutation SceneSegmentUpdate($input: SceneSegmentUpdateInput!) { + sceneSegmentUpdate(input: $input) { + ...SceneSegmentData + } +} + +mutation SceneSegmentDestroy($id: ID!) { + sceneSegmentDestroy(id: $id) +} diff --git a/ui/v2.5/graphql/queries/scene-segment.graphql b/ui/v2.5/graphql/queries/scene-segment.graphql new file mode 100644 index 0000000000..2c810e94cd --- /dev/null +++ b/ui/v2.5/graphql/queries/scene-segment.graphql @@ -0,0 +1,11 @@ +query FindSceneSegments($scene_id: ID!) { + findSceneSegments(scene_id: $scene_id) { + ...SceneSegmentData + } +} + +query FindSceneSegment($id: ID!) { + findSceneSegment(id: $id) { + ...SceneSegmentData + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx index 4a36a95626..90fbcf47a8 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx @@ -5,9 +5,9 @@ import { useFormik } from "formik"; import * as yup from "yup"; import * as GQL from "src/core/generated-graphql"; import { - useSceneSegmentCreate, - useSceneSegmentUpdate, - useSceneSegmentDestroy, + useSceneSegmentCreate, + useSceneSegmentUpdate, + useSceneSegmentDestroy, } from "src/core/StashService"; import { DurationInput } from "src/components/Shared/DurationInput"; import { getPlayerPosition } from "src/components/ScenePlayer/util"; @@ -17,210 +17,221 @@ import { formikUtils } from "src/utils/form"; import { yupFormikValidate } from "src/utils/yup"; interface ISceneSegmentForm { - sceneID: string; - segment?: GQL.SceneSegmentDataFragment; - onClose: () => void; + sceneID: string; + segment?: GQL.SceneSegmentDataFragment; + onClose: () => void; } export const SceneSegmentForm: React.FC = ({ - sceneID, - segment, - onClose, + sceneID, + segment, + onClose, }) => { - const intl = useIntl(); - - const [sceneSegmentCreate] = useSceneSegmentCreate(); - const [sceneSegmentUpdate] = useSceneSegmentUpdate(); - const [sceneSegmentDestroy] = useSceneSegmentDestroy(); - const Toast = useToast(); - - const isNew = segment === undefined; - - const schema = yup.object({ - title: yup.string().required("Title is required"), - start_seconds: yup.number().min(0, "Start time must be >= 0").required(), - end_seconds: yup - .number() - .min(0, "End time must be >= 0") - .required() - .test( - "is-greater-than-start", - "End time must be greater than start time", - function (value) { - return value > this.parent.start_seconds; - } - ), - }); - - const initialValues = useMemo( - () => ({ - title: segment?.title ?? "", - start_seconds: segment?.start_seconds ?? Math.round(getPlayerPosition() ?? 0), - end_seconds: segment?.end_seconds ?? Math.round(getPlayerPosition() ?? 0) + 60, - }), - [segment] - ); - - type InputValues = yup.InferType; - - const formik = useFormik({ - initialValues, - enableReinitialize: true, - validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), - }); - - async function onSave(input: InputValues) { - try { - if (isNew) { - await sceneSegmentCreate({ - variables: { - input: { - scene_id: sceneID, - ...input, - }, - }, - }); - } else { - await sceneSegmentUpdate({ - variables: { - input: { - id: segment.id, - ...input, - }, - }, - }); - } - } catch (e) { - Toast.error(e); - } finally { - onClose(); - } - } - - async function onDelete() { - if (isNew) return; - - try { - await sceneSegmentDestroy({ variables: { id: segment.id } }); - } catch (e) { - Toast.error(e); - } finally { - onClose(); + const intl = useIntl(); + + const [sceneSegmentCreate] = useSceneSegmentCreate(); + const [sceneSegmentUpdate] = useSceneSegmentUpdate(); + const [sceneSegmentDestroy] = useSceneSegmentDestroy(); + const Toast = useToast(); + + const isNew = segment === undefined; + + const schema = yup.object({ + title: yup.string().required("Title is required"), + start_seconds: yup.number().min(0, "Start time must be >= 0").required(), + end_seconds: yup + .number() + .min(0, "End time must be >= 0") + .required() + .test( + "is-greater-than-start", + "End time must be greater than start time", + function (value) { + return value > this.parent.start_seconds; } + ), + }); + + const initialValues = useMemo( + () => ({ + title: segment?.title ?? "", + start_seconds: + segment?.start_seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: + segment?.end_seconds ?? Math.round(getPlayerPosition() ?? 0) + 60, + }), + [segment] + ); + + type InputValues = yup.InferType; + + const formik = useFormik({ + initialValues, + enableReinitialize: true, + validate: yupFormikValidate(schema), + onSubmit: (values) => onSave(schema.cast(values)), + }); + + async function onSave(input: InputValues) { + try { + if (isNew) { + await sceneSegmentCreate({ + variables: { + input: { + scene_id: sceneID, + ...input, + }, + }, + }); + } else { + await sceneSegmentUpdate({ + variables: { + input: { + id: segment.id, + ...input, + }, + }, + }); + } + } catch (e) { + Toast.error(e); + } finally { + onClose(); } + } - const splitProps = { - labelProps: { - column: true, - sm: 3, - }, - fieldProps: { - sm: 9, - }, - }; - - const { renderField } = formikUtils(intl, formik, splitProps); - - function renderTitleField() { - const title = intl.formatMessage({ id: "title" }); - const control = ( - - ); + async function onDelete() { + if (isNew) return; - return renderField("title", title, control); + try { + await sceneSegmentDestroy({ variables: { id: segment.id } }); + } catch (e) { + Toast.error(e); + } finally { + onClose(); } + } + + const splitProps = { + labelProps: { + column: true, + sm: 3, + }, + fieldProps: { + sm: 9, + }, + }; + + const { renderField } = formikUtils(intl, formik, splitProps); + + function renderTitleField() { + const title = intl.formatMessage({ id: "title" }); + const control = ( + + ); - function renderStartTimeField() { - const { error } = formik.getFieldMeta("start_seconds"); - - const title = intl.formatMessage({ id: "time_start", defaultMessage: "Start Time" }); - const control = ( - formik.setFieldValue("start_seconds", v)} - onReset={() => - formik.setFieldValue("start_seconds", getPlayerPosition() ?? 0) - } - error={error} - /> - ); + return renderField("title", title, control); + } - return renderField("start_seconds", title, control); - } + function renderStartTimeField() { + const { error } = formik.getFieldMeta("start_seconds"); - function renderEndTimeField() { - const { error } = formik.getFieldMeta("end_seconds"); - - const title = intl.formatMessage({ id: "time_end" }); - const control = ( - <> - formik.setFieldValue("end_seconds", v)} - onReset={() => - formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) - } - error={error} - /> - {formik.touched.end_seconds && formik.errors.end_seconds && ( - - {formik.errors.end_seconds} - - )} - - ); - - return renderField("end_seconds", title, control); - } + const title = intl.formatMessage({ + id: "time_start", + defaultMessage: "Start Time", + }); + const control = ( + formik.setFieldValue("start_seconds", v)} + onReset={() => + formik.setFieldValue("start_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + ); - return ( -
-
-

- {isNew ? ( - - ) : ( - - )} -

- {renderTitleField()} - {renderStartTimeField()} - {renderEndTimeField()} -
-
-
- - - {!isNew && ( - - )} -
-
-
+ return renderField("start_seconds", title, control); + } + + function renderEndTimeField() { + const { error } = formik.getFieldMeta("end_seconds"); + + const title = intl.formatMessage({ id: "time_end" }); + const control = ( + <> + formik.setFieldValue("end_seconds", v)} + onReset={() => + formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + {formik.touched.end_seconds && formik.errors.end_seconds && ( + + {formik.errors.end_seconds} + + )} + ); + + return renderField("end_seconds", title, control); + } + + return ( +
+
+

+ {isNew ? ( + + ) : ( + + )} +

+ {renderTitleField()} + {renderStartTimeField()} + {renderEndTimeField()} +
+
+
+ + + {!isNew && ( + + )} +
+
+
+ ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx index 1f3331b269..31d88936a4 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx @@ -6,118 +6,121 @@ import * as GQL from "src/core/generated-graphql"; import { SceneSegmentForm } from "./SceneSegmentForm"; interface ISceneSegmentsPanelProps { - sceneId: string; - isVisible: boolean; + sceneId: string; + isVisible: boolean; } export const SceneSegmentsPanel: React.FC = ({ - sceneId, - isVisible, + sceneId, + isVisible, }) => { - const { data, loading, refetch } = GQL.useFindSceneSegmentsQuery({ - variables: { scene_id: sceneId }, - }); - const [isEditorOpen, setIsEditorOpen] = useState(false); - const [editingSegment, setEditingSegment] = - useState(); + const { data, loading, refetch } = GQL.useFindSceneSegmentsQuery({ + variables: { scene_id: sceneId }, + }); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingSegment, setEditingSegment] = + useState(); - // set up hotkeys - useEffect(() => { - if (!isVisible) return; + // set up hotkeys + useEffect(() => { + if (!isVisible) return; - Mousetrap.bind("s", () => onOpenEditor()); + Mousetrap.bind("s", () => onOpenEditor()); - return () => { - Mousetrap.unbind("s"); - }; - }); + return () => { + Mousetrap.unbind("s"); + }; + }); - if (loading) return null; + if (loading) return null; - function onOpenEditor(segment?: GQL.SceneSegmentDataFragment) { - setIsEditorOpen(true); - setEditingSegment(segment ?? undefined); - } + function onOpenEditor(segment?: GQL.SceneSegmentDataFragment) { + setIsEditorOpen(true); + setEditingSegment(segment ?? undefined); + } - const closeEditor = () => { - setEditingSegment(undefined); - setIsEditorOpen(false); - refetch(); - }; + const closeEditor = () => { + setEditingSegment(undefined); + setIsEditorOpen(false); + refetch(); + }; - if (isEditorOpen) - return ( - - ); + if (isEditorOpen) + return ( + + ); - const segments = data?.findSceneSegments ?? []; + const segments = data?.findSceneSegments ?? []; - return ( -
-

- -

- + return ( +
+

+ +

+ - {segments.length === 0 ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {segments.map((segment) => { - const duration = segment.end_seconds - segment.start_seconds; - return ( - - - - - - - - ); - })} - -
TitleStartEndDurationActions
{segment.title}{formatTime(segment.start_seconds)}{formatTime(segment.end_seconds)}{formatTime(duration)} - -
-
- )} + {segments.length === 0 ? ( +
+
- ); + ) : ( +
+ + + + + + + + + + + + {segments.map((segment) => { + const duration = segment.end_seconds - segment.start_seconds; + return ( + + + + + + + + ); + })} + +
TitleStartEndDurationActions
{segment.title}{formatTime(segment.start_seconds)}{formatTime(segment.end_seconds)}{formatTime(duration)} + +
+
+ )} +
+ ); }; function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; } export default SceneSegmentsPanel;