Browse Source

frontend-tools: Add scripting tool

Adds a scripting tool that gives the users the ability to load scripts
and change their settings.
jp9000 7 years ago
parent
commit
0b01e4c7a1

+ 29 - 0
UI/frontend-plugins/frontend-tools/CMakeLists.txt

@@ -11,6 +11,8 @@ if(UNIX AND NOT APPLE)
 	include_directories(${X11_INCLUDE_DIR})
 endif()
 
+include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/deps/obs-scripting")
+
 configure_file(
 	"${CMAKE_CURRENT_SOURCE_DIR}/frontend-tools-config.h.in"
 	"${CMAKE_BINARY_DIR}/config/frontend-tools-config.h")
@@ -21,12 +23,19 @@ set(frontend-tools_HEADERS
 	auto-scene-switcher.hpp
 	output-timer.hpp
 	tool-helpers.hpp
+	../../properties-view.hpp
+	../../properties-view.moc.hpp
+	../../vertical-scroll-area.hpp
+	../../double-slider.hpp
 	)
 set(frontend-tools_SOURCES
 	${frontend-tools_SOURCES}
 	auto-scene-switcher.cpp
 	frontend-tools.c
 	output-timer.cpp
+	../../properties-view.cpp
+	../../vertical-scroll-area.cpp
+	../../double-slider.cpp
 	)
 set(frontend-tools_UI
 	${frontend-tools_UI}
@@ -34,6 +43,25 @@ set(frontend-tools_UI
 	forms/output-timer.ui
 	)
 
+if(ENABLE_SCRIPTING)
+	set(frontend-tools_HEADERS
+		${frontend-tools_HEADERS}
+		scripts.hpp
+		)
+	set(frontend-tools_SOURCES
+		${frontend-tools_SOURCES}
+		scripts.cpp
+		)
+	set(frontend-tools_UI
+		${frontend-tools_UI}
+		forms/scripts.ui
+		)
+	set(EXTRA_LIBS
+		${EXTRA_LIBS}
+		obs-scripting
+		)
+endif()
+
 if(WIN32)
 	set(frontend-tools_PLATFORM_SOURCES
 		auto-scene-switcher-win.cpp)
@@ -79,6 +107,7 @@ add_library(frontend-tools MODULE
 	)
 target_link_libraries(frontend-tools
 	${frontend-tools_PLATFORM_LIBS}
+	${EXTRA_LIBS}
 	obs-frontend-api
 	Qt5::Widgets
 	libobs)

+ 19 - 0
UI/frontend-plugins/frontend-tools/data/locale/en-US.ini

@@ -24,3 +24,22 @@ OutputTimer.Stream.StoppingIn="Streaming stopping in:"
 OutputTimer.Record.StoppingIn="Recording stopping in:"
 OutputTimer.Stream.EnableEverytime="Enable streaming timer every time"
 OutputTimer.Record.EnableEverytime="Enable recording timer every time"
+
+Scripts="Scripts"
+LoadedScripts="Loaded Scripts"
+AddScripts="Add Scripts"
+RemoveScripts="Remove Scripts"
+ReloadScripts="Reload Scripts"
+LoadedScripts="Loaded Scripts"
+LuaSettings="Lua Settings"
+LuaSettings.LuaDepPaths="Lua Dependency Paths"
+LuaSettings.AddLuaDepPath="Add Lua Dependency Path"
+LuaSettings.RemoveLuaDepPath="Remove Lua Dependency Path"
+PythonSettings="Python Settings"
+PythonSettings.PythonInstallPath32bit="Python Install Path (32bit)"
+PythonSettings.PythonInstallPath64bit="Python Install Path (64bit)"
+PythonSettings.BrowsePythonPath="Browse Python Path"
+ScriptLogWindow="Script Log"
+
+FileFilter.ScriptFiles="Script Files"
+FileFilter.AllFiles="All Files"

+ 180 - 0
UI/frontend-plugins/frontend-tools/data/scripts/countdown.lua

@@ -0,0 +1,180 @@
+obs           = obslua
+source_name   = ""
+total_seconds = 0
+
+cur_seconds   = 0
+last_text     = ""
+stop_text     = ""
+activated     = false
+
+hotkey_id     = obs.OBS_INVALID_HOTKEY_ID
+
+-- Function to set the time text
+function set_time_text()
+	local seconds       = math.floor(cur_seconds % 60)
+	local total_minutes = math.floor(cur_seconds / 60)
+	local minutes       = math.floor(total_minutes % 60)
+	local hours         = math.floor(total_minutes / 60)
+	local text          = string.format("%02d:%02d:%02d", hours, minutes, seconds)
+
+	if cur_seconds < 1 then
+		text = stop_text
+	end
+
+	if text ~= last_text then
+		local source = obs.obs_get_source_by_name(source_name)
+		if source ~= nil then
+			local settings = obs.obs_data_create()
+			obs.obs_data_set_string(settings, "text", text)
+			obs.obs_source_update(source, settings)
+			obs.obs_data_release(settings)
+			obs.obs_source_release(source)
+		end
+	end
+
+	last_text = text
+end
+
+function timer_callback()
+	cur_seconds = cur_seconds - 1
+	if cur_seconds < 0 then
+		obs.remove_current_callback()
+		cur_seconds = 0
+	end
+
+	set_time_text()
+end
+
+function activate(activating)
+	if activated == activating then
+		return
+	end
+
+	activated = activating
+
+	if activating then
+		cur_seconds = total_seconds
+		set_time_text()
+		obs.timer_add(timer_callback, 1000)
+	else
+		obs.timer_remove(timer_callback)
+	end
+end
+
+-- Called when a source is activated/deactivated
+function activate_signal(cd, activating)
+	local source = obs.calldata_source(cd, "source")
+	if source ~= nil then
+		local name = obs.obs_source_get_name(source)
+		if (name == source_name) then
+			activate(activating)
+		end
+	end
+end
+
+function source_activated(cd)
+	activate_signal(cd, true)
+end
+
+function source_deactivated(cd)
+	activate_signal(cd, false)
+end
+
+function reset(pressed)
+	if not pressed then
+		return
+	end
+
+	activate(false)
+	local source = obs.obs_get_source_by_name(source_name)
+	if source ~= nil then
+		local active = obs.obs_source_active(source)
+		obs.obs_source_release(source)
+		activate(active)
+	end
+end
+
+function reset_button_clicked(props, p)
+	reset(true)
+	return false
+end
+
+----------------------------------------------------------
+
+-- A function named script_properties defines the properties that the user
+-- can change for the entire script module itself
+function script_properties()
+	local props = obs.obs_properties_create()
+	obs.obs_properties_add_int(props, "duration", "Duration (minutes)", 1, 100000, 1)
+
+	local p = obs.obs_properties_add_list(props, "source", "Text Source", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING)
+	local sources = obs.obs_enum_sources()
+	if sources ~= nil then
+		for _, source in ipairs(sources) do
+			source_id = obs.obs_source_get_id(source)
+			if source_id == "text_gdiplus" or source_id == "text_ft2_source" then
+				local name = obs.obs_source_get_name(source)
+				obs.obs_property_list_add_string(p, name, name)
+			end
+		end
+	end
+	obs.source_list_release(sources)
+
+	obs.obs_properties_add_text(props, "stop_text", "Final Text", obs.OBS_TEXT_DEFAULT)
+	obs.obs_properties_add_button(props, "reset_button", "Reset Timer", reset_button_clicked)
+
+	return props
+end
+
+-- A function named script_description returns the description shown to
+-- the user
+function script_description()
+	return "Sets a text source to act as a countdown timer when the source is active.\n\nMade by Jim"
+end
+
+-- A function named script_update will be called when settings are changed
+function script_update(settings)
+	activate(false)
+
+	total_seconds = obs.obs_data_get_int(settings, "duration") * 60
+	source_name = obs.obs_data_get_string(settings, "source")
+	stop_text = obs.obs_data_get_string(settings, "stop_text")
+
+	reset(true)
+end
+
+-- A function named script_defaults will be called to set the default settings
+function script_defaults(settings)
+	obs.obs_data_set_default_int(settings, "duration", 5)
+	obs.obs_data_set_default_string(settings, "stop_text", "Starting soon (tm)")
+end
+
+-- A function named script_save will be called when the script is saved
+--
+-- NOTE: This function is usually used for saving extra data (such as in this
+-- case, a hotkey's save data).  Settings set via the properties are saved
+-- automatically.
+function script_save(settings)
+	local hotkey_save_array = obs.obs_hotkey_save(hotkey_id)
+	obs.obs_data_set_array(settings, "reset_hotkey", hotkey_save_array)
+	obs.obs_data_array_release(hotkey_save_array)
+end
+
+-- a function named script_load will be called on startup
+function script_load(settings)
+	-- Connect hotkey and activation/deactivation signal callbacks
+	--
+	-- NOTE: These particular script callbacks do not necessarily have to
+	-- be disconnected, as callbacks will automatically destroy themselves
+	-- if the script is unloaded.  So there's no real need to manually
+	-- disconnect callbacks that are intended to last until the script is
+	-- unloaded.
+	local sh = obs.obs_get_signal_handler()
+	obs.signal_handler_connect(sh, "source_activate", source_activated)
+	obs.signal_handler_connect(sh, "source_deactivate", source_deactivated)
+
+	hotkey_id = obs.obs_hotkey_register_frontend("reset_timer_thingy", "Reset Timer", reset)
+	local hotkey_save_array = obs.obs_data_get_array(settings, "reset_hotkey")
+	obs.obs_hotkey_load(hotkey_id, hotkey_save_array)
+	obs.obs_data_array_release(hotkey_save_array)
+end

+ 266 - 0
UI/frontend-plugins/frontend-tools/forms/scripts.ui

@@ -0,0 +1,266 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ScriptsTool</class>
+ <widget class="QWidget" name="ScriptsTool">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>775</width>
+    <height>492</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Scripts</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QTabWidget" name="tabWidget">
+     <property name="currentIndex">
+      <number>0</number>
+     </property>
+     <widget class="QWidget" name="scriptsTab">
+      <attribute name="title">
+       <string>Scripts</string>
+      </attribute>
+      <layout class="QHBoxLayout" name="horizontalLayout_4">
+       <item>
+        <layout class="QVBoxLayout" name="verticalLayout_3">
+         <item>
+          <widget class="QLabel" name="label_2">
+           <property name="text">
+            <string>LoadedScripts</string>
+           </property>
+           <property name="buddy">
+            <cstring>scripts</cstring>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QListWidget" name="scripts">
+           <property name="sortingEnabled">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <layout class="QHBoxLayout" name="horizontalLayout_2">
+           <item>
+            <widget class="QPushButton" name="addScripts">
+             <property name="maximumSize">
+              <size>
+               <width>22</width>
+               <height>22</height>
+              </size>
+             </property>
+             <property name="toolTip">
+              <string>AddScripts</string>
+             </property>
+             <property name="accessibleName">
+              <string>AddScripts</string>
+             </property>
+             <property name="text">
+              <string notr="true"/>
+             </property>
+             <property name="flat">
+              <bool>true</bool>
+             </property>
+             <property name="themeID" stdset="0">
+              <string notr="true">addIconSmall</string>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QPushButton" name="removeScripts">
+             <property name="maximumSize">
+              <size>
+               <width>22</width>
+               <height>22</height>
+              </size>
+             </property>
+             <property name="toolTip">
+              <string>RemoveScripts</string>
+             </property>
+             <property name="accessibleName">
+              <string>RemoveScripts</string>
+             </property>
+             <property name="text">
+              <string notr="true"/>
+             </property>
+             <property name="flat">
+              <bool>true</bool>
+             </property>
+             <property name="themeID" stdset="0">
+              <string notr="true">removeIconSmall</string>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QPushButton" name="reloadScripts">
+             <property name="maximumSize">
+              <size>
+               <width>22</width>
+               <height>22</height>
+              </size>
+             </property>
+             <property name="toolTip">
+              <string>ReloadScripts</string>
+             </property>
+             <property name="accessibleName">
+              <string>ReloadScripts</string>
+             </property>
+             <property name="text">
+              <string notr="true"/>
+             </property>
+             <property name="flat">
+              <bool>true</bool>
+             </property>
+             <property name="themeID" stdset="0">
+              <string notr="true">refreshIconSmall</string>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <spacer name="horizontalSpacer_2">
+             <property name="orientation">
+              <enum>Qt::Horizontal</enum>
+             </property>
+             <property name="sizeHint" stdset="0">
+              <size>
+               <width>40</width>
+               <height>20</height>
+              </size>
+             </property>
+            </spacer>
+           </item>
+           <item>
+            <widget class="QPushButton" name="scriptLog">
+             <property name="text">
+              <string>ScriptLogWindow</string>
+             </property>
+            </widget>
+           </item>
+          </layout>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <layout class="QVBoxLayout" name="propertiesLayout">
+         <item>
+          <widget class="QLabel" name="label_3">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="text">
+            <string>Description</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="description">
+           <property name="text">
+            <string notr="true"/>
+           </property>
+           <property name="wordWrap">
+            <bool>true</bool>
+           </property>
+           <property name="margin">
+            <number>12</number>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="pythonSettingsTab">
+      <attribute name="title">
+       <string>PythonSettings</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_2">
+       <item>
+        <widget class="QLabel" name="pythonPathLabel">
+         <property name="text">
+          <string notr="true"/>
+         </property>
+         <property name="buddy">
+          <cstring>pythonPath</cstring>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <layout class="QHBoxLayout" name="horizontalLayout_3">
+         <item>
+          <widget class="QLineEdit" name="pythonPath">
+           <property name="readOnly">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QPushButton" name="pythonPathBrowse">
+           <property name="accessibleName">
+            <string>PythonSettings.BrowsePythonPath</string>
+           </property>
+           <property name="text">
+            <string>Browse</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <spacer name="verticalSpacer">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>510</width>
+           <height>306</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="close">
+       <property name="text">
+        <string>Close</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <tabstops>
+  <tabstop>tabWidget</tabstop>
+  <tabstop>close</tabstop>
+  <tabstop>pythonPath</tabstop>
+  <tabstop>pythonPathBrowse</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>

+ 19 - 0
UI/frontend-plugins/frontend-tools/frontend-tools-config.h.in

@@ -1,3 +1,22 @@
 #pragma once
 
+#ifndef TRUE
+#define TRUE 1
+#endif
+
+#ifndef ON
+#define ON 1
+#endif
+
+#ifndef FALSE
+#define FALSE 0
+#endif
+
+#ifndef OFF
+#define OFF 0
+#endif
+
 #define BUILD_CAPTIONS @BUILD_CAPTIONS@
+#define ENABLE_SCRIPTING @ENABLE_SCRIPTING@
+#define COMPILE_LUA @COMPILE_LUA@
+#define COMPILE_PYTHON @COMPILE_PYTHON@

+ 11 - 0
UI/frontend-plugins/frontend-tools/frontend-tools.c

@@ -15,6 +15,11 @@ void FreeCaptions();
 void InitOutputTimer();
 void FreeOutputTimer();
 
+#if ENABLE_SCRIPTING
+void InitScripts();
+void FreeScripts();
+#endif
+
 bool obs_module_load(void)
 {
 #if defined(_WIN32) && BUILD_CAPTIONS
@@ -22,6 +27,9 @@ bool obs_module_load(void)
 #endif
 	InitSceneSwitcher();
 	InitOutputTimer();
+#if ENABLE_SCRIPTING
+	InitScripts();
+#endif
 	return true;
 }
 
@@ -32,4 +40,7 @@ void obs_module_unload(void)
 #endif
 	FreeSceneSwitcher();
 	FreeOutputTimer();
+#if ENABLE_SCRIPTING
+	FreeScripts();
+#endif
 }

+ 519 - 0
UI/frontend-plugins/frontend-tools/scripts.cpp

@@ -0,0 +1,519 @@
+#include "scripts.hpp"
+#include "frontend-tools-config.h"
+#include "../../properties-view.hpp"
+
+#include <QFileDialog>
+#include <QPlainTextEdit>
+#include <QVBoxLayout>
+#include <QScrollBar>
+#include <QFontDatabase>
+#include <QFont>
+#include <QDialogButtonBox>
+#include <QResizeEvent>
+
+#include <obs.hpp>
+#include <obs-module.h>
+#include <obs-frontend-api.h>
+#include <obs-scripting.h>
+
+#include <util/config-file.h>
+#include <util/platform.h>
+#include <util/util.hpp>
+
+#include <string>
+
+#include "ui_scripts.h"
+
+#if COMPILE_PYTHON && (defined(_WIN32) || defined(__APPLE__))
+#define PYTHON_UI 1
+#else
+#define PYTHON_UI 0
+#endif
+
+#if ARCH_BITS == 64
+#define ARCH_NAME "64bit"
+#else
+#define ARCH_NAME "32bit"
+#endif
+
+#define PYTHONPATH_LABEL_TEXT "PythonSettings.PythonInstallPath" ARCH_NAME
+
+/* ----------------------------------------------------------------- */
+
+using OBSScript = OBSObj<obs_script_t*, obs_script_destroy>;
+
+struct ScriptData {
+	std::vector<OBSScript> scripts;
+
+	inline obs_script_t *FindScript(const char *path)
+	{
+		for (OBSScript &script : scripts) {
+			const char *script_path = obs_script_get_path(script);
+			if (strcmp(script_path, path) == 0) {
+				return script;
+			}
+		}
+
+		return nullptr;
+	}
+
+	bool ScriptOpened(const char *path)
+	{
+		for (OBSScript &script : scripts) {
+			const char *script_path = obs_script_get_path(script);
+			if (strcmp(script_path, path) == 0) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+};
+
+static ScriptData *scriptData = nullptr;
+static ScriptsTool *scriptsWindow = nullptr;
+static ScriptLogWindow *scriptLogWindow = nullptr;
+static QPlainTextEdit *scriptLogWidget = nullptr;
+
+/* ----------------------------------------------------------------- */
+
+ScriptLogWindow::ScriptLogWindow() : QWidget(nullptr)
+{
+	const QFont fixedFont =
+		QFontDatabase::systemFont(QFontDatabase::FixedFont);
+
+	QPlainTextEdit *edit = new QPlainTextEdit();
+	edit->setReadOnly(true);
+	edit->setFont(fixedFont);
+	edit->setWordWrapMode(QTextOption::NoWrap);
+
+	QDialogButtonBox *buttonBox = new QDialogButtonBox(
+			QDialogButtonBox::Close);
+	connect(buttonBox, &QDialogButtonBox::rejected, this, &QWidget::hide);
+
+	QVBoxLayout *layout = new QVBoxLayout();
+	layout->addWidget(edit);
+	layout->addWidget(buttonBox);
+
+	setLayout(layout);
+	scriptLogWidget = edit;
+
+	resize(600, 400);
+
+	config_t *global_config = obs_frontend_get_global_config();
+	const char *geom = config_get_string(global_config,
+			"ScriptLogWindow", "geometry");
+	if (geom != nullptr) {
+		QByteArray ba = QByteArray::fromBase64(QByteArray(geom));
+		restoreGeometry(ba);
+	}
+
+	setWindowTitle(obs_module_text("ScriptLogWindow"));
+
+	connect(edit->verticalScrollBar(), &QAbstractSlider::sliderMoved,
+			this, &ScriptLogWindow::ScrollChanged);
+}
+
+ScriptLogWindow::~ScriptLogWindow()
+{
+	config_t *global_config = obs_frontend_get_global_config();
+	config_set_string(global_config,
+			"ScriptLogWindow", "geometry",
+			saveGeometry().toBase64().constData());
+}
+
+void ScriptLogWindow::ScrollChanged(int val)
+{
+	QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
+	bottomScrolled = (val == scroll->maximum());
+}
+
+void ScriptLogWindow::resizeEvent(QResizeEvent *event)
+{
+	QWidget::resizeEvent(event);
+
+	if (bottomScrolled) {
+		QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
+		scroll->setValue(scroll->maximum());
+	}
+}
+
+void ScriptLogWindow::AddLogMsg(int log_level, QString msg)
+{
+	QScrollBar *scroll = scriptLogWidget->verticalScrollBar();
+	bottomScrolled = scroll->value() == scroll->maximum();
+
+	lines += QStringLiteral("\n");
+	lines += msg;
+	scriptLogWidget->setPlainText(lines);
+
+	if (bottomScrolled)
+		scroll->setValue(scroll->maximum());
+
+	if (log_level <= LOG_WARNING) {
+		show();
+		raise();
+	}
+}
+
+void ScriptLogWindow::Clear()
+{
+	lines.clear();
+}
+
+/* ----------------------------------------------------------------- */
+
+ScriptsTool::ScriptsTool()
+	: QWidget (nullptr),
+	  ui      (new Ui_ScriptsTool)
+{
+	ui->setupUi(this);
+	RefreshLists();
+
+#if PYTHON_UI
+	config_t *config = obs_frontend_get_global_config();
+	const char *path = config_get_string(config, "Python",
+			"Path" ARCH_NAME);
+	ui->pythonPath->setText(path);
+	ui->pythonPathLabel->setText(obs_module_text(PYTHONPATH_LABEL_TEXT));
+#else
+	delete ui->pythonSettingsTab;
+	ui->pythonSettingsTab = nullptr;
+#endif
+
+	delete propertiesView;
+	propertiesView = new QWidget();
+	propertiesView->setSizePolicy(QSizePolicy::Expanding,
+			QSizePolicy::Expanding);
+	ui->propertiesLayout->addWidget(propertiesView);
+}
+
+ScriptsTool::~ScriptsTool()
+{
+	delete ui;
+}
+
+void ScriptsTool::RemoveScript(const char *path)
+{
+	for (size_t i = 0; i < scriptData->scripts.size(); i++) {
+		OBSScript &script = scriptData->scripts[i];
+
+		const char *script_path = obs_script_get_path(script);
+		if (strcmp(script_path, path) == 0) {
+			scriptData->scripts.erase(
+					scriptData->scripts.begin() + i);
+			break;
+		}
+	}
+}
+
+void ScriptsTool::ReloadScript(const char *path)
+{
+	for (OBSScript &script : scriptData->scripts) {
+		const char *script_path = obs_script_get_path(script);
+		if (strcmp(script_path, path) == 0) {
+			obs_script_reload(script);
+			break;
+		}
+	}
+}
+
+void ScriptsTool::RefreshLists()
+{
+	ui->scripts->clear();
+
+	for (OBSScript &script : scriptData->scripts) {
+		const char *script_path = obs_script_get_path(script);
+		ui->scripts->addItem(script_path);
+	}
+}
+
+void ScriptsTool::on_close_clicked()
+{
+	close();
+}
+
+void ScriptsTool::on_addScripts_clicked()
+{
+	const char **formats = obs_scripting_supported_formats();
+	const char **cur_format = formats;
+	QString extensions;
+	QString filter;
+
+	while (*cur_format) {
+		if (!extensions.isEmpty())
+			extensions += QStringLiteral(" ");
+
+		extensions += QStringLiteral("*.");
+		extensions += *cur_format;
+
+		cur_format++;
+	}
+
+	if (!extensions.isEmpty()) {
+		filter += obs_module_text("FileFilter.ScriptFiles");
+		filter += QStringLiteral(" (");
+		filter += extensions;
+		filter += QStringLiteral(")");
+	}
+
+	if (filter.isEmpty())
+		return;
+
+	static std::string lastBrowsedDir;
+
+	if (lastBrowsedDir.empty()) {
+		BPtr<char> baseScriptPath = obs_module_file("scripts");
+		lastBrowsedDir = baseScriptPath;
+	}
+
+	QFileDialog dlg(this, obs_module_text("AddScripts"));
+	dlg.setFileMode(QFileDialog::ExistingFiles);
+	dlg.setDirectory(QDir(lastBrowsedDir.c_str()));
+	dlg.setNameFilter(filter);
+	dlg.exec();
+
+	QStringList files = dlg.selectedFiles();
+	if (!files.count())
+		return;
+
+	lastBrowsedDir = dlg.directory().path().toUtf8().constData();
+
+	for (const QString &file : files) {
+		QByteArray pathBytes = file.toUtf8();
+		const char *path = pathBytes.constData();
+
+		if (scriptData->ScriptOpened(path)) {
+			continue;
+		}
+
+		obs_script_t *script = obs_script_create(path, NULL);
+		if (script) {
+			scriptData->scripts.emplace_back(script);
+			ui->scripts->addItem(file);
+		}
+	}
+}
+
+void ScriptsTool::on_removeScripts_clicked()
+{
+	QList<QListWidgetItem *> items = ui->scripts->selectedItems();
+
+	for (QListWidgetItem *item : items)
+		RemoveScript(item->text().toUtf8().constData());
+	RefreshLists();
+}
+
+void ScriptsTool::on_reloadScripts_clicked()
+{
+	QList<QListWidgetItem *> items = ui->scripts->selectedItems();
+	for (QListWidgetItem *item : items)
+		ReloadScript(item->text().toUtf8().constData());
+
+	on_scripts_currentRowChanged(ui->scripts->currentRow());
+}
+
+void ScriptsTool::on_scriptLog_clicked()
+{
+	scriptLogWindow->show();
+	scriptLogWindow->raise();
+}
+
+void ScriptsTool::on_pythonPathBrowse_clicked()
+{
+	QString curPath = ui->pythonPath->text();
+	QString newPath = QFileDialog::getExistingDirectory(
+			this,
+			ui->pythonPathLabel->text(),
+			curPath);
+
+	if (newPath.isEmpty())
+		return;
+
+	QByteArray array = newPath.toUtf8();
+	const char *path = array.constData();
+
+	config_t *config = obs_frontend_get_global_config();
+	config_set_string(config, "Python", "Path" ARCH_NAME, path);
+
+	ui->pythonPath->setText(newPath);
+
+	if (obs_scripting_python_loaded())
+		return;
+	if (!obs_scripting_load_python(path))
+		return;
+
+	for (OBSScript &script : scriptData->scripts) {
+		enum obs_script_lang lang = obs_script_get_lang(script);
+		if (lang == OBS_SCRIPT_LANG_PYTHON) {
+			obs_script_reload(script);
+		}
+	}
+
+	on_scripts_currentRowChanged(ui->scripts->currentRow());
+}
+
+void ScriptsTool::on_scripts_currentRowChanged(int row)
+{
+	ui->propertiesLayout->removeWidget(propertiesView);
+	delete propertiesView;
+
+	if (row == -1) {
+		propertiesView = new QWidget();
+		propertiesView->setSizePolicy(QSizePolicy::Expanding,
+				QSizePolicy::Expanding);
+		ui->propertiesLayout->addWidget(propertiesView);
+		ui->description->setText(QString());
+		return;
+	}
+
+	QByteArray array = ui->scripts->item(row)->text().toUtf8();
+	const char *path = array.constData();
+
+	obs_script_t *script = scriptData->FindScript(path);
+	if (!script) {
+		propertiesView = nullptr;
+		return;
+	}
+
+	OBSData settings = obs_script_get_settings(script);
+	obs_data_release(settings);
+
+	propertiesView = new OBSPropertiesView(settings, script,
+			(PropertiesReloadCallback)obs_script_get_properties,
+			(PropertiesUpdateCallback)obs_script_update);
+	ui->propertiesLayout->addWidget(propertiesView);
+	ui->description->setText(obs_script_get_description(script));
+}
+
+/* ----------------------------------------------------------------- */
+
+extern "C" void FreeScripts()
+{
+	obs_scripting_unload();
+}
+
+static void obs_event(enum obs_frontend_event event, void *)
+{
+	if (event == OBS_FRONTEND_EVENT_EXIT) {
+		delete scriptData;
+		delete scriptsWindow;
+		delete scriptLogWindow;
+
+	} else if (event == OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP) {
+		scriptLogWindow->hide();
+		scriptLogWindow->Clear();
+
+		delete scriptData;
+		scriptData = new ScriptData;
+	}
+}
+
+static void load_script_data(obs_data_t *load_data, bool, void *)
+{
+	obs_data_array_t *array = obs_data_get_array(load_data,
+			"scripts-tool");
+
+	delete scriptData;
+	scriptData = new ScriptData;
+
+	size_t size = obs_data_array_count(array);
+	for (size_t i = 0; i < size; i++) {
+		obs_data_t *obj = obs_data_array_item(array, i);
+		const char *path = obs_data_get_string(obj, "path");
+		obs_data_t *settings = obs_data_get_obj(obj, "settings");
+
+		obs_script_t *script = obs_script_create(path, settings);
+		if (script) {
+			scriptData->scripts.emplace_back(script);
+		}
+
+		obs_data_release(settings);
+		obs_data_release(obj);
+	}
+
+	if (scriptsWindow)
+		scriptsWindow->RefreshLists();
+
+	obs_data_array_release(array);
+}
+
+static void save_script_data(obs_data_t *save_data, bool saving, void *)
+{
+	if (!saving)
+		return;
+
+	obs_data_array_t *array = obs_data_array_create();
+
+	for (OBSScript &script : scriptData->scripts) {
+		const char *script_path = obs_script_get_path(script);
+		obs_data_t *settings = obs_script_save(script);
+
+		obs_data_t *obj = obs_data_create();
+		obs_data_set_string(obj, "path", script_path);
+		obs_data_set_obj(obj, "settings", settings);
+		obs_data_array_push_back(array, obj);
+		obs_data_release(obj);
+
+		obs_data_release(settings);
+	}
+
+	obs_data_set_array(save_data, "scripts-tool", array);
+	obs_data_array_release(array);
+}
+
+static void script_log(void *, obs_script_t *script, int log_level,
+		const char *message)
+{
+	QString qmsg;
+	qmsg = QStringLiteral("[%1] %2").arg(
+			obs_script_get_file(script),
+			message);
+
+	QMetaObject::invokeMethod(scriptLogWindow, "AddLogMsg",
+			Q_ARG(int, log_level),
+			Q_ARG(QString, qmsg));
+}
+
+extern "C" void InitScripts()
+{
+	scriptLogWindow = new ScriptLogWindow();
+
+	obs_scripting_load();
+	obs_scripting_set_log_callback(script_log, nullptr);
+
+	QAction *action = (QAction*)obs_frontend_add_tools_menu_qaction(
+			obs_module_text("Scripts"));
+
+#if PYTHON_UI
+	config_t *config = obs_frontend_get_global_config();
+	const char *python_path = config_get_string(config, "Python",
+			"Path" ARCH_NAME);
+
+	if (!obs_scripting_python_loaded() && python_path && *python_path)
+		obs_scripting_load_python(python_path);
+#endif
+
+	scriptData = new ScriptData;
+
+	auto cb = [] ()
+	{
+		obs_frontend_push_ui_translation(obs_module_get_string);
+
+		if (!scriptsWindow) {
+			scriptsWindow = new ScriptsTool();
+			scriptsWindow->show();
+		} else {
+			scriptsWindow->show();
+			scriptsWindow->raise();
+		}
+
+		obs_frontend_pop_ui_translation();
+	};
+
+	obs_frontend_add_save_callback(save_script_data, nullptr);
+	obs_frontend_add_preload_callback(load_script_data, nullptr);
+	obs_frontend_add_event_callback(obs_event, nullptr);
+
+	action->connect(action, &QAction::triggered, cb);
+}

+ 49 - 0
UI/frontend-plugins/frontend-tools/scripts.hpp

@@ -0,0 +1,49 @@
+#include <QWidget>
+#include <QString>
+
+class Ui_ScriptsTool;
+
+class ScriptLogWindow : public QWidget {
+	Q_OBJECT
+
+	QString lines;
+	bool bottomScrolled = true;
+
+	void resizeEvent(QResizeEvent *event) override;
+
+public:
+	ScriptLogWindow();
+	~ScriptLogWindow();
+
+public slots:
+	void AddLogMsg(int log_level, QString msg);
+	void Clear();
+	void ScrollChanged(int val);
+};
+
+class ScriptsTool : public QWidget {
+	Q_OBJECT
+
+	Ui_ScriptsTool *ui;
+	QWidget *propertiesView = nullptr;
+
+public:
+	ScriptsTool();
+	~ScriptsTool();
+
+	void RemoveScript(const char *path);
+	void ReloadScript(const char *path);
+	void RefreshLists();
+
+public slots:
+	void on_close_clicked();
+
+	void on_addScripts_clicked();
+	void on_removeScripts_clicked();
+	void on_reloadScripts_clicked();
+	void on_scriptLog_clicked();
+
+	void on_scripts_currentRowChanged(int row);
+
+	void on_pythonPathBrowse_clicked();
+};