From 0b0c4f5acff63c995d781e5118648ead0e61107d Mon Sep 17 00:00:00 2001 From: JustSomeDevPerson <221926418+JustSomeDevPerson@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:30:10 +0200 Subject: [PATCH] feat: loop playlist when last element is done playing --- graphql/schema/types/config.graphql | 4 ++ internal/api/resolver_mutation_configure.go | 1 + internal/api/resolver_query_configuration.go | 2 + internal/manager/config/config.go | 5 ++ .../manager/config/config_concurrency_test.go | 1 + ui/v2.5/graphql/data/config.graphql | 1 + .../Scenes/SceneDetails/QueueViewer.tsx | 12 +++++ .../components/Scenes/SceneDetails/Scene.tsx | 29 +++++++++-- .../SettingsInterfacePanel.tsx | 48 +++++++++++-------- ui/v2.5/src/locales/en-GB.json | 5 ++ ui/v2.5/src/models/sceneQueue.ts | 4 ++ 11 files changed, 87 insertions(+), 25 deletions(-) diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 5ab7fdfeab..a157e62712 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -401,6 +401,8 @@ input ConfigInterfaceInput { autostartVideoOnPlaySelected: Boolean "If true, next scene in playlist will be played at video end by default" continuePlaylistDefault: Boolean + "If true, queue wraps to the first scene after the last scene finishes by default" + loopPlaylistDefault: Boolean "If true, studio overlays will be shown as text instead of logo images" showStudioAsText: Boolean @@ -478,6 +480,8 @@ type ConfigInterfaceResult { autostartVideoOnPlaySelected: Boolean "If true, next scene in playlist will be played at video end by default" continuePlaylistDefault: Boolean + "If true, queue wraps to the first scene after the last scene finishes by default" + loopPlaylistDefault: Boolean "If true, studio overlays will be shown as text instead of logo images" showStudioAsText: Boolean diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 3df1c9114c..ac728bf088 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -491,6 +491,7 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI r.setConfigBool(config.ShowStudioAsText, input.ShowStudioAsText) r.setConfigBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected) r.setConfigBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault) + r.setConfigBool(config.LoopPlaylistDefault, input.LoopPlaylistDefault) r.setConfigString(config.Language, input.Language) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index cf2c0e3cca..19a5af6a12 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -154,6 +154,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { autostartVideo := config.GetAutostartVideo() autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected() continuePlaylistDefault := config.GetContinuePlaylistDefault() + loopPlaylistDefault := config.GetLoopPlaylistDefault() showStudioAsText := config.GetShowStudioAsText() css := config.GetCSS() cssEnabled := config.GetCSSEnabled() @@ -183,6 +184,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { ShowStudioAsText: &showStudioAsText, AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected, ContinuePlaylistDefault: &continuePlaylistDefault, + LoopPlaylistDefault: &loopPlaylistDefault, CSS: &css, CSSEnabled: &cssEnabled, Javascript: &javascript, diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 18cf3ed001..ad602edd1e 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -209,6 +209,7 @@ const ( AutostartVideoOnPlaySelected = "autostart_video_on_play_selected" autostartVideoOnPlaySelectedDefault = true ContinuePlaylistDefault = "continue_playlist_default" + LoopPlaylistDefault = "loop_playlist_default" ShowStudioAsText = "show_studio_as_text" CSSEnabled = "cssenabled" JavascriptEnabled = "javascriptenabled" @@ -1326,6 +1327,10 @@ func (i *Config) GetContinuePlaylistDefault() bool { return i.getBool(ContinuePlaylistDefault) } +func (i *Config) GetLoopPlaylistDefault() bool { + return i.getBool(LoopPlaylistDefault) +} + func (i *Config) GetShowStudioAsText() bool { return i.getBool(ShowStudioAsText) } diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index fd9b067c7e..624b06dbf0 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -121,6 +121,7 @@ func TestConcurrentConfigAccess(t *testing.T) { i.SetInterface(DisableDropdownCreateMovie, i.GetDisableDropdownCreate().Movie) i.SetInterface(AutostartVideoOnPlaySelected, i.GetAutostartVideoOnPlaySelected()) i.SetInterface(ContinuePlaylistDefault, i.GetContinuePlaylistDefault()) + i.SetInterface(LoopPlaylistDefault, i.GetLoopPlaylistDefault()) i.SetInterface(PythonPath, i.GetPythonPath()) t.Logf("Worker %v iteration %v took %v", wk, l, time.Since(start)) } diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index ba8215fe38..ab05f1a266 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -90,6 +90,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { autostartVideo autostartVideoOnPlaySelected continuePlaylistDefault + loopPlaylistDefault showStudioAsText css cssEnabled diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx index 57dc38d048..2824a84823 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx @@ -19,8 +19,10 @@ export interface IPlaylistViewer { currentID?: string; start?: number; continue?: boolean; + loop?: boolean; hasMoreScenes: boolean; setContinue: (v: boolean) => void; + setLoop: (v: boolean) => void; onSceneClicked: (id: string) => void; onNext: () => void; onPrevious: () => void; @@ -34,8 +36,10 @@ export const QueueViewer: React.FC = ({ currentID, start = 0, continue: continuePlaylist = false, + loop: loopPlaylist = false, hasMoreScenes, setContinue, + setLoop, onNext, onPrevious, onRandom, @@ -127,6 +131,14 @@ export const QueueViewer: React.FC = ({ setContinue(!continuePlaylist); }} /> + { + setLoop(!loopPlaylist); + }} + />
{currentIndex > 0 || start > 1 ? ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 533b57b56b..469c866c02 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -154,6 +154,8 @@ interface IProps { collapsed: boolean; setCollapsed: (state: boolean) => void; setContinuePlaylist: (value: boolean) => void; + loopPlaylist: boolean; + setLoopPlaylist: (value: boolean) => void; } interface ISceneParams { @@ -183,6 +185,8 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { collapsed, setCollapsed, setContinuePlaylist, + loopPlaylist, + setLoopPlaylist, } = props; const Toast = useToast(); @@ -598,6 +602,8 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { currentID={scene.id} continue={continuePlaylist} setContinue={setContinuePlaylist} + loop={loopPlaylist} + setLoop={setLoopPlaylist} onSceneClicked={onQueueSceneClicked} onNext={onQueueNext} onPrevious={onQueuePrevious} @@ -672,9 +678,8 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { {maybeRenderMergeDialog()} {maybeRenderDeleteDialog()}
@@ -785,10 +790,19 @@ const SceneLoader: React.FC> = ({ } }, [configuration?.interface.continuePlaylistDefault, queryParams]); + const queryLoop = useMemo(() => { + const loop = queryParams.get("loop"); + if (loop) { + return loop === "true"; + } + return !!configuration?.interface.loopPlaylistDefault; + }, [configuration?.interface.loopPlaylistDefault, queryParams]); + const [queueScenes, setQueueScenes] = useState([]); const [collapsed, setCollapsed] = useState(false); const [continuePlaylist, setContinuePlaylist] = useState(queryContinue); + const [loopPlaylist, setLoopPlaylist] = useState(queryLoop); const [hideScrubber, setHideScrubber] = useState( !(configuration?.interface.showScrubber ?? true) ); @@ -904,11 +918,12 @@ const SceneLoader: React.FC> = ({ newPage, autoPlay, continue: continuePlaylist, + loop: loopPlaylist, }); history.replace(sceneLink); } - async function queueNext(autoPlay: boolean) { + async function queueNext(autoPlay: boolean, allowLoop = false) { if (currentQueueIndex === -1) return; if (currentQueueIndex < queueScenes.length - 1) { @@ -922,6 +937,8 @@ const SceneLoader: React.FC> = ({ const newPage = (sceneQueue.query?.currentPage ?? 0) + 1; loadScene(loadedScenes[0].id, autoPlay, newPage); } + } else if (allowLoop && loopPlaylist && queueScenes.length > 0) { + loadScene(queueScenes[0].id, autoPlay); } } } @@ -972,7 +989,7 @@ const SceneLoader: React.FC> = ({ function onComplete() { // load the next scene if we're continuing if (continuePlaylist) { - queueNext(true); + queueNext(true, true); } } @@ -1029,6 +1046,8 @@ const SceneLoader: React.FC> = ({ collapsed={collapsed} setCollapsed={setCollapsed} setContinuePlaylist={setContinuePlaylist} + loopPlaylist={loopPlaylist} + setLoopPlaylist={setLoopPlaylist} />
saveInterface({ continuePlaylistDefault: v })} /> + saveInterface({ loopPlaylistDefault: v })} + /> + id="max-loop-duration" headingID="config.ui.max_loop_duration.heading" @@ -807,26 +815,26 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( {(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) === RatingSystemType.Stars && ( - - saveRatingSystemStarPrecision(v as RatingStarPrecision) - } - > - {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => ( - - ))} - - )} + + saveRatingSystemStarPrecision(v as RatingStarPrecision) + } + > + {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => ( + + ))} + + )} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e3653d7057..f5e5f6b73b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -71,6 +71,7 @@ "hide_configuration": "Hide Configuration", "identify": "Identify", "ignore": "Ignore", + "loop": "Loop", "import": "Import…", "import_from_file": "Import from file", "load": "Load", @@ -842,6 +843,10 @@ "description": "Play next scene in queue when video finishes.", "heading": "Continue playlist by default" }, + "loop_playlist_default": { + "description": "When the last scene in the queue finishes, continue from the first scene.", + "heading": "Loop playlist by default" + }, "disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on mobile", "enable_chromecast": "Enable Chromecast", "show_ab_loop_controls": "Show AB loop controls", diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index 6d4c247f43..94d89ae426 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -12,6 +12,7 @@ export interface IPlaySceneOptions { newPage?: number; autoPlay?: boolean; continue?: boolean; + loop?: boolean; start?: number; } @@ -117,6 +118,9 @@ export class SceneQueue { if (options.continue !== undefined) { params.push("continue=" + options.continue); } + if (options.loop !== undefined) { + params.push("loop=" + options.loop); + } if (options.start !== undefined) { params.push("t=" + options.start); }