diff --git a/modules/gui/qt/Makefile.am b/modules/gui/qt/Makefile.am
index fcc8e3b9e9be284327b1be37f1f2897480cacc57..bfd2d79fb80ce18042566f8a1f8cb5c2797b07b5 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/dialogs/toolbar/qml/EditorDNDDelegate.qml b/modules/gui/qt/dialogs/toolbar/qml/EditorDNDDelegate.qml
index c885c2729d58f1f6ff7d92a3a47b1a6f3e8821da..1caf1957f4eaecca3e44d5baa43412b757252c8e 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/ToolbarEditor.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditor.qml
index 0ae25ad93763d3c5d17e1e69aea8f02293c5a599..df2b2ad61b8b1782d0211b2b966d04ad37421d06 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
diff --git a/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorButtonList.qml
index 3d4c9e2c41edc53cf5ed8fb0dc0ebfa94e000c07..e1c93385afaf7b3197812628f97fa26a01e2e020 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/dialogs/toolbar/qml/ToolbarEditorDialog.qml b/modules/gui/qt/dialogs/toolbar/qml/ToolbarEditorDialog.qml
index ffff3c17cb82372ea145d28c3f647f273e2e2c57..2b55111d053a3caf4e326f265e4844c0986bc140 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:
diff --git a/modules/gui/qt/maininterface/mainctx.cpp b/modules/gui/qt/maininterface/mainctx.cpp
index cfa81d5e2de2ddd7710641df0e4860e86fc18528..d0406f9d9d2b8715e40aeefd760508d4cb319950 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 ba82c43bccef7dbdaf5a6a78570655ee136a6687..127b251c152ae7ceadf411c8152a9bb57ebdfd73 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/mainui.cpp b/modules/gui/qt/maininterface/mainui.cpp
index f3f3450b02aa1b92635dc61f32a1e55f55a8b755..4d78ac7fa8985b55ef3eff3918a13127f06197ce 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);
diff --git a/modules/gui/qt/maininterface/qml/MainInterface.qml b/modules/gui/qt/maininterface/qml/MainInterface.qml
index 57897e53e7107815fbab2f94a19b2051f4b4cfa9..118b534cf6767a9bae74de618f76b9726decf8e8 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/meson.build b/modules/gui/qt/meson.build
index 3740f7ac1436660cf2151171abdc589fb50a5de3..c1d2c0e9116f741e809246cc2c294ecc2c928620 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/player/control_list_model.hpp b/modules/gui/qt/player/control_list_model.hpp
index 127cd9fea750a2482e26700985422a6da50a33b6..dafb88b24bf7d2fb6d28103b93afde85a3644f50 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 127f02c9805876fb0337fc1bffcf98f2dc51ae70..a63e41c9480d2e01514884d6419370fe112a668d 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 9b4c78fe66053dfbb05672b0362a1487746ec6da..1b429dbbfb6c634c8f126bc8770b1771b0b1d766 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/player/qml/controlbarcontrols/Fallback.qml b/modules/gui/qt/player/qml/controlbarcontrols/Fallback.qml
index 6686e5b18586ec3dd42cb6b6c9a764ca870748c6..fab3ef262c24e0948d04b5a8ad2f1086abfbc872 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
     }
 }
diff --git a/modules/gui/qt/plugins.hpp b/modules/gui/qt/plugins.hpp
index 64c759c4712f8a73b6607d1aaefbd7b5f67cb3a9..d38f9282b9f9998c4c83c2cad3da502092cb334f 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)
diff --git a/modules/gui/qt/util/filesystemwatcher.hpp b/modules/gui/qt/util/filesystemwatcher.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..998eb509ab24fb9cf3018ea1c7c4f64a90b712a8
--- /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