From 4ff68050d878d5edbdb4104c97d929e024647292 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:45:15 +0200
Subject: [PATCH 1/6] qml: use `Text.Fit` in Fallback control so that the text
 has to fit

---
 modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml b/modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml
index 6686e5b18586..fab3ef262c24 100644
--- a/modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml
+++ b/modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml
@@ -47,5 +47,6 @@ Control {
         text: qsTr("WIDGET\nNOT\nFOUND")
         horizontalAlignment: Text.AlignHCenter
         color: theme.fg.primary
+        fontSizeMode: Text.Fit
     }
 }
-- 
GitLab


From 6f7e8686b8a3e1eeacb25cc72a3dc5d182d2fb61 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:54:35 +0200
Subject: [PATCH 2/6] qt: support external controls in player tool bar

If we are paying the price of using the QML engine,
we might as well provide ability to use external
QML files.

This allows having external controls for the
player without manually adding the control and
building the application.
---
 .../dialogs/toolbar/qml/EditorDNDDelegate.qml |   2 +-
 .../toolbar/qml/ToolbarEditorButtonList.qml   |  10 +-
 modules/gui/qt/maininterface/mainctx.cpp      |  33 +++++
 modules/gui/qt/maininterface/mainctx.hpp      |   6 +
 .../qt/maininterface/qml/MainInterface.qml    |   1 +
 modules/gui/qt/player/control_list_model.hpp  |   3 +
 modules/gui/qt/player/qml/ControlRepeater.qml |   2 +-
 .../player/qml/PlayerControlbarControls.qml   | 137 ++++++++++++------
 modules/gui/qt/plugins.hpp                    |   1 +
 9 files changed, 142 insertions(+), 53 deletions(-)

diff --git a/modules/gui/qt/dialogs/toolbar/qml/EditorDNDDelegate.qml b/modules/gui/qt/dialogs/toolbar/qml/EditorDNDDelegate.qml
index c885c2729d58..1caf1957f4ea 100644
--- a/modules/gui/qt/dialogs/toolbar/qml/EditorDNDDelegate.qml
+++ b/modules/gui/qt/dialogs/toolbar/qml/EditorDNDDelegate.qml
@@ -158,7 +158,7 @@ T.Control {
 
             anchors.fill: (parent === control.contentItem) ? parent : undefined
 
-            source: PlayerControlbarControls.control(model.id).source
+            source: PlayerControlbarControls.controlList.control(model.id).source
 
             Drag.source: control
 
diff --git a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml
index 3d4c9e2c41ed..e1c93385afaf 100644
--- a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml
+++ b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml
@@ -32,7 +32,7 @@ GridView {
     clip: true
 
     ScrollBar.vertical: ScrollBar { }
-    model: PlayerControlbarControls.controlList.length
+    model: PlayerControlbarControls.controlList.controls.length
 
     currentIndex: -1
     highlightFollowsCurrentItem: false
@@ -139,7 +139,7 @@ GridView {
 
         drag.smoothed: false
 
-        readonly property int mIndex: PlayerControlbarControls.controlList[model.index].id
+        readonly property int mIndex: PlayerControlbarControls.controlList.controls[model.index].id
 
         readonly property ColorContext colorContext: ColorContext {
             colorSet: ColorContext.Item
@@ -149,7 +149,7 @@ GridView {
             if (drag.active) {
                 root.controlDragStarted(mIndex)
 
-                buttonDragItem.text = PlayerControlbarControls.controlList[model.index].label
+                buttonDragItem.text = PlayerControlbarControls.controlList.controls[model.index].label
                 buttonDragItem.Drag.source = this
                 buttonDragItem.Drag.start()
 
@@ -191,7 +191,7 @@ GridView {
                     Layout.alignment: Qt.AlignHCenter
 
                     color: colorContext.fg.primary
-                    text: PlayerControlbarControls.controlList[model.index].label
+                    text: PlayerControlbarControls.controlList.controls[model.index].label
                 }
 
                 Widgets.ListSubtitleLabel {
@@ -201,7 +201,7 @@ GridView {
                     color: colorContext.fg.secondary
                     elide: Text.ElideNone
                     fontSizeMode: Text.Fit
-                    text: PlayerControlbarControls.controlList[model.index].text
+                    text: PlayerControlbarControls.controlList.controls[model.index].text
                     wrapMode: Text.WordWrap
                     horizontalAlignment: Text.AlignHCenter
                 }
diff --git a/modules/gui/qt/maininterface/mainctx.cpp b/modules/gui/qt/maininterface/mainctx.cpp
index cfa81d5e2de2..d0406f9d9d2b 100644
--- a/modules/gui/qt/maininterface/mainctx.cpp
+++ b/modules/gui/qt/maininterface/mainctx.cpp
@@ -771,6 +771,39 @@ QJSValue MainCtx::urlListToMimeData(const QJSValue &array) {
     return ret;
 }
 
+QStringList MainCtx::getFilesInDirectory(const QString &path, int sortFlags)
+{
+    const QDir directory(QUrl(path).toLocalFile());
+    return directory.entryList(QDir::Filter::Files, static_cast<QDir::SortFlags>(sortFlags));
+}
+
+bool MainCtx::dirExists(const QString &path)
+{
+    const QDir directory(QUrl(path).toLocalFile());
+    return directory.exists();
+}
+
+bool MainCtx::mkDir(const QString &path)
+{
+    const QDir directory(QUrl(path).toLocalFile());
+    if (directory.exists())
+        return false;
+    return directory.mkpath(directory.path());
+}
+
+bool MainCtx::mkFile(const QString &path, const QString &content)
+{
+    QFile file(QUrl(path).toLocalFile());
+    if (file.open(QIODevice::OpenModeFlag::WriteOnly))
+    {
+        QTextStream stream(&file);
+        stream << content;
+        return (stream.status() == QTextStream::Ok);
+    }
+
+    return false;
+}
+
 VideoSurfaceProvider* MainCtx::getVideoSurfaceProvider() const
 {
     return m_videoSurfaceProvider;
diff --git a/modules/gui/qt/maininterface/mainctx.hpp b/modules/gui/qt/maininterface/mainctx.hpp
index ba82c43bccef..127b251c152a 100644
--- a/modules/gui/qt/maininterface/mainctx.hpp
+++ b/modules/gui/qt/maininterface/mainctx.hpp
@@ -332,6 +332,12 @@ public:
     Q_INVOKABLE virtual bool platformHandlesTitleBarButtonsWithCSD() const { return false; };
     Q_INVOKABLE virtual bool platformHandlesShadowsWithCSD() const { return false; };
 
+    // Filesystem accessories:
+    Q_INVOKABLE static QStringList getFilesInDirectory(const QString& path, int sortFlags = -1);
+    Q_INVOKABLE static bool dirExists(const QString& path);
+    Q_INVOKABLE static bool mkDir(const QString& path);
+    Q_INVOKABLE static bool mkFile(const QString& path, const QString& content = {});
+
     /**
      * @brief ask for the application to terminate
      */
diff --git a/modules/gui/qt/maininterface/qml/MainInterface.qml b/modules/gui/qt/maininterface/qml/MainInterface.qml
index 57897e53e710..118b534cf676 100644
--- a/modules/gui/qt/maininterface/qml/MainInterface.qml
+++ b/modules/gui/qt/maininterface/qml/MainInterface.qml
@@ -18,6 +18,7 @@
 
 // NOTE: All imports used throughout the interface
 //       must be imported here as well:
+import QtCore
 import QtQml
 import QtQml.Models
 import QtQuick
diff --git a/modules/gui/qt/player/control_list_model.hpp b/modules/gui/qt/player/control_list_model.hpp
index 127cd9fea750..dafb88b24bf7 100644
--- a/modules/gui/qt/player/control_list_model.hpp
+++ b/modules/gui/qt/player/control_list_model.hpp
@@ -83,6 +83,9 @@ public:
         WIDGET_SPACER = 0x40,
         WIDGET_SPACER_EXTEND,
         WIDGET_MAX,
+
+        EXTERNAL = 0x160,
+        EXTERNAL_END = 0x320
     };
     Q_ENUM(ControlType)
 
diff --git a/modules/gui/qt/player/qml/ControlRepeater.qml b/modules/gui/qt/player/qml/ControlRepeater.qml
index 127f02c98058..a63e41c9480d 100644
--- a/modules/gui/qt/player/qml/ControlRepeater.qml
+++ b/modules/gui/qt/player/qml/ControlRepeater.qml
@@ -51,7 +51,7 @@ Repeater {
 
         // Settings
 
-        source: PlayerControlbarControls.control(model.id).source
+        source: PlayerControlbarControls.controlList.control(model.id).source
 
         focus: (index === 0)
 
diff --git a/modules/gui/qt/player/qml/PlayerControlbarControls.qml b/modules/gui/qt/player/qml/PlayerControlbarControls.qml
index 9b4c78fe6605..1b429dbbfb6c 100644
--- a/modules/gui/qt/player/qml/PlayerControlbarControls.qml
+++ b/modules/gui/qt/player/qml/PlayerControlbarControls.qml
@@ -18,6 +18,7 @@
 
 pragma Singleton
 
+import QtCore
 import QtQml
 
 
@@ -29,57 +30,101 @@ import VLC.Style
 QtObject {
     readonly property string controlPath : "qrc:///qt/qml/VLC/PlayerControls/"
 
-    readonly property var controlList: [
-        { id: ControlListModel.PLAY_BUTTON, file: "PlayButton.qml", label: VLCIcons.play_filled, text: qsTr("Play") },
-        { id: ControlListModel.STOP_BUTTON, file: "StopButton.qml", label: VLCIcons.stop, text: qsTr("Stop") },
-        { id: ControlListModel.OPEN_BUTTON, file: "OpenButton.qml", label: VLCIcons.eject, text: qsTr("Open") },
-        { id: ControlListModel.PREVIOUS_BUTTON, file: "PreviousButton.qml", label: VLCIcons.previous, text: qsTr("Previous") },
-        { id: ControlListModel.NEXT_BUTTON, file: "NextButton.qml", label: VLCIcons.next, text: qsTr("Next") },
-        { id: ControlListModel.SLOWER_BUTTON, file: "SlowerButton.qml", label: VLCIcons.slower, text: qsTr("Slower") },
-        { id: ControlListModel.FASTER_BUTTON, file: "FasterButton.qml", label: VLCIcons.faster, text: qsTr("Faster") },
-        { id: ControlListModel.FULLSCREEN_BUTTON, file: "FullscreenButton.qml", label: VLCIcons.fullscreen, text: qsTr("Fullscreen") },
-        { id: ControlListModel.EXTENDED_BUTTON, file: "ExtendedSettingsButton.qml", label: VLCIcons.effect_filter, text: qsTr("Extended panel") },
-        { id: ControlListModel.PLAYLIST_BUTTON, file: "PlaylistButton.qml", label: VLCIcons.playlist, text: qsTr("Playlist") },
-        { id: ControlListModel.SNAPSHOT_BUTTON, file: "SnapshotButton.qml", label: VLCIcons.snapshot, text: qsTr("Snapshot") },
-        { id: ControlListModel.RECORD_BUTTON, file: "RecordButton.qml", label: VLCIcons.record, text: qsTr("Record") },
-        { id: ControlListModel.ATOB_BUTTON, file: "AtoBButton.qml", label: VLCIcons.atob, text: qsTr("A-B Loop") },
-        { id: ControlListModel.FRAME_BUTTON, file: "FrameButton.qml", label: VLCIcons.frame_by_frame, text: qsTr("Frame By Frame") },
-        { id: ControlListModel.REVERSE_BUTTON, file: "ReverseButton.qml", label: VLCIcons.play_reverse, text: qsTr("Trickplay Reverse") },
-        { id: ControlListModel.SKIP_BACK_BUTTON, file: "SkipBackButton.qml", label: VLCIcons.skip_back, text: qsTr("Step backward") },
-        { id: ControlListModel.SKIP_FW_BUTTON, file: "SkipForwardButton.qml", label: VLCIcons.skip_for, text: qsTr("Step forward") },
-        { id: ControlListModel.QUIT_BUTTON, file: "QuitButton.qml", label: VLCIcons.clear, text: qsTr("Quit") },
-        { id: ControlListModel.RANDOM_BUTTON, file: "RandomButton.qml", label: VLCIcons.shuffle, text: qsTr("Random") },
-        { id: ControlListModel.LOOP_BUTTON, file: "LoopButton.qml", label: VLCIcons.repeat_all, text: qsTr("Loop") },
-        { id: ControlListModel.INFO_BUTTON, file: "InfoButton.qml", label: VLCIcons.info, text: qsTr("Information") },
-        { id: ControlListModel.LANG_BUTTON, file: "LangButton.qml", label: VLCIcons.audiosub, text: qsTr("Open subtitles") },
-        { id: ControlListModel.BOOKMARK_BUTTON, file: "BookmarkButton.qml", label: VLCIcons.bookmark, text: qsTr("Bookmark Button") },
-        { id: ControlListModel.CHAPTER_PREVIOUS_BUTTON, file: "ChapterPreviousButton.qml", label: VLCIcons.dvd_prev, text: qsTr("Previous chapter") },
-        { id: ControlListModel.CHAPTER_NEXT_BUTTON, file: "ChapterNextButton.qml", label: VLCIcons.dvd_next, text: qsTr("Next chapter") },
-        { id: ControlListModel.VOLUME, file: "VolumeWidget.qml", label: VLCIcons.volume_high, text: qsTr("Volume Widget") },
-        { id: ControlListModel.NAVIGATION_BOX, file: "NavigationBoxButton.qml", label: VLCIcons.ic_fluent_arrow_move, text: qsTr("Navigation Box") },
-        { id: ControlListModel.NAVIGATION_BUTTONS, file: "NavigationWidget.qml", label: VLCIcons.dvd_menu, text: qsTr("Navigation") },
-        { id: ControlListModel.DVD_MENUS_BUTTON, file: "DvdMenuButton.qml", label: VLCIcons.dvd_menu, text: qsTr("DVD menus") },
-        { id: ControlListModel.PROGRAM_BUTTON, file: "ProgramButton.qml", label: VLCIcons.tv, text: qsTr("Program Button") },
-        { id: ControlListModel.TELETEXT_BUTTONS, file: "TeletextButton.qml", label: VLCIcons.tvtelx, text: qsTr("Teletext") },
-        { id: ControlListModel.RENDERER_BUTTON, file: "RendererButton.qml", label: VLCIcons.renderer, text: qsTr("Renderer Button") },
-        { id: ControlListModel.ASPECT_RATIO_COMBOBOX, file: "AspectRatioWidget.qml", label: VLCIcons.aspect_ratio, text: qsTr("Aspect Ratio") },
-        { id: ControlListModel.WIDGET_SPACER, file: "SpacerWidget.qml", label: VLCIcons.space, text: qsTr("Spacer") },
-        { id: ControlListModel.WIDGET_SPACER_EXTEND, file: "ExpandingSpacerWidget.qml", label: VLCIcons.space, text: qsTr("Expanding Spacer") },
-        { id: ControlListModel.PLAYER_SWITCH_BUTTON, file: "PlayerSwitchButton.qml", label: VLCIcons.fullscreen, text: qsTr("Switch Player") },
-        { id: ControlListModel.ARTWORK_INFO, file: "ArtworkInfoWidget.qml", label: VLCIcons.info, text: qsTr("Artwork Info") },
-        { id: ControlListModel.PLAYBACK_SPEED_BUTTON, file: "PlaybackSpeedButton.qml", label: "1x", text: qsTr("Playback Speed") },
-        { id: ControlListModel.HIGH_RESOLUTION_TIME_WIDGET, file: "HighResolutionTimeWidget.qml", label: VLCIcons.info, text: qsTr("High Resolution Time") }
-    ]
-
-    function control(id) {
-        const entry = controlList.find( function(e) { return ( e.id === id ) } )
+    readonly property var controlList: ({
+        control: _control /* retriever function */,
+        controls: [
+            { id: ControlListModel.PLAY_BUTTON, file: "PlayButton.qml", label: VLCIcons.play_filled, text: qsTr("Play") },
+            { id: ControlListModel.STOP_BUTTON, file: "StopButton.qml", label: VLCIcons.stop, text: qsTr("Stop") },
+            { id: ControlListModel.OPEN_BUTTON, file: "OpenButton.qml", label: VLCIcons.eject, text: qsTr("Open") },
+            { id: ControlListModel.PREVIOUS_BUTTON, file: "PreviousButton.qml", label: VLCIcons.previous, text: qsTr("Previous") },
+            { id: ControlListModel.NEXT_BUTTON, file: "NextButton.qml", label: VLCIcons.next, text: qsTr("Next") },
+            { id: ControlListModel.SLOWER_BUTTON, file: "SlowerButton.qml", label: VLCIcons.slower, text: qsTr("Slower") },
+            { id: ControlListModel.FASTER_BUTTON, file: "FasterButton.qml", label: VLCIcons.faster, text: qsTr("Faster") },
+            { id: ControlListModel.FULLSCREEN_BUTTON, file: "FullscreenButton.qml", label: VLCIcons.fullscreen, text: qsTr("Fullscreen") },
+            { id: ControlListModel.EXTENDED_BUTTON, file: "ExtendedSettingsButton.qml", label: VLCIcons.effect_filter, text: qsTr("Extended panel") },
+            { id: ControlListModel.PLAYLIST_BUTTON, file: "PlaylistButton.qml", label: VLCIcons.playlist, text: qsTr("Playlist") },
+            { id: ControlListModel.SNAPSHOT_BUTTON, file: "SnapshotButton.qml", label: VLCIcons.snapshot, text: qsTr("Snapshot") },
+            { id: ControlListModel.RECORD_BUTTON, file: "RecordButton.qml", label: VLCIcons.record, text: qsTr("Record") },
+            { id: ControlListModel.ATOB_BUTTON, file: "AtoBButton.qml", label: VLCIcons.atob, text: qsTr("A-B Loop") },
+            { id: ControlListModel.FRAME_BUTTON, file: "FrameButton.qml", label: VLCIcons.frame_by_frame, text: qsTr("Frame By Frame") },
+            { id: ControlListModel.REVERSE_BUTTON, file: "ReverseButton.qml", label: VLCIcons.play_reverse, text: qsTr("Trickplay Reverse") },
+            { id: ControlListModel.SKIP_BACK_BUTTON, file: "SkipBackButton.qml", label: VLCIcons.skip_back, text: qsTr("Step backward") },
+            { id: ControlListModel.SKIP_FW_BUTTON, file: "SkipForwardButton.qml", label: VLCIcons.skip_for, text: qsTr("Step forward") },
+            { id: ControlListModel.QUIT_BUTTON, file: "QuitButton.qml", label: VLCIcons.clear, text: qsTr("Quit") },
+            { id: ControlListModel.RANDOM_BUTTON, file: "RandomButton.qml", label: VLCIcons.shuffle, text: qsTr("Random") },
+            { id: ControlListModel.LOOP_BUTTON, file: "LoopButton.qml", label: VLCIcons.repeat_all, text: qsTr("Loop") },
+            { id: ControlListModel.INFO_BUTTON, file: "InfoButton.qml", label: VLCIcons.info, text: qsTr("Information") },
+            { id: ControlListModel.LANG_BUTTON, file: "LangButton.qml", label: VLCIcons.audiosub, text: qsTr("Open subtitles") },
+            { id: ControlListModel.BOOKMARK_BUTTON, file: "BookmarkButton.qml", label: VLCIcons.bookmark, text: qsTr("Bookmark Button") },
+            { id: ControlListModel.CHAPTER_PREVIOUS_BUTTON, file: "ChapterPreviousButton.qml", label: VLCIcons.dvd_prev, text: qsTr("Previous chapter") },
+            { id: ControlListModel.CHAPTER_NEXT_BUTTON, file: "ChapterNextButton.qml", label: VLCIcons.dvd_next, text: qsTr("Next chapter") },
+            { id: ControlListModel.VOLUME, file: "VolumeWidget.qml", label: VLCIcons.volume_high, text: qsTr("Volume Widget") },
+            { id: ControlListModel.NAVIGATION_BOX, file: "NavigationBoxButton.qml", label: VLCIcons.ic_fluent_arrow_move, text: qsTr("Navigation Box") },
+            { id: ControlListModel.NAVIGATION_BUTTONS, file: "NavigationWidget.qml", label: VLCIcons.dvd_menu, text: qsTr("Navigation") },
+            { id: ControlListModel.DVD_MENUS_BUTTON, file: "DvdMenuButton.qml", label: VLCIcons.dvd_menu, text: qsTr("DVD menus") },
+            { id: ControlListModel.PROGRAM_BUTTON, file: "ProgramButton.qml", label: VLCIcons.tv, text: qsTr("Program Button") },
+            { id: ControlListModel.TELETEXT_BUTTONS, file: "TeletextButton.qml", label: VLCIcons.tvtelx, text: qsTr("Teletext") },
+            { id: ControlListModel.RENDERER_BUTTON, file: "RendererButton.qml", label: VLCIcons.renderer, text: qsTr("Renderer Button") },
+            { id: ControlListModel.ASPECT_RATIO_COMBOBOX, file: "AspectRatioWidget.qml", label: VLCIcons.aspect_ratio, text: qsTr("Aspect Ratio") },
+            { id: ControlListModel.WIDGET_SPACER, file: "SpacerWidget.qml", label: VLCIcons.space, text: qsTr("Spacer") },
+            { id: ControlListModel.WIDGET_SPACER_EXTEND, file: "ExpandingSpacerWidget.qml", label: VLCIcons.space, text: qsTr("Expanding Spacer") },
+            { id: ControlListModel.PLAYER_SWITCH_BUTTON, file: "PlayerSwitchButton.qml", label: VLCIcons.fullscreen, text: qsTr("Switch Player") },
+            { id: ControlListModel.ARTWORK_INFO, file: "ArtworkInfoWidget.qml", label: VLCIcons.info, text: qsTr("Artwork Info") },
+            { id: ControlListModel.PLAYBACK_SPEED_BUTTON, file: "PlaybackSpeedButton.qml", label: "1x", text: qsTr("Playback Speed") },
+            { id: ControlListModel.HIGH_RESOLUTION_TIME_WIDGET, file: "HighResolutionTimeWidget.qml", label: VLCIcons.info, text: qsTr("High Resolution Time") },
+            ..._externalControls
+        ]
+    })
+
+    property var _externalControls: []
+
+    readonly property string externalControlPath: (StandardPaths.writableLocation(StandardPaths.AppConfigLocation) + "/qt-external-controls/")
+
+    function refreshExternalControls() {
+        const files = MainCtx.getFilesInDirectory(externalControlPath)
+        let externalControls = []
+        if (files.length > 0) {
+            for (const file of files) {
+                if (file.includes('-') && file.endsWith('.qml')) {
+                    const split = file.split('-')
+                    const id = Math.trunc(Number(split[0]))
+                    if (id >= 0 && id <= (ControlListModel.EXTERNAL_END - ControlListModel.EXTERNAL)) {
+                        const text = split[1].slice(0, -4) // slice the ".qml" extension
+                        if (text.length > 0) {
+                            const obj = { id: ControlListModel.EXTERNAL + id, file: file, label: VLCIcons.dropzone, text: text }
+                            externalControls.push(obj)
+                        }
+                    }
+                }
+            }
+        }
+        _externalControls = externalControls
+    }
+
+
+    Component.onCompleted: {
+        refreshExternalControls()
+    }
+
+    function _control(id) {
+        const entry = controlList.controls.find( function(e) { return ( e.id === id ) } )
+        const isExternal = (id >= ControlListModel.EXTERNAL && id <= ControlListModel.EXTERNAL_END)
 
         if (entry === undefined) {
-            console.warn("control delegate id " + id +  " doesn't exist")
+            if (isExternal)
+                console.warn("external control with id " + (id - ControlListModel.EXTERNAL) + " does not exist")
+            else
+                console.warn("internal control with id " + id +  " doesn't exist")
             return { source: controlPath + "Fallback.qml" }
         }
 
-        entry.source = controlPath + entry.file
+        let dir
+        if (isExternal)
+            dir = externalControlPath
+        else
+            dir = controlPath
+
+        entry.source = dir + entry.file
 
         return entry
     }
diff --git a/modules/gui/qt/plugins.hpp b/modules/gui/qt/plugins.hpp
index 64c759c4712f..d38f9282b9f9 100644
--- a/modules/gui/qt/plugins.hpp
+++ b/modules/gui/qt/plugins.hpp
@@ -57,6 +57,7 @@
     Q_IMPORT_QML_PLUGIN(QtGraphicalEffectsPlugin)
     Q_IMPORT_QML_PLUGIN(QtGraphicalEffectsPrivatePlugin)
 #endif
+    Q_IMPORT_QML_PLUGIN(QtQmlCorePlugin)
     Q_IMPORT_QML_PLUGIN(QtQmlModelsPlugin)
     Q_IMPORT_QML_PLUGIN(QtQmlPlugin)
     Q_IMPORT_QML_PLUGIN(QtQmlWorkerScriptPlugin)
-- 
GitLab


From 1d1f1a4e58371471ad6da320433b60eabdbadf62 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:54:56 +0200
Subject: [PATCH 3/6] qt: introduce `FileSystemWatcher`

---
 modules/gui/qt/Makefile.am                |  2 +
 modules/gui/qt/meson.build                |  2 +
 modules/gui/qt/util/filesystemwatcher.hpp | 70 +++++++++++++++++++++++
 3 files changed, 74 insertions(+)
 create mode 100644 modules/gui/qt/util/filesystemwatcher.hpp

diff --git a/modules/gui/qt/Makefile.am b/modules/gui/qt/Makefile.am
index fcc8e3b9e9be..bfd2d79fb80c 100644
--- a/modules/gui/qt/Makefile.am
+++ b/modules/gui/qt/Makefile.am
@@ -334,6 +334,7 @@ libqt_plugin_la_SOURCES = \
 	util/vlcqtmessagehandler.hpp \
 	util/colorizedsvgicon.cpp \
 	util/colorizedsvgicon.hpp \
+	util/filesystemwatcher.hpp \
 	widgets/native/animators.cpp \
 	widgets/native/animators.hpp \
 	widgets/native/customwidgets.cpp widgets/native/customwidgets.hpp \
@@ -487,6 +488,7 @@ nodist_libqt_plugin_la_SOURCES = \
 	util/variables.moc.cpp \
 	util/vlctick.moc.cpp \
 	util/dismiss_popup_event_filter.moc.cpp \
+	util/filesystemwatcher.moc.cpp \
 	util/list_selection_model.moc.cpp \
 	util/vlchotkeyconverter.moc.cpp \
 	util/qsgtextureview.moc.cpp \
diff --git a/modules/gui/qt/meson.build b/modules/gui/qt/meson.build
index 3740f7ac1436..c1d2c0e9116f 100644
--- a/modules/gui/qt/meson.build
+++ b/modules/gui/qt/meson.build
@@ -145,6 +145,7 @@ moc_headers = files(
     'util/dismiss_popup_event_filter.hpp',
     'util/list_selection_model.hpp',
     'util/qsgtextureview.hpp',
+    'util/filesystemwatcher.hpp',
     'widgets/native/animators.hpp',
     'widgets/native/csdthemeimage.hpp',
     'widgets/native/customwidgets.hpp',
@@ -478,6 +479,7 @@ some_sources = files(
     'util/dismiss_popup_event_filter.hpp',
     'util/list_selection_model.cpp',
     'util/list_selection_model.hpp',
+    'util/filesystemwatcher.hpp',
     'util/model_recovery_agent.hpp',
     'util/vlcqtmessagehandler.cpp',
     'util/vlcqtmessagehandler.hpp',
diff --git a/modules/gui/qt/util/filesystemwatcher.hpp b/modules/gui/qt/util/filesystemwatcher.hpp
new file mode 100644
index 000000000000..998eb509ab24
--- /dev/null
+++ b/modules/gui/qt/util/filesystemwatcher.hpp
@@ -0,0 +1,70 @@
+/*****************************************************************************
+ * Copyright (C) 2025 VLC authors and VideoLAN
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * ( at your option ) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
+ *****************************************************************************/
+#ifndef FILESYSTEMWATCHER_HPP
+#define FILESYSTEMWATCHER_HPP
+
+#include <QFileSystemWatcher>
+#include <QQmlEngine>
+
+class FileSystemWatcher : public QFileSystemWatcher
+{
+    Q_OBJECT
+
+    Q_PROPERTY(QString path MEMBER m_path WRITE setPath NOTIFY pathChanged FINAL)
+
+    QML_ELEMENT
+
+public:
+    explicit FileSystemWatcher(QObject *parent = nullptr) : QFileSystemWatcher(parent)
+    { }
+
+    void setPath(const QString& path)
+    {
+        QString localFile = QUrl(path).toLocalFile();
+
+        if (localFile == m_path)
+            return;
+
+        bool changed = false;
+
+        if (!m_path.isEmpty())
+        {
+            removePath(m_path);
+            m_path.clear();
+            changed = true;
+        }
+
+        if (!localFile.isEmpty() && addPath(localFile))
+        {
+            m_path = localFile;
+            changed = true;
+        }
+
+        if (changed)
+            emit pathChanged();
+
+    }
+
+signals:
+    void pathChanged();
+
+private:
+    QString m_path;
+};
+
+#endif // FILESYSTEMWATCHER_HPP
-- 
GitLab


From 19857b58aef863eb18ef708ce37f86e693cc0967 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:55:05 +0200
Subject: [PATCH 4/6] qt: register `FileSystemWatcher`

---
 modules/gui/qt/maininterface/mainui.cpp | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/modules/gui/qt/maininterface/mainui.cpp b/modules/gui/qt/maininterface/mainui.cpp
index f3f3450b02aa..4d78ac7fa898 100644
--- a/modules/gui/qt/maininterface/mainui.cpp
+++ b/modules/gui/qt/maininterface/mainui.cpp
@@ -46,6 +46,7 @@
 #include "util/csdbuttonmodel.hpp"
 #include "util/vlctick.hpp"
 #include "util/list_selection_model.hpp"
+#include "util/filesystemwatcher.hpp"
 
 #include "dialogs/help/aboutmodel.hpp"
 #include "dialogs/dialogs_provider.hpp"
@@ -337,6 +338,7 @@ void MainUI::registerQMLTypes()
         qmlRegisterType<FlickableScrollHandler>( uri, versionMajor, versionMinor, "FlickableScrollHandler" );
         qmlRegisterType<ListSelectionModel>( uri, versionMajor, versionMinor, "ListSelectionModel" );
         qmlRegisterType<DoubleClickIgnoringItem>( uri, versionMajor, versionMinor, "DoubleClickIgnoringItem" );
+        qmlRegisterType<FileSystemWatcher>( uri, versionMajor, versionMinor, "FileSystemWatcher" );
 
         qmlRegisterModule(uri, versionMajor, versionMinor);
         qmlProtectModule(uri, versionMajor);
-- 
GitLab


From 5f617de400b0d995e0f6d7c3306f544d333b95c7 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:56:17 +0200
Subject: [PATCH 5/6] qml: use `FileSystemWatcher` in `ToolbarEditor`

This makes the widget grid view show the newly
added controls while the tool bar is open.
---
 .../qt/dialogs/toolbar/qml/ToolbarEditor.qml  | 50 +++++++++++++++++++
 1 file changed, 50 insertions(+)

diff --git a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditor.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditor.qml
index 0ae25ad93763..df2b2ad61b8b 100644
--- a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditor.qml
+++ b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditor.qml
@@ -26,6 +26,7 @@ import VLC.Style
 import VLC.Widgets as Widgets
 import VLC.Util
 import VLC.PlayerControls
+import VLC.Player // PlayerControlbarControls
 
 Item {
     id: root
@@ -44,6 +45,55 @@ Item {
         colorSet: ColorContext.View
     }
 
+    FileSystemWatcher {
+        // This is added to automatically update the buttons list, which
+        // is useful if the user adds a new external control while the
+        // dialog is open. This could be added to the PlayerControlbarControls
+        // singleton so that updates occur when the toolbar editor dialog
+        // is not open, but I see little reason for that as the user should
+        // restart the application instead if they make changes while the
+        // dialog is not open.
+
+        path: {
+            const dir = PlayerControlbarControls.externalControlPath
+            if (MainCtx.dirExists(dir))
+                return dir
+            else {
+                // Prepare:
+                if (MainCtx.mkDir(dir)) {
+                    MainCtx.mkFile(dir + "/README.txt",
+                                   qsTr("You may place external controls in this directory.\n" +
+                                        "The control file name must be <id>-<name>.qml, where\n" +
+                                        "<id> is an arbitrary integer between 0 and %1, and\n" +
+                                        "<name> is the name of the control. An example control\n" +
+                                        "is available below:\n\n" +
+                                        "```\n" +
+                                        "import QtQuick\n\n" +
+                                        "import VLC.Widgets as Widgets\n" +
+                                        "import VLC.Style\n" +
+                                        "import VLC.Player\n" +
+                                        "import VLC.Playlist\n\n" +
+                                        "Widgets.IconToolButton {\n" +
+                                        "   id: stopBtn\n" +
+                                        "   enabled: Player.isStarted\n" +
+                                        "   text: VLCIcons.stop\n" +
+                                        "   onClicked: MainPlaylistController.stop()\n" +
+                                        "   description: qsTr(\"Stop\") \n" +
+                                        "}\n" +
+                                        "```\n").arg(ControlListModel.EXTERNAL_END - ControlListModel.EXTERNAL))
+                    return dir
+                }
+            }
+
+            return ""
+        }
+
+        onDirectoryChanged: {
+            // Files added or removed:
+            PlayerControlbarControls.refreshExternalControls()
+        }
+    }
+
     ColumnLayout{
         anchors.fill: parent
         spacing: 0
-- 
GitLab


From 7d40f4caa68f6a20ea4d025e3c15bdee5e3c7ca2 Mon Sep 17 00:00:00 2001
From: Fatih Uzunoglu <fuzun54@outlook.com>
Date: Fri, 10 Jan 2025 16:56:42 +0200
Subject: [PATCH 6/6] qml: add browse external controls directory button to
 `ToolbarEditorDialog`

---
 .../qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml    | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml
index ffff3c17cb82..2b55111d053a 100644
--- a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml
+++ b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml
@@ -24,7 +24,7 @@ import VLC.MainInterface
 import VLC.Widgets as Widgets
 import VLC.Style
 import VLC.Dialogs
-
+import VLC.Player // PlayerControlbarControls
 
 WindowDialog {
     id: root
@@ -181,6 +181,15 @@ WindowDialog {
                           MainCtx.controlbarProfileModel.deleteSelectedProfile()
                     }
                 }
+
+                Widgets.IconToolButton {
+                    description: qsTr("Browse the external controls directory...")
+                    text: VLCIcons.ellipsis
+
+                    onClicked: {
+                        Qt.openUrlExternally(PlayerControlbarControls.externalControlPath)
+                    }
+                }
             }
 
             // The main context of the toolbareditor dialog:
-- 
GitLab