1
0
Эх сурвалжийг харах

frontend: Prepare Qt UI dialogs for splits

PatTheMav 11 сар өмнө
parent
commit
e4a43f6555

+ 0 - 0
UI/window-extra-browsers.cpp → frontend/components/DelButton.hpp


+ 562 - 0
frontend/components/EditWidget.hpp

@@ -0,0 +1,562 @@
+#include "moc_window-extra-browsers.cpp"
+#include "window-dock-browser.hpp"
+#include "window-basic-main.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QUuid>
+
+#include <json11.hpp>
+
+#include "ui_OBSExtraBrowsers.h"
+
+using namespace json11;
+
+#define OBJ_NAME_SUFFIX "_extraBrowser"
+
+enum class Column : int {
+	Title,
+	Url,
+	Delete,
+
+	Count,
+};
+
+/* ------------------------------------------------------------------------- */
+
+void ExtraBrowsersModel::Reset()
+{
+	items.clear();
+
+	OBSBasic *main = OBSBasic::Get();
+
+	for (int i = 0; i < main->extraBrowserDocks.size(); i++) {
+		Item item;
+		item.prevIdx = i;
+		item.title = main->extraBrowserDockNames[i];
+		item.url = main->extraBrowserDockTargets[i];
+		items.push_back(item);
+	}
+}
+
+int ExtraBrowsersModel::rowCount(const QModelIndex &) const
+{
+	int count = items.size() + 1;
+	return count;
+}
+
+int ExtraBrowsersModel::columnCount(const QModelIndex &) const
+{
+	return (int)Column::Count;
+}
+
+QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const
+{
+	int column = index.column();
+	int idx = index.row();
+	int count = items.size();
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (!validRole)
+		return QVariant();
+
+	if (idx >= 0 && idx < count) {
+		switch (column) {
+		case (int)Column::Title:
+			return items[idx].title;
+		case (int)Column::Url:
+			return items[idx].url;
+		}
+	} else if (idx == count) {
+		switch (column) {
+		case (int)Column::Title:
+			return newTitle;
+		case (int)Column::Url:
+			return newURL;
+		}
+	}
+
+	return QVariant();
+}
+
+QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (validRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case (int)Column::Title:
+			return QTStr("ExtraBrowsers.DockName");
+		case (int)Column::Url:
+			return QStringLiteral("URL");
+		}
+	}
+
+	return QVariant();
+}
+
+Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() != (int)Column::Delete)
+		flags |= Qt::ItemIsEditable;
+
+	return flags;
+}
+
+class DelButton : public QPushButton {
+public:
+	inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+class EditWidget : public QLineEdit {
+public:
+	inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+void ExtraBrowsersModel::AddDeleteButton(int idx)
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr);
+
+	QPushButton *del = new DelButton(index);
+	del->setProperty("class", "icon-trash");
+	del->setObjectName("extraPanelDelete");
+	del->setMinimumSize(QSize(20, 20));
+	connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem);
+
+	widget->setIndexWidget(index, del);
+	widget->setRowHeight(idx, 20);
+	widget->setColumnWidth(idx, 20);
+}
+
+void ExtraBrowsersModel::CheckToAdd()
+{
+	if (newTitle.isEmpty() || newURL.isEmpty())
+		return;
+
+	int idx = items.size() + 1;
+	beginInsertRows(QModelIndex(), idx, idx);
+
+	Item item;
+	item.prevIdx = -1;
+	item.title = newTitle;
+	item.url = newURL;
+	items.push_back(item);
+
+	newTitle = "";
+	newURL = "";
+
+	endInsertRows();
+
+	AddDeleteButton(idx - 1);
+}
+
+void ExtraBrowsersModel::UpdateItem(Item &item)
+{
+	int idx = item.prevIdx;
+
+	OBSBasic *main = OBSBasic::Get();
+	BrowserDock *dock = reinterpret_cast<BrowserDock *>(main->extraBrowserDocks[idx].get());
+	dock->setWindowTitle(item.title);
+	dock->setObjectName(item.title + OBJ_NAME_SUFFIX);
+
+	if (main->extraBrowserDockNames[idx] != item.title) {
+		main->extraBrowserDockNames[idx] = item.title;
+		dock->toggleViewAction()->setText(item.title);
+		dock->setTitle(item.title);
+	}
+
+	if (main->extraBrowserDockTargets[idx] != item.url) {
+		dock->cefWidget->setURL(QT_TO_UTF8(item.url));
+		main->extraBrowserDockTargets[idx] = item.url;
+	}
+}
+
+void ExtraBrowsersModel::DeleteItem()
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	DelButton *del = reinterpret_cast<DelButton *>(sender());
+	int row = del->index.row();
+
+	/* there's some sort of internal bug in Qt and deleting certain index
+	 * widgets or "editors" that can cause a crash inside Qt if the widget
+	 * is not manually removed, at least on 5.7 */
+	widget->setIndexWidget(del->index, nullptr);
+	del->deleteLater();
+
+	/* --------- */
+
+	beginRemoveRows(QModelIndex(), row, row);
+
+	int prevIdx = items[row].prevIdx;
+	items.removeAt(row);
+
+	if (prevIdx != -1) {
+		int i = 0;
+		for (; i < deleted.size() && deleted[i] < prevIdx; i++)
+			;
+		deleted.insert(i, prevIdx);
+	}
+
+	endRemoveRows();
+}
+
+void ExtraBrowsersModel::Apply()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	for (Item &item : items) {
+		if (item.prevIdx != -1) {
+			UpdateItem(item);
+		} else {
+			QString uuid = QUuid::createUuid().toString();
+			uuid.replace(QRegularExpression("[{}-]"), "");
+			main->AddExtraBrowserDock(item.title, item.url, uuid, true);
+		}
+	}
+
+	for (int i = deleted.size() - 1; i >= 0; i--) {
+		int idx = deleted[i];
+		main->extraBrowserDockTargets.removeAt(idx);
+		main->extraBrowserDockNames.removeAt(idx);
+		main->extraBrowserDocks.removeAt(idx);
+	}
+
+	if (main->extraBrowserDocks.empty())
+		main->extraBrowserMenuDocksSeparator.clear();
+
+	deleted.clear();
+
+	Reset();
+}
+
+void ExtraBrowsersModel::TabSelection(bool forward)
+{
+	QListView *widget = reinterpret_cast<QListView *>(parent());
+	QItemSelectionModel *selModel = widget->selectionModel();
+
+	QModelIndex sel = selModel->currentIndex();
+	int row = sel.row();
+	int col = sel.column();
+
+	switch (sel.column()) {
+	case (int)Column::Title:
+		if (!forward) {
+			if (row == 0) {
+				return;
+			}
+
+			row -= 1;
+		}
+
+		col += 1;
+		break;
+
+	case (int)Column::Url:
+		if (forward) {
+			if (row == items.size()) {
+				return;
+			}
+
+			row += 1;
+		}
+
+		col -= 1;
+	}
+
+	sel = createIndex(row, col, nullptr);
+	selModel->setCurrentIndex(sel, QItemSelectionModel::Clear);
+}
+
+void ExtraBrowsersModel::Init()
+{
+	for (int i = 0; i < items.count(); i++)
+		AddDeleteButton(i);
+}
+
+/* ------------------------------------------------------------------------- */
+
+QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &,
+					     const QModelIndex &index) const
+{
+	QLineEdit *text = new EditWidget(parent, index);
+	text->installEventFilter(const_cast<ExtraBrowsersDelegate *>(this));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	return text;
+}
+
+void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = reinterpret_cast<QLineEdit *>(editor);
+	text->blockSignals(true);
+	text->setText(index.data().toString());
+	text->blockSignals(false);
+}
+
+bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QLineEdit *edit = qobject_cast<QLineEdit *>(object);
+	if (!edit)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		RevertText(edit);
+	}
+	if (LineEditChanged(event)) {
+		UpdateText(edit);
+
+		if (event->type() == QEvent::KeyPress) {
+			QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+			if (keyEvent->key() == Qt::Key_Tab) {
+				model->TabSelection(true);
+			} else if (keyEvent->key() == Qt::Key_Backtab) {
+				model->TabSelection(false);
+			}
+		}
+		return true;
+	}
+
+	return false;
+}
+
+bool ExtraBrowsersDelegate::ValidName(const QString &name) const
+{
+	for (auto &item : model->items) {
+		if (name.compare(item.title, Qt::CaseInsensitive) == 0) {
+			return false;
+		}
+	}
+	return true;
+}
+
+void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString oldText;
+	if (col == (int)Column::Title) {
+		oldText = newItem ? model->newTitle : model->items[row].title;
+	} else {
+		oldText = newItem ? model->newURL : model->items[row].url;
+	}
+
+	edit->setText(oldText);
+}
+
+bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString text = edit->text().trimmed();
+
+	if (!newItem && text.isEmpty()) {
+		return false;
+	}
+
+	if (col == (int)Column::Title) {
+		QString oldText = newItem ? model->newTitle : model->items[row].title;
+		bool same = oldText.compare(text, Qt::CaseInsensitive) == 0;
+
+		if (!same && !ValidName(text)) {
+			edit->setText(oldText);
+			return false;
+		}
+	}
+
+	if (!newItem) {
+		/* if edited existing item, update it*/
+		switch (col) {
+		case (int)Column::Title:
+			model->items[row].title = text;
+			break;
+		case (int)Column::Url:
+			model->items[row].url = text;
+			break;
+		}
+	} else {
+		/* if both new values filled out, create new one */
+		switch (col) {
+		case (int)Column::Title:
+			model->newTitle = text;
+			break;
+		case (int)Column::Url:
+			model->newURL = text;
+			break;
+		}
+
+		model->CheckToAdd();
+	}
+
+	emit commitData(edit);
+	return true;
+}
+
+/* ------------------------------------------------------------------------- */
+
+OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers)
+{
+	ui->setupUi(this);
+
+	setAttribute(Qt::WA_DeleteOnClose, true);
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	model = new ExtraBrowsersModel(ui->table);
+
+	ui->table->setModel(model);
+	ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model));
+	ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model));
+	ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed);
+	ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+}
+
+OBSExtraBrowsers::~OBSExtraBrowsers() {}
+
+void OBSExtraBrowsers::closeEvent(QCloseEvent *event)
+{
+	QDialog::closeEvent(event);
+	model->Apply();
+}
+
+void OBSExtraBrowsers::on_apply_clicked()
+{
+	model->Apply();
+}
+
+/* ------------------------------------------------------------------------- */
+
+void OBSBasic::ClearExtraBrowserDocks()
+{
+	extraBrowserDockTargets.clear();
+	extraBrowserDockNames.clear();
+	extraBrowserDocks.clear();
+}
+
+void OBSBasic::LoadExtraBrowserDocks()
+{
+	const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks");
+
+	std::string err;
+	Json json = Json::parse(jsonStr, err);
+	if (!err.empty())
+		return;
+
+	Json::array array = json.array_items();
+	if (!array.empty())
+		extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator();
+
+	for (Json &item : array) {
+		std::string title = item["title"].string_value();
+		std::string url = item["url"].string_value();
+		std::string uuid = item["uuid"].string_value();
+
+		AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false);
+	}
+}
+
+void OBSBasic::SaveExtraBrowserDocks()
+{
+	Json::array array;
+	for (int i = 0; i < extraBrowserDocks.size(); i++) {
+		QDockWidget *dock = extraBrowserDocks[i].get();
+		QString title = extraBrowserDockNames[i];
+		QString url = extraBrowserDockTargets[i];
+		QString uuid = dock->property("uuid").toString();
+		Json::object obj{
+			{"title", QT_TO_UTF8(title)},
+			{"url", QT_TO_UTF8(url)},
+			{"uuid", QT_TO_UTF8(uuid)},
+		};
+		array.push_back(obj);
+	}
+
+	std::string output = Json(array).dump();
+	config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str());
+}
+
+void OBSBasic::ManageExtraBrowserDocks()
+{
+	if (!extraBrowsers.isNull()) {
+		extraBrowsers->show();
+		extraBrowsers->raise();
+		return;
+	}
+
+	extraBrowsers = new OBSExtraBrowsers(this);
+	extraBrowsers->show();
+}
+
+void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate)
+{
+	static int panel_version = -1;
+	if (panel_version == -1) {
+		panel_version = obs_browser_qcef_version();
+	}
+
+	BrowserDock *dock = new BrowserDock(title);
+	QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid);
+	bId.replace(QRegularExpression("[{}-]"), "");
+	dock->setProperty("uuid", bId);
+	dock->setObjectName(title + OBJ_NAME_SUFFIX);
+	dock->resize(460, 600);
+	dock->setMinimumSize(80, 80);
+	dock->setWindowTitle(title);
+	dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr);
+	if (browser && panel_version >= 1)
+		browser->allowAllPopups(true);
+
+	dock->SetWidget(browser);
+
+	/* Add support for Twitch Dashboard panels */
+	if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) {
+		QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/");
+		QRegularExpressionMatch match = re.match(url);
+		QString username = match.captured(1);
+		if (username.length() > 0) {
+			std::string script;
+			script = "Object.defineProperty(document, 'referrer', { get: () => '";
+			script += "https://twitch.tv/";
+			script += QT_TO_UTF8(username);
+			script += "/dashboard/live";
+			script += "'});";
+			browser->setStartupScript(script);
+		}
+	}
+
+	AddDockWidget(dock, Qt::RightDockWidgetArea, true);
+	extraBrowserDocks.push_back(std::shared_ptr<QDockWidget>(dock));
+	extraBrowserDockNames.push_back(title);
+	extraBrowserDockTargets.push_back(url);
+
+	if (firstCreate) {
+		dock->setFloating(true);
+
+		QPoint curPos = pos();
+		QSize wSizeD2 = size() / 2;
+		QSize dSizeD2 = dock->size() / 2;
+
+		curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width());
+		curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height());
+
+		dock->move(curPos);
+		dock->setVisible(true);
+	}
+}

+ 0 - 0
UI/window-basic-interaction.cpp → frontend/dialogs/OBSBasicInteraction.cpp


+ 0 - 0
UI/window-basic-interaction.hpp → frontend/dialogs/OBSBasicInteraction.hpp


+ 562 - 0
frontend/dialogs/OBSExtraBrowsers.cpp

@@ -0,0 +1,562 @@
+#include "moc_window-extra-browsers.cpp"
+#include "window-dock-browser.hpp"
+#include "window-basic-main.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QUuid>
+
+#include <json11.hpp>
+
+#include "ui_OBSExtraBrowsers.h"
+
+using namespace json11;
+
+#define OBJ_NAME_SUFFIX "_extraBrowser"
+
+enum class Column : int {
+	Title,
+	Url,
+	Delete,
+
+	Count,
+};
+
+/* ------------------------------------------------------------------------- */
+
+void ExtraBrowsersModel::Reset()
+{
+	items.clear();
+
+	OBSBasic *main = OBSBasic::Get();
+
+	for (int i = 0; i < main->extraBrowserDocks.size(); i++) {
+		Item item;
+		item.prevIdx = i;
+		item.title = main->extraBrowserDockNames[i];
+		item.url = main->extraBrowserDockTargets[i];
+		items.push_back(item);
+	}
+}
+
+int ExtraBrowsersModel::rowCount(const QModelIndex &) const
+{
+	int count = items.size() + 1;
+	return count;
+}
+
+int ExtraBrowsersModel::columnCount(const QModelIndex &) const
+{
+	return (int)Column::Count;
+}
+
+QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const
+{
+	int column = index.column();
+	int idx = index.row();
+	int count = items.size();
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (!validRole)
+		return QVariant();
+
+	if (idx >= 0 && idx < count) {
+		switch (column) {
+		case (int)Column::Title:
+			return items[idx].title;
+		case (int)Column::Url:
+			return items[idx].url;
+		}
+	} else if (idx == count) {
+		switch (column) {
+		case (int)Column::Title:
+			return newTitle;
+		case (int)Column::Url:
+			return newURL;
+		}
+	}
+
+	return QVariant();
+}
+
+QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (validRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case (int)Column::Title:
+			return QTStr("ExtraBrowsers.DockName");
+		case (int)Column::Url:
+			return QStringLiteral("URL");
+		}
+	}
+
+	return QVariant();
+}
+
+Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() != (int)Column::Delete)
+		flags |= Qt::ItemIsEditable;
+
+	return flags;
+}
+
+class DelButton : public QPushButton {
+public:
+	inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+class EditWidget : public QLineEdit {
+public:
+	inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+void ExtraBrowsersModel::AddDeleteButton(int idx)
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr);
+
+	QPushButton *del = new DelButton(index);
+	del->setProperty("class", "icon-trash");
+	del->setObjectName("extraPanelDelete");
+	del->setMinimumSize(QSize(20, 20));
+	connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem);
+
+	widget->setIndexWidget(index, del);
+	widget->setRowHeight(idx, 20);
+	widget->setColumnWidth(idx, 20);
+}
+
+void ExtraBrowsersModel::CheckToAdd()
+{
+	if (newTitle.isEmpty() || newURL.isEmpty())
+		return;
+
+	int idx = items.size() + 1;
+	beginInsertRows(QModelIndex(), idx, idx);
+
+	Item item;
+	item.prevIdx = -1;
+	item.title = newTitle;
+	item.url = newURL;
+	items.push_back(item);
+
+	newTitle = "";
+	newURL = "";
+
+	endInsertRows();
+
+	AddDeleteButton(idx - 1);
+}
+
+void ExtraBrowsersModel::UpdateItem(Item &item)
+{
+	int idx = item.prevIdx;
+
+	OBSBasic *main = OBSBasic::Get();
+	BrowserDock *dock = reinterpret_cast<BrowserDock *>(main->extraBrowserDocks[idx].get());
+	dock->setWindowTitle(item.title);
+	dock->setObjectName(item.title + OBJ_NAME_SUFFIX);
+
+	if (main->extraBrowserDockNames[idx] != item.title) {
+		main->extraBrowserDockNames[idx] = item.title;
+		dock->toggleViewAction()->setText(item.title);
+		dock->setTitle(item.title);
+	}
+
+	if (main->extraBrowserDockTargets[idx] != item.url) {
+		dock->cefWidget->setURL(QT_TO_UTF8(item.url));
+		main->extraBrowserDockTargets[idx] = item.url;
+	}
+}
+
+void ExtraBrowsersModel::DeleteItem()
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	DelButton *del = reinterpret_cast<DelButton *>(sender());
+	int row = del->index.row();
+
+	/* there's some sort of internal bug in Qt and deleting certain index
+	 * widgets or "editors" that can cause a crash inside Qt if the widget
+	 * is not manually removed, at least on 5.7 */
+	widget->setIndexWidget(del->index, nullptr);
+	del->deleteLater();
+
+	/* --------- */
+
+	beginRemoveRows(QModelIndex(), row, row);
+
+	int prevIdx = items[row].prevIdx;
+	items.removeAt(row);
+
+	if (prevIdx != -1) {
+		int i = 0;
+		for (; i < deleted.size() && deleted[i] < prevIdx; i++)
+			;
+		deleted.insert(i, prevIdx);
+	}
+
+	endRemoveRows();
+}
+
+void ExtraBrowsersModel::Apply()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	for (Item &item : items) {
+		if (item.prevIdx != -1) {
+			UpdateItem(item);
+		} else {
+			QString uuid = QUuid::createUuid().toString();
+			uuid.replace(QRegularExpression("[{}-]"), "");
+			main->AddExtraBrowserDock(item.title, item.url, uuid, true);
+		}
+	}
+
+	for (int i = deleted.size() - 1; i >= 0; i--) {
+		int idx = deleted[i];
+		main->extraBrowserDockTargets.removeAt(idx);
+		main->extraBrowserDockNames.removeAt(idx);
+		main->extraBrowserDocks.removeAt(idx);
+	}
+
+	if (main->extraBrowserDocks.empty())
+		main->extraBrowserMenuDocksSeparator.clear();
+
+	deleted.clear();
+
+	Reset();
+}
+
+void ExtraBrowsersModel::TabSelection(bool forward)
+{
+	QListView *widget = reinterpret_cast<QListView *>(parent());
+	QItemSelectionModel *selModel = widget->selectionModel();
+
+	QModelIndex sel = selModel->currentIndex();
+	int row = sel.row();
+	int col = sel.column();
+
+	switch (sel.column()) {
+	case (int)Column::Title:
+		if (!forward) {
+			if (row == 0) {
+				return;
+			}
+
+			row -= 1;
+		}
+
+		col += 1;
+		break;
+
+	case (int)Column::Url:
+		if (forward) {
+			if (row == items.size()) {
+				return;
+			}
+
+			row += 1;
+		}
+
+		col -= 1;
+	}
+
+	sel = createIndex(row, col, nullptr);
+	selModel->setCurrentIndex(sel, QItemSelectionModel::Clear);
+}
+
+void ExtraBrowsersModel::Init()
+{
+	for (int i = 0; i < items.count(); i++)
+		AddDeleteButton(i);
+}
+
+/* ------------------------------------------------------------------------- */
+
+QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &,
+					     const QModelIndex &index) const
+{
+	QLineEdit *text = new EditWidget(parent, index);
+	text->installEventFilter(const_cast<ExtraBrowsersDelegate *>(this));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	return text;
+}
+
+void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = reinterpret_cast<QLineEdit *>(editor);
+	text->blockSignals(true);
+	text->setText(index.data().toString());
+	text->blockSignals(false);
+}
+
+bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QLineEdit *edit = qobject_cast<QLineEdit *>(object);
+	if (!edit)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		RevertText(edit);
+	}
+	if (LineEditChanged(event)) {
+		UpdateText(edit);
+
+		if (event->type() == QEvent::KeyPress) {
+			QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+			if (keyEvent->key() == Qt::Key_Tab) {
+				model->TabSelection(true);
+			} else if (keyEvent->key() == Qt::Key_Backtab) {
+				model->TabSelection(false);
+			}
+		}
+		return true;
+	}
+
+	return false;
+}
+
+bool ExtraBrowsersDelegate::ValidName(const QString &name) const
+{
+	for (auto &item : model->items) {
+		if (name.compare(item.title, Qt::CaseInsensitive) == 0) {
+			return false;
+		}
+	}
+	return true;
+}
+
+void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString oldText;
+	if (col == (int)Column::Title) {
+		oldText = newItem ? model->newTitle : model->items[row].title;
+	} else {
+		oldText = newItem ? model->newURL : model->items[row].url;
+	}
+
+	edit->setText(oldText);
+}
+
+bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString text = edit->text().trimmed();
+
+	if (!newItem && text.isEmpty()) {
+		return false;
+	}
+
+	if (col == (int)Column::Title) {
+		QString oldText = newItem ? model->newTitle : model->items[row].title;
+		bool same = oldText.compare(text, Qt::CaseInsensitive) == 0;
+
+		if (!same && !ValidName(text)) {
+			edit->setText(oldText);
+			return false;
+		}
+	}
+
+	if (!newItem) {
+		/* if edited existing item, update it*/
+		switch (col) {
+		case (int)Column::Title:
+			model->items[row].title = text;
+			break;
+		case (int)Column::Url:
+			model->items[row].url = text;
+			break;
+		}
+	} else {
+		/* if both new values filled out, create new one */
+		switch (col) {
+		case (int)Column::Title:
+			model->newTitle = text;
+			break;
+		case (int)Column::Url:
+			model->newURL = text;
+			break;
+		}
+
+		model->CheckToAdd();
+	}
+
+	emit commitData(edit);
+	return true;
+}
+
+/* ------------------------------------------------------------------------- */
+
+OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers)
+{
+	ui->setupUi(this);
+
+	setAttribute(Qt::WA_DeleteOnClose, true);
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	model = new ExtraBrowsersModel(ui->table);
+
+	ui->table->setModel(model);
+	ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model));
+	ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model));
+	ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed);
+	ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+}
+
+OBSExtraBrowsers::~OBSExtraBrowsers() {}
+
+void OBSExtraBrowsers::closeEvent(QCloseEvent *event)
+{
+	QDialog::closeEvent(event);
+	model->Apply();
+}
+
+void OBSExtraBrowsers::on_apply_clicked()
+{
+	model->Apply();
+}
+
+/* ------------------------------------------------------------------------- */
+
+void OBSBasic::ClearExtraBrowserDocks()
+{
+	extraBrowserDockTargets.clear();
+	extraBrowserDockNames.clear();
+	extraBrowserDocks.clear();
+}
+
+void OBSBasic::LoadExtraBrowserDocks()
+{
+	const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks");
+
+	std::string err;
+	Json json = Json::parse(jsonStr, err);
+	if (!err.empty())
+		return;
+
+	Json::array array = json.array_items();
+	if (!array.empty())
+		extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator();
+
+	for (Json &item : array) {
+		std::string title = item["title"].string_value();
+		std::string url = item["url"].string_value();
+		std::string uuid = item["uuid"].string_value();
+
+		AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false);
+	}
+}
+
+void OBSBasic::SaveExtraBrowserDocks()
+{
+	Json::array array;
+	for (int i = 0; i < extraBrowserDocks.size(); i++) {
+		QDockWidget *dock = extraBrowserDocks[i].get();
+		QString title = extraBrowserDockNames[i];
+		QString url = extraBrowserDockTargets[i];
+		QString uuid = dock->property("uuid").toString();
+		Json::object obj{
+			{"title", QT_TO_UTF8(title)},
+			{"url", QT_TO_UTF8(url)},
+			{"uuid", QT_TO_UTF8(uuid)},
+		};
+		array.push_back(obj);
+	}
+
+	std::string output = Json(array).dump();
+	config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str());
+}
+
+void OBSBasic::ManageExtraBrowserDocks()
+{
+	if (!extraBrowsers.isNull()) {
+		extraBrowsers->show();
+		extraBrowsers->raise();
+		return;
+	}
+
+	extraBrowsers = new OBSExtraBrowsers(this);
+	extraBrowsers->show();
+}
+
+void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate)
+{
+	static int panel_version = -1;
+	if (panel_version == -1) {
+		panel_version = obs_browser_qcef_version();
+	}
+
+	BrowserDock *dock = new BrowserDock(title);
+	QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid);
+	bId.replace(QRegularExpression("[{}-]"), "");
+	dock->setProperty("uuid", bId);
+	dock->setObjectName(title + OBJ_NAME_SUFFIX);
+	dock->resize(460, 600);
+	dock->setMinimumSize(80, 80);
+	dock->setWindowTitle(title);
+	dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr);
+	if (browser && panel_version >= 1)
+		browser->allowAllPopups(true);
+
+	dock->SetWidget(browser);
+
+	/* Add support for Twitch Dashboard panels */
+	if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) {
+		QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/");
+		QRegularExpressionMatch match = re.match(url);
+		QString username = match.captured(1);
+		if (username.length() > 0) {
+			std::string script;
+			script = "Object.defineProperty(document, 'referrer', { get: () => '";
+			script += "https://twitch.tv/";
+			script += QT_TO_UTF8(username);
+			script += "/dashboard/live";
+			script += "'});";
+			browser->setStartupScript(script);
+		}
+	}
+
+	AddDockWidget(dock, Qt::RightDockWidgetArea, true);
+	extraBrowserDocks.push_back(std::shared_ptr<QDockWidget>(dock));
+	extraBrowserDockNames.push_back(title);
+	extraBrowserDockTargets.push_back(url);
+
+	if (firstCreate) {
+		dock->setFloating(true);
+
+		QPoint curPos = pos();
+		QSize wSizeD2 = size() / 2;
+		QSize dSizeD2 = dock->size() / 2;
+
+		curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width());
+		curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height());
+
+		dock->move(curPos);
+		dock->setVisible(true);
+	}
+}

+ 0 - 0
UI/window-extra-browsers.hpp → frontend/dialogs/OBSExtraBrowsers.hpp


+ 0 - 0
UI/window-missing-files.cpp → frontend/dialogs/OBSMissingFiles.cpp


+ 0 - 0
UI/window-missing-files.hpp → frontend/dialogs/OBSMissingFiles.hpp


+ 0 - 0
UI/window-remux.cpp → frontend/dialogs/OBSRemux.cpp


+ 0 - 0
UI/window-remux.hpp → frontend/dialogs/OBSRemux.hpp


+ 562 - 0
frontend/utility/ExtraBrowsersDelegate.cpp

@@ -0,0 +1,562 @@
+#include "moc_window-extra-browsers.cpp"
+#include "window-dock-browser.hpp"
+#include "window-basic-main.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QUuid>
+
+#include <json11.hpp>
+
+#include "ui_OBSExtraBrowsers.h"
+
+using namespace json11;
+
+#define OBJ_NAME_SUFFIX "_extraBrowser"
+
+enum class Column : int {
+	Title,
+	Url,
+	Delete,
+
+	Count,
+};
+
+/* ------------------------------------------------------------------------- */
+
+void ExtraBrowsersModel::Reset()
+{
+	items.clear();
+
+	OBSBasic *main = OBSBasic::Get();
+
+	for (int i = 0; i < main->extraBrowserDocks.size(); i++) {
+		Item item;
+		item.prevIdx = i;
+		item.title = main->extraBrowserDockNames[i];
+		item.url = main->extraBrowserDockTargets[i];
+		items.push_back(item);
+	}
+}
+
+int ExtraBrowsersModel::rowCount(const QModelIndex &) const
+{
+	int count = items.size() + 1;
+	return count;
+}
+
+int ExtraBrowsersModel::columnCount(const QModelIndex &) const
+{
+	return (int)Column::Count;
+}
+
+QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const
+{
+	int column = index.column();
+	int idx = index.row();
+	int count = items.size();
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (!validRole)
+		return QVariant();
+
+	if (idx >= 0 && idx < count) {
+		switch (column) {
+		case (int)Column::Title:
+			return items[idx].title;
+		case (int)Column::Url:
+			return items[idx].url;
+		}
+	} else if (idx == count) {
+		switch (column) {
+		case (int)Column::Title:
+			return newTitle;
+		case (int)Column::Url:
+			return newURL;
+		}
+	}
+
+	return QVariant();
+}
+
+QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (validRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case (int)Column::Title:
+			return QTStr("ExtraBrowsers.DockName");
+		case (int)Column::Url:
+			return QStringLiteral("URL");
+		}
+	}
+
+	return QVariant();
+}
+
+Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() != (int)Column::Delete)
+		flags |= Qt::ItemIsEditable;
+
+	return flags;
+}
+
+class DelButton : public QPushButton {
+public:
+	inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+class EditWidget : public QLineEdit {
+public:
+	inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+void ExtraBrowsersModel::AddDeleteButton(int idx)
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr);
+
+	QPushButton *del = new DelButton(index);
+	del->setProperty("class", "icon-trash");
+	del->setObjectName("extraPanelDelete");
+	del->setMinimumSize(QSize(20, 20));
+	connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem);
+
+	widget->setIndexWidget(index, del);
+	widget->setRowHeight(idx, 20);
+	widget->setColumnWidth(idx, 20);
+}
+
+void ExtraBrowsersModel::CheckToAdd()
+{
+	if (newTitle.isEmpty() || newURL.isEmpty())
+		return;
+
+	int idx = items.size() + 1;
+	beginInsertRows(QModelIndex(), idx, idx);
+
+	Item item;
+	item.prevIdx = -1;
+	item.title = newTitle;
+	item.url = newURL;
+	items.push_back(item);
+
+	newTitle = "";
+	newURL = "";
+
+	endInsertRows();
+
+	AddDeleteButton(idx - 1);
+}
+
+void ExtraBrowsersModel::UpdateItem(Item &item)
+{
+	int idx = item.prevIdx;
+
+	OBSBasic *main = OBSBasic::Get();
+	BrowserDock *dock = reinterpret_cast<BrowserDock *>(main->extraBrowserDocks[idx].get());
+	dock->setWindowTitle(item.title);
+	dock->setObjectName(item.title + OBJ_NAME_SUFFIX);
+
+	if (main->extraBrowserDockNames[idx] != item.title) {
+		main->extraBrowserDockNames[idx] = item.title;
+		dock->toggleViewAction()->setText(item.title);
+		dock->setTitle(item.title);
+	}
+
+	if (main->extraBrowserDockTargets[idx] != item.url) {
+		dock->cefWidget->setURL(QT_TO_UTF8(item.url));
+		main->extraBrowserDockTargets[idx] = item.url;
+	}
+}
+
+void ExtraBrowsersModel::DeleteItem()
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	DelButton *del = reinterpret_cast<DelButton *>(sender());
+	int row = del->index.row();
+
+	/* there's some sort of internal bug in Qt and deleting certain index
+	 * widgets or "editors" that can cause a crash inside Qt if the widget
+	 * is not manually removed, at least on 5.7 */
+	widget->setIndexWidget(del->index, nullptr);
+	del->deleteLater();
+
+	/* --------- */
+
+	beginRemoveRows(QModelIndex(), row, row);
+
+	int prevIdx = items[row].prevIdx;
+	items.removeAt(row);
+
+	if (prevIdx != -1) {
+		int i = 0;
+		for (; i < deleted.size() && deleted[i] < prevIdx; i++)
+			;
+		deleted.insert(i, prevIdx);
+	}
+
+	endRemoveRows();
+}
+
+void ExtraBrowsersModel::Apply()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	for (Item &item : items) {
+		if (item.prevIdx != -1) {
+			UpdateItem(item);
+		} else {
+			QString uuid = QUuid::createUuid().toString();
+			uuid.replace(QRegularExpression("[{}-]"), "");
+			main->AddExtraBrowserDock(item.title, item.url, uuid, true);
+		}
+	}
+
+	for (int i = deleted.size() - 1; i >= 0; i--) {
+		int idx = deleted[i];
+		main->extraBrowserDockTargets.removeAt(idx);
+		main->extraBrowserDockNames.removeAt(idx);
+		main->extraBrowserDocks.removeAt(idx);
+	}
+
+	if (main->extraBrowserDocks.empty())
+		main->extraBrowserMenuDocksSeparator.clear();
+
+	deleted.clear();
+
+	Reset();
+}
+
+void ExtraBrowsersModel::TabSelection(bool forward)
+{
+	QListView *widget = reinterpret_cast<QListView *>(parent());
+	QItemSelectionModel *selModel = widget->selectionModel();
+
+	QModelIndex sel = selModel->currentIndex();
+	int row = sel.row();
+	int col = sel.column();
+
+	switch (sel.column()) {
+	case (int)Column::Title:
+		if (!forward) {
+			if (row == 0) {
+				return;
+			}
+
+			row -= 1;
+		}
+
+		col += 1;
+		break;
+
+	case (int)Column::Url:
+		if (forward) {
+			if (row == items.size()) {
+				return;
+			}
+
+			row += 1;
+		}
+
+		col -= 1;
+	}
+
+	sel = createIndex(row, col, nullptr);
+	selModel->setCurrentIndex(sel, QItemSelectionModel::Clear);
+}
+
+void ExtraBrowsersModel::Init()
+{
+	for (int i = 0; i < items.count(); i++)
+		AddDeleteButton(i);
+}
+
+/* ------------------------------------------------------------------------- */
+
+QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &,
+					     const QModelIndex &index) const
+{
+	QLineEdit *text = new EditWidget(parent, index);
+	text->installEventFilter(const_cast<ExtraBrowsersDelegate *>(this));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	return text;
+}
+
+void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = reinterpret_cast<QLineEdit *>(editor);
+	text->blockSignals(true);
+	text->setText(index.data().toString());
+	text->blockSignals(false);
+}
+
+bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QLineEdit *edit = qobject_cast<QLineEdit *>(object);
+	if (!edit)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		RevertText(edit);
+	}
+	if (LineEditChanged(event)) {
+		UpdateText(edit);
+
+		if (event->type() == QEvent::KeyPress) {
+			QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+			if (keyEvent->key() == Qt::Key_Tab) {
+				model->TabSelection(true);
+			} else if (keyEvent->key() == Qt::Key_Backtab) {
+				model->TabSelection(false);
+			}
+		}
+		return true;
+	}
+
+	return false;
+}
+
+bool ExtraBrowsersDelegate::ValidName(const QString &name) const
+{
+	for (auto &item : model->items) {
+		if (name.compare(item.title, Qt::CaseInsensitive) == 0) {
+			return false;
+		}
+	}
+	return true;
+}
+
+void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString oldText;
+	if (col == (int)Column::Title) {
+		oldText = newItem ? model->newTitle : model->items[row].title;
+	} else {
+		oldText = newItem ? model->newURL : model->items[row].url;
+	}
+
+	edit->setText(oldText);
+}
+
+bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString text = edit->text().trimmed();
+
+	if (!newItem && text.isEmpty()) {
+		return false;
+	}
+
+	if (col == (int)Column::Title) {
+		QString oldText = newItem ? model->newTitle : model->items[row].title;
+		bool same = oldText.compare(text, Qt::CaseInsensitive) == 0;
+
+		if (!same && !ValidName(text)) {
+			edit->setText(oldText);
+			return false;
+		}
+	}
+
+	if (!newItem) {
+		/* if edited existing item, update it*/
+		switch (col) {
+		case (int)Column::Title:
+			model->items[row].title = text;
+			break;
+		case (int)Column::Url:
+			model->items[row].url = text;
+			break;
+		}
+	} else {
+		/* if both new values filled out, create new one */
+		switch (col) {
+		case (int)Column::Title:
+			model->newTitle = text;
+			break;
+		case (int)Column::Url:
+			model->newURL = text;
+			break;
+		}
+
+		model->CheckToAdd();
+	}
+
+	emit commitData(edit);
+	return true;
+}
+
+/* ------------------------------------------------------------------------- */
+
+OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers)
+{
+	ui->setupUi(this);
+
+	setAttribute(Qt::WA_DeleteOnClose, true);
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	model = new ExtraBrowsersModel(ui->table);
+
+	ui->table->setModel(model);
+	ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model));
+	ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model));
+	ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed);
+	ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+}
+
+OBSExtraBrowsers::~OBSExtraBrowsers() {}
+
+void OBSExtraBrowsers::closeEvent(QCloseEvent *event)
+{
+	QDialog::closeEvent(event);
+	model->Apply();
+}
+
+void OBSExtraBrowsers::on_apply_clicked()
+{
+	model->Apply();
+}
+
+/* ------------------------------------------------------------------------- */
+
+void OBSBasic::ClearExtraBrowserDocks()
+{
+	extraBrowserDockTargets.clear();
+	extraBrowserDockNames.clear();
+	extraBrowserDocks.clear();
+}
+
+void OBSBasic::LoadExtraBrowserDocks()
+{
+	const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks");
+
+	std::string err;
+	Json json = Json::parse(jsonStr, err);
+	if (!err.empty())
+		return;
+
+	Json::array array = json.array_items();
+	if (!array.empty())
+		extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator();
+
+	for (Json &item : array) {
+		std::string title = item["title"].string_value();
+		std::string url = item["url"].string_value();
+		std::string uuid = item["uuid"].string_value();
+
+		AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false);
+	}
+}
+
+void OBSBasic::SaveExtraBrowserDocks()
+{
+	Json::array array;
+	for (int i = 0; i < extraBrowserDocks.size(); i++) {
+		QDockWidget *dock = extraBrowserDocks[i].get();
+		QString title = extraBrowserDockNames[i];
+		QString url = extraBrowserDockTargets[i];
+		QString uuid = dock->property("uuid").toString();
+		Json::object obj{
+			{"title", QT_TO_UTF8(title)},
+			{"url", QT_TO_UTF8(url)},
+			{"uuid", QT_TO_UTF8(uuid)},
+		};
+		array.push_back(obj);
+	}
+
+	std::string output = Json(array).dump();
+	config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str());
+}
+
+void OBSBasic::ManageExtraBrowserDocks()
+{
+	if (!extraBrowsers.isNull()) {
+		extraBrowsers->show();
+		extraBrowsers->raise();
+		return;
+	}
+
+	extraBrowsers = new OBSExtraBrowsers(this);
+	extraBrowsers->show();
+}
+
+void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate)
+{
+	static int panel_version = -1;
+	if (panel_version == -1) {
+		panel_version = obs_browser_qcef_version();
+	}
+
+	BrowserDock *dock = new BrowserDock(title);
+	QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid);
+	bId.replace(QRegularExpression("[{}-]"), "");
+	dock->setProperty("uuid", bId);
+	dock->setObjectName(title + OBJ_NAME_SUFFIX);
+	dock->resize(460, 600);
+	dock->setMinimumSize(80, 80);
+	dock->setWindowTitle(title);
+	dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr);
+	if (browser && panel_version >= 1)
+		browser->allowAllPopups(true);
+
+	dock->SetWidget(browser);
+
+	/* Add support for Twitch Dashboard panels */
+	if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) {
+		QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/");
+		QRegularExpressionMatch match = re.match(url);
+		QString username = match.captured(1);
+		if (username.length() > 0) {
+			std::string script;
+			script = "Object.defineProperty(document, 'referrer', { get: () => '";
+			script += "https://twitch.tv/";
+			script += QT_TO_UTF8(username);
+			script += "/dashboard/live";
+			script += "'});";
+			browser->setStartupScript(script);
+		}
+	}
+
+	AddDockWidget(dock, Qt::RightDockWidgetArea, true);
+	extraBrowserDocks.push_back(std::shared_ptr<QDockWidget>(dock));
+	extraBrowserDockNames.push_back(title);
+	extraBrowserDockTargets.push_back(url);
+
+	if (firstCreate) {
+		dock->setFloating(true);
+
+		QPoint curPos = pos();
+		QSize wSizeD2 = size() / 2;
+		QSize dSizeD2 = dock->size() / 2;
+
+		curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width());
+		curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height());
+
+		dock->move(curPos);
+		dock->setVisible(true);
+	}
+}

+ 88 - 0
frontend/utility/ExtraBrowsersDelegate.hpp

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <QDialog>
+#include <QScopedPointer>
+#include <QAbstractTableModel>
+#include <QStyledItemDelegate>
+#include <memory>
+
+class Ui_OBSExtraBrowsers;
+class ExtraBrowsersModel;
+
+class QCefWidget;
+
+class OBSExtraBrowsers : public QDialog {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_OBSExtraBrowsers> ui;
+	ExtraBrowsersModel *model;
+
+public:
+	OBSExtraBrowsers(QWidget *parent);
+	~OBSExtraBrowsers();
+
+	void closeEvent(QCloseEvent *event) override;
+
+public slots:
+	void on_apply_clicked();
+};
+
+class ExtraBrowsersModel : public QAbstractTableModel {
+	Q_OBJECT
+
+public:
+	inline ExtraBrowsersModel(QObject *parent = nullptr) : QAbstractTableModel(parent)
+	{
+		Reset();
+		QMetaObject::invokeMethod(this, "Init", Qt::QueuedConnection);
+	}
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+	QVariant data(const QModelIndex &index, int role) const override;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
+	Qt::ItemFlags flags(const QModelIndex &index) const override;
+
+	struct Item {
+		int prevIdx;
+		QString title;
+		QString url;
+	};
+
+	void TabSelection(bool forward);
+
+	void AddDeleteButton(int idx);
+	void Reset();
+	void CheckToAdd();
+	void UpdateItem(Item &item);
+	void DeleteItem();
+	void Apply();
+
+	QVector<Item> items;
+	QVector<int> deleted;
+
+	QString newTitle;
+	QString newURL;
+
+public slots:
+	void Init();
+};
+
+class ExtraBrowsersDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	inline ExtraBrowsersDelegate(ExtraBrowsersModel *model_) : QStyledItemDelegate(nullptr), model(model_) {}
+
+	QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
+			      const QModelIndex &index) const override;
+
+	void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+
+	bool eventFilter(QObject *object, QEvent *event) override;
+	void RevertText(QLineEdit *edit);
+	bool UpdateText(QLineEdit *edit);
+	bool ValidName(const QString &text) const;
+
+	ExtraBrowsersModel *model;
+};

+ 562 - 0
frontend/utility/ExtraBrowsersModel.cpp

@@ -0,0 +1,562 @@
+#include "moc_window-extra-browsers.cpp"
+#include "window-dock-browser.hpp"
+#include "window-basic-main.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QUuid>
+
+#include <json11.hpp>
+
+#include "ui_OBSExtraBrowsers.h"
+
+using namespace json11;
+
+#define OBJ_NAME_SUFFIX "_extraBrowser"
+
+enum class Column : int {
+	Title,
+	Url,
+	Delete,
+
+	Count,
+};
+
+/* ------------------------------------------------------------------------- */
+
+void ExtraBrowsersModel::Reset()
+{
+	items.clear();
+
+	OBSBasic *main = OBSBasic::Get();
+
+	for (int i = 0; i < main->extraBrowserDocks.size(); i++) {
+		Item item;
+		item.prevIdx = i;
+		item.title = main->extraBrowserDockNames[i];
+		item.url = main->extraBrowserDockTargets[i];
+		items.push_back(item);
+	}
+}
+
+int ExtraBrowsersModel::rowCount(const QModelIndex &) const
+{
+	int count = items.size() + 1;
+	return count;
+}
+
+int ExtraBrowsersModel::columnCount(const QModelIndex &) const
+{
+	return (int)Column::Count;
+}
+
+QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const
+{
+	int column = index.column();
+	int idx = index.row();
+	int count = items.size();
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (!validRole)
+		return QVariant();
+
+	if (idx >= 0 && idx < count) {
+		switch (column) {
+		case (int)Column::Title:
+			return items[idx].title;
+		case (int)Column::Url:
+			return items[idx].url;
+		}
+	} else if (idx == count) {
+		switch (column) {
+		case (int)Column::Title:
+			return newTitle;
+		case (int)Column::Url:
+			return newURL;
+		}
+	}
+
+	return QVariant();
+}
+
+QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (validRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case (int)Column::Title:
+			return QTStr("ExtraBrowsers.DockName");
+		case (int)Column::Url:
+			return QStringLiteral("URL");
+		}
+	}
+
+	return QVariant();
+}
+
+Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() != (int)Column::Delete)
+		flags |= Qt::ItemIsEditable;
+
+	return flags;
+}
+
+class DelButton : public QPushButton {
+public:
+	inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+class EditWidget : public QLineEdit {
+public:
+	inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+void ExtraBrowsersModel::AddDeleteButton(int idx)
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr);
+
+	QPushButton *del = new DelButton(index);
+	del->setProperty("class", "icon-trash");
+	del->setObjectName("extraPanelDelete");
+	del->setMinimumSize(QSize(20, 20));
+	connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem);
+
+	widget->setIndexWidget(index, del);
+	widget->setRowHeight(idx, 20);
+	widget->setColumnWidth(idx, 20);
+}
+
+void ExtraBrowsersModel::CheckToAdd()
+{
+	if (newTitle.isEmpty() || newURL.isEmpty())
+		return;
+
+	int idx = items.size() + 1;
+	beginInsertRows(QModelIndex(), idx, idx);
+
+	Item item;
+	item.prevIdx = -1;
+	item.title = newTitle;
+	item.url = newURL;
+	items.push_back(item);
+
+	newTitle = "";
+	newURL = "";
+
+	endInsertRows();
+
+	AddDeleteButton(idx - 1);
+}
+
+void ExtraBrowsersModel::UpdateItem(Item &item)
+{
+	int idx = item.prevIdx;
+
+	OBSBasic *main = OBSBasic::Get();
+	BrowserDock *dock = reinterpret_cast<BrowserDock *>(main->extraBrowserDocks[idx].get());
+	dock->setWindowTitle(item.title);
+	dock->setObjectName(item.title + OBJ_NAME_SUFFIX);
+
+	if (main->extraBrowserDockNames[idx] != item.title) {
+		main->extraBrowserDockNames[idx] = item.title;
+		dock->toggleViewAction()->setText(item.title);
+		dock->setTitle(item.title);
+	}
+
+	if (main->extraBrowserDockTargets[idx] != item.url) {
+		dock->cefWidget->setURL(QT_TO_UTF8(item.url));
+		main->extraBrowserDockTargets[idx] = item.url;
+	}
+}
+
+void ExtraBrowsersModel::DeleteItem()
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	DelButton *del = reinterpret_cast<DelButton *>(sender());
+	int row = del->index.row();
+
+	/* there's some sort of internal bug in Qt and deleting certain index
+	 * widgets or "editors" that can cause a crash inside Qt if the widget
+	 * is not manually removed, at least on 5.7 */
+	widget->setIndexWidget(del->index, nullptr);
+	del->deleteLater();
+
+	/* --------- */
+
+	beginRemoveRows(QModelIndex(), row, row);
+
+	int prevIdx = items[row].prevIdx;
+	items.removeAt(row);
+
+	if (prevIdx != -1) {
+		int i = 0;
+		for (; i < deleted.size() && deleted[i] < prevIdx; i++)
+			;
+		deleted.insert(i, prevIdx);
+	}
+
+	endRemoveRows();
+}
+
+void ExtraBrowsersModel::Apply()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	for (Item &item : items) {
+		if (item.prevIdx != -1) {
+			UpdateItem(item);
+		} else {
+			QString uuid = QUuid::createUuid().toString();
+			uuid.replace(QRegularExpression("[{}-]"), "");
+			main->AddExtraBrowserDock(item.title, item.url, uuid, true);
+		}
+	}
+
+	for (int i = deleted.size() - 1; i >= 0; i--) {
+		int idx = deleted[i];
+		main->extraBrowserDockTargets.removeAt(idx);
+		main->extraBrowserDockNames.removeAt(idx);
+		main->extraBrowserDocks.removeAt(idx);
+	}
+
+	if (main->extraBrowserDocks.empty())
+		main->extraBrowserMenuDocksSeparator.clear();
+
+	deleted.clear();
+
+	Reset();
+}
+
+void ExtraBrowsersModel::TabSelection(bool forward)
+{
+	QListView *widget = reinterpret_cast<QListView *>(parent());
+	QItemSelectionModel *selModel = widget->selectionModel();
+
+	QModelIndex sel = selModel->currentIndex();
+	int row = sel.row();
+	int col = sel.column();
+
+	switch (sel.column()) {
+	case (int)Column::Title:
+		if (!forward) {
+			if (row == 0) {
+				return;
+			}
+
+			row -= 1;
+		}
+
+		col += 1;
+		break;
+
+	case (int)Column::Url:
+		if (forward) {
+			if (row == items.size()) {
+				return;
+			}
+
+			row += 1;
+		}
+
+		col -= 1;
+	}
+
+	sel = createIndex(row, col, nullptr);
+	selModel->setCurrentIndex(sel, QItemSelectionModel::Clear);
+}
+
+void ExtraBrowsersModel::Init()
+{
+	for (int i = 0; i < items.count(); i++)
+		AddDeleteButton(i);
+}
+
+/* ------------------------------------------------------------------------- */
+
+QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &,
+					     const QModelIndex &index) const
+{
+	QLineEdit *text = new EditWidget(parent, index);
+	text->installEventFilter(const_cast<ExtraBrowsersDelegate *>(this));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	return text;
+}
+
+void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = reinterpret_cast<QLineEdit *>(editor);
+	text->blockSignals(true);
+	text->setText(index.data().toString());
+	text->blockSignals(false);
+}
+
+bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QLineEdit *edit = qobject_cast<QLineEdit *>(object);
+	if (!edit)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		RevertText(edit);
+	}
+	if (LineEditChanged(event)) {
+		UpdateText(edit);
+
+		if (event->type() == QEvent::KeyPress) {
+			QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+			if (keyEvent->key() == Qt::Key_Tab) {
+				model->TabSelection(true);
+			} else if (keyEvent->key() == Qt::Key_Backtab) {
+				model->TabSelection(false);
+			}
+		}
+		return true;
+	}
+
+	return false;
+}
+
+bool ExtraBrowsersDelegate::ValidName(const QString &name) const
+{
+	for (auto &item : model->items) {
+		if (name.compare(item.title, Qt::CaseInsensitive) == 0) {
+			return false;
+		}
+	}
+	return true;
+}
+
+void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString oldText;
+	if (col == (int)Column::Title) {
+		oldText = newItem ? model->newTitle : model->items[row].title;
+	} else {
+		oldText = newItem ? model->newURL : model->items[row].url;
+	}
+
+	edit->setText(oldText);
+}
+
+bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString text = edit->text().trimmed();
+
+	if (!newItem && text.isEmpty()) {
+		return false;
+	}
+
+	if (col == (int)Column::Title) {
+		QString oldText = newItem ? model->newTitle : model->items[row].title;
+		bool same = oldText.compare(text, Qt::CaseInsensitive) == 0;
+
+		if (!same && !ValidName(text)) {
+			edit->setText(oldText);
+			return false;
+		}
+	}
+
+	if (!newItem) {
+		/* if edited existing item, update it*/
+		switch (col) {
+		case (int)Column::Title:
+			model->items[row].title = text;
+			break;
+		case (int)Column::Url:
+			model->items[row].url = text;
+			break;
+		}
+	} else {
+		/* if both new values filled out, create new one */
+		switch (col) {
+		case (int)Column::Title:
+			model->newTitle = text;
+			break;
+		case (int)Column::Url:
+			model->newURL = text;
+			break;
+		}
+
+		model->CheckToAdd();
+	}
+
+	emit commitData(edit);
+	return true;
+}
+
+/* ------------------------------------------------------------------------- */
+
+OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers)
+{
+	ui->setupUi(this);
+
+	setAttribute(Qt::WA_DeleteOnClose, true);
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	model = new ExtraBrowsersModel(ui->table);
+
+	ui->table->setModel(model);
+	ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model));
+	ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model));
+	ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed);
+	ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+}
+
+OBSExtraBrowsers::~OBSExtraBrowsers() {}
+
+void OBSExtraBrowsers::closeEvent(QCloseEvent *event)
+{
+	QDialog::closeEvent(event);
+	model->Apply();
+}
+
+void OBSExtraBrowsers::on_apply_clicked()
+{
+	model->Apply();
+}
+
+/* ------------------------------------------------------------------------- */
+
+void OBSBasic::ClearExtraBrowserDocks()
+{
+	extraBrowserDockTargets.clear();
+	extraBrowserDockNames.clear();
+	extraBrowserDocks.clear();
+}
+
+void OBSBasic::LoadExtraBrowserDocks()
+{
+	const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks");
+
+	std::string err;
+	Json json = Json::parse(jsonStr, err);
+	if (!err.empty())
+		return;
+
+	Json::array array = json.array_items();
+	if (!array.empty())
+		extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator();
+
+	for (Json &item : array) {
+		std::string title = item["title"].string_value();
+		std::string url = item["url"].string_value();
+		std::string uuid = item["uuid"].string_value();
+
+		AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false);
+	}
+}
+
+void OBSBasic::SaveExtraBrowserDocks()
+{
+	Json::array array;
+	for (int i = 0; i < extraBrowserDocks.size(); i++) {
+		QDockWidget *dock = extraBrowserDocks[i].get();
+		QString title = extraBrowserDockNames[i];
+		QString url = extraBrowserDockTargets[i];
+		QString uuid = dock->property("uuid").toString();
+		Json::object obj{
+			{"title", QT_TO_UTF8(title)},
+			{"url", QT_TO_UTF8(url)},
+			{"uuid", QT_TO_UTF8(uuid)},
+		};
+		array.push_back(obj);
+	}
+
+	std::string output = Json(array).dump();
+	config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str());
+}
+
+void OBSBasic::ManageExtraBrowserDocks()
+{
+	if (!extraBrowsers.isNull()) {
+		extraBrowsers->show();
+		extraBrowsers->raise();
+		return;
+	}
+
+	extraBrowsers = new OBSExtraBrowsers(this);
+	extraBrowsers->show();
+}
+
+void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate)
+{
+	static int panel_version = -1;
+	if (panel_version == -1) {
+		panel_version = obs_browser_qcef_version();
+	}
+
+	BrowserDock *dock = new BrowserDock(title);
+	QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid);
+	bId.replace(QRegularExpression("[{}-]"), "");
+	dock->setProperty("uuid", bId);
+	dock->setObjectName(title + OBJ_NAME_SUFFIX);
+	dock->resize(460, 600);
+	dock->setMinimumSize(80, 80);
+	dock->setWindowTitle(title);
+	dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr);
+	if (browser && panel_version >= 1)
+		browser->allowAllPopups(true);
+
+	dock->SetWidget(browser);
+
+	/* Add support for Twitch Dashboard panels */
+	if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) {
+		QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/");
+		QRegularExpressionMatch match = re.match(url);
+		QString username = match.captured(1);
+		if (username.length() > 0) {
+			std::string script;
+			script = "Object.defineProperty(document, 'referrer', { get: () => '";
+			script += "https://twitch.tv/";
+			script += QT_TO_UTF8(username);
+			script += "/dashboard/live";
+			script += "'});";
+			browser->setStartupScript(script);
+		}
+	}
+
+	AddDockWidget(dock, Qt::RightDockWidgetArea, true);
+	extraBrowserDocks.push_back(std::shared_ptr<QDockWidget>(dock));
+	extraBrowserDockNames.push_back(title);
+	extraBrowserDockTargets.push_back(url);
+
+	if (firstCreate) {
+		dock->setFloating(true);
+
+		QPoint curPos = pos();
+		QSize wSizeD2 = size() / 2;
+		QSize dSizeD2 = dock->size() / 2;
+
+		curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width());
+		curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height());
+
+		dock->move(curPos);
+		dock->setVisible(true);
+	}
+}

+ 88 - 0
frontend/utility/ExtraBrowsersModel.hpp

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <QDialog>
+#include <QScopedPointer>
+#include <QAbstractTableModel>
+#include <QStyledItemDelegate>
+#include <memory>
+
+class Ui_OBSExtraBrowsers;
+class ExtraBrowsersModel;
+
+class QCefWidget;
+
+class OBSExtraBrowsers : public QDialog {
+	Q_OBJECT
+
+	std::unique_ptr<Ui_OBSExtraBrowsers> ui;
+	ExtraBrowsersModel *model;
+
+public:
+	OBSExtraBrowsers(QWidget *parent);
+	~OBSExtraBrowsers();
+
+	void closeEvent(QCloseEvent *event) override;
+
+public slots:
+	void on_apply_clicked();
+};
+
+class ExtraBrowsersModel : public QAbstractTableModel {
+	Q_OBJECT
+
+public:
+	inline ExtraBrowsersModel(QObject *parent = nullptr) : QAbstractTableModel(parent)
+	{
+		Reset();
+		QMetaObject::invokeMethod(this, "Init", Qt::QueuedConnection);
+	}
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+	QVariant data(const QModelIndex &index, int role) const override;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
+	Qt::ItemFlags flags(const QModelIndex &index) const override;
+
+	struct Item {
+		int prevIdx;
+		QString title;
+		QString url;
+	};
+
+	void TabSelection(bool forward);
+
+	void AddDeleteButton(int idx);
+	void Reset();
+	void CheckToAdd();
+	void UpdateItem(Item &item);
+	void DeleteItem();
+	void Apply();
+
+	QVector<Item> items;
+	QVector<int> deleted;
+
+	QString newTitle;
+	QString newURL;
+
+public slots:
+	void Init();
+};
+
+class ExtraBrowsersDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	inline ExtraBrowsersDelegate(ExtraBrowsersModel *model_) : QStyledItemDelegate(nullptr), model(model_) {}
+
+	QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
+			      const QModelIndex &index) const override;
+
+	void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+
+	bool eventFilter(QObject *object, QEvent *event) override;
+	void RevertText(QLineEdit *edit);
+	bool UpdateText(QLineEdit *edit);
+	bool ValidName(const QString &text) const;
+
+	ExtraBrowsersModel *model;
+};

+ 542 - 0
frontend/utility/MissingFilesModel.cpp

@@ -0,0 +1,542 @@
+/******************************************************************************
+    Copyright (C) 2019 by Dillon Pentz <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "moc_window-missing-files.cpp"
+#include "window-basic-main.hpp"
+
+#include "obs-app.hpp"
+
+#include <QLineEdit>
+#include <QToolButton>
+#include <QFileDialog>
+
+#include <qt-wrappers.hpp>
+
+enum MissingFilesColumn {
+	Source,
+	OriginalPath,
+	NewPath,
+	State,
+
+	Count
+};
+
+enum MissingFilesRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole };
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+MissingFilesPathItemDelegate::MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath)
+	: QStyledItemDelegate(),
+	  isOutput(isOutput),
+	  defaultPath(defaultPath)
+{
+}
+
+QWidget *MissingFilesPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+						    const QModelIndex &) const
+{
+	QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding,
+				     QSizePolicy::ControlType::PushButton);
+
+	QWidget *container = new QWidget(parent);
+
+	auto browseCallback = [this, container]() {
+		const_cast<MissingFilesPathItemDelegate *>(this)->handleBrowse(container);
+	};
+
+	auto clearCallback = [this, container]() {
+		const_cast<MissingFilesPathItemDelegate *>(this)->handleClear(container);
+	};
+
+	QHBoxLayout *layout = new QHBoxLayout();
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->setSpacing(0);
+
+	QLineEdit *text = new QLineEdit();
+	text->setObjectName(QStringLiteral("text"));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	layout->addWidget(text);
+
+	QToolButton *browseButton = new QToolButton();
+	browseButton->setText("...");
+	browseButton->setSizePolicy(buttonSizePolicy);
+	layout->addWidget(browseButton);
+
+	container->connect(browseButton, &QToolButton::clicked, browseCallback);
+
+	// The "clear" button is not shown in input cells
+	if (isOutput) {
+		QToolButton *clearButton = new QToolButton();
+		clearButton->setText("X");
+		clearButton->setSizePolicy(buttonSizePolicy);
+		layout->addWidget(clearButton);
+
+		container->connect(clearButton, &QToolButton::clicked, clearCallback);
+	}
+
+	container->setLayout(layout);
+	container->setFocusProxy(text);
+
+	return container;
+}
+
+void MissingFilesPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = editor->findChild<QLineEdit *>();
+	text->setText(index.data().toString());
+
+	editor->setProperty(PATH_LIST_PROP, QVariant());
+}
+
+void MissingFilesPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
+						const QModelIndex &index) const
+{
+	// We use the PATH_LIST_PROP property to pass a list of
+	// path strings from the editor widget into the model's
+	// NewPathsToProcessRole. This is only used when paths
+	// are selected through the "browse" or "delete" buttons
+	// in the editor. If the user enters new text in the
+	// text box, we simply pass that text on to the model
+	// as normal text data in the default role.
+	QVariant pathListProp = editor->property(PATH_LIST_PROP);
+	if (pathListProp.isValid()) {
+		QStringList list = editor->property(PATH_LIST_PROP).toStringList();
+		if (isOutput) {
+			model->setData(index, list);
+		} else
+			model->setData(index, list, MissingFilesRole::NewPathsToProcessRole);
+	} else {
+		QLineEdit *lineEdit = editor->findChild<QLineEdit *>();
+		model->setData(index, lineEdit->text(), 0);
+	}
+}
+
+void MissingFilesPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+					 const QModelIndex &index) const
+{
+	QStyleOptionViewItem localOption = option;
+	initStyleOption(&localOption, index);
+
+	QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter);
+}
+
+void MissingFilesPathItemDelegate::handleBrowse(QWidget *container)
+{
+
+	QLineEdit *text = container->findChild<QLineEdit *>();
+
+	QString currentPath = text->text();
+	if (currentPath.isEmpty() || currentPath.compare(QTStr("MissingFiles.Clear")) == 0)
+		currentPath = defaultPath;
+
+	bool isSet = false;
+	if (isOutput) {
+		QString newPath =
+			QFileDialog::getOpenFileName(container, QTStr("MissingFiles.SelectFile"), currentPath, nullptr);
+
+#ifdef __APPLE__
+		// TODO: Revisit when QTBUG-42661 is fixed
+		container->window()->raise();
+#endif
+
+		if (!newPath.isEmpty()) {
+			container->setProperty(PATH_LIST_PROP, QStringList() << newPath);
+			isSet = true;
+		}
+	}
+
+	if (isSet)
+		emit commitData(container);
+}
+
+void MissingFilesPathItemDelegate::handleClear(QWidget *container)
+{
+	// An empty string list will indicate that the entry is being
+	// blanked and should be deleted.
+	container->setProperty(PATH_LIST_PROP, QStringList() << QTStr("MissingFiles.Clear"));
+	container->findChild<QLineEdit *>()->clearFocus();
+	((QWidget *)container->parent())->setFocus();
+	emit commitData(container);
+}
+
+/**
+	Model
+**/
+
+MissingFilesModel::MissingFilesModel(QObject *parent) : QAbstractTableModel(parent)
+{
+	QStyle *style = QApplication::style();
+
+	warningIcon = style->standardIcon(QStyle::SP_MessageBoxWarning);
+}
+
+int MissingFilesModel::rowCount(const QModelIndex &) const
+{
+	return files.length();
+}
+
+int MissingFilesModel::columnCount(const QModelIndex &) const
+{
+	return MissingFilesColumn::Count;
+}
+
+int MissingFilesModel::found() const
+{
+	int res = 0;
+
+	for (int i = 0; i < files.length(); i++) {
+		if (files[i].state != Missing && files[i].state != Cleared)
+			res++;
+	}
+
+	return res;
+}
+
+QVariant MissingFilesModel::data(const QModelIndex &index, int role) const
+{
+	QVariant result = QVariant();
+
+	if (index.row() >= files.length()) {
+		return QVariant();
+	} else if (role == Qt::DisplayRole) {
+		QFileInfo fi(files[index.row()].originalPath);
+
+		switch (index.column()) {
+		case MissingFilesColumn::Source:
+			result = files[index.row()].source;
+			break;
+		case MissingFilesColumn::OriginalPath:
+			result = fi.fileName();
+			break;
+		case MissingFilesColumn::NewPath:
+			result = files[index.row()].newPath;
+			break;
+		case MissingFilesColumn::State:
+			switch (files[index.row()].state) {
+			case MissingFilesState::Missing:
+				result = QTStr("MissingFiles.Missing");
+				break;
+
+			case MissingFilesState::Replaced:
+				result = QTStr("MissingFiles.Replaced");
+				break;
+
+			case MissingFilesState::Found:
+				result = QTStr("MissingFiles.Found");
+				break;
+
+			case MissingFilesState::Cleared:
+				result = QTStr("MissingFiles.Cleared");
+				break;
+			}
+			break;
+		}
+	} else if (role == Qt::DecorationRole && index.column() == MissingFilesColumn::Source) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSourceAutoRelease source = obs_get_source_by_name(files[index.row()].source.toStdString().c_str());
+
+		if (source) {
+			result = main->GetSourceIcon(obs_source_get_id(source));
+		}
+	} else if (role == Qt::FontRole && index.column() == MissingFilesColumn::State) {
+		QFont font = QFont();
+		font.setBold(true);
+
+		result = font;
+	} else if (role == Qt::ToolTipRole && index.column() == MissingFilesColumn::State) {
+		switch (files[index.row()].state) {
+		case MissingFilesState::Missing:
+			result = QTStr("MissingFiles.Missing");
+			break;
+
+		case MissingFilesState::Replaced:
+			result = QTStr("MissingFiles.Replaced");
+			break;
+
+		case MissingFilesState::Found:
+			result = QTStr("MissingFiles.Found");
+			break;
+
+		case MissingFilesState::Cleared:
+			result = QTStr("MissingFiles.Cleared");
+			break;
+
+		default:
+			break;
+		}
+	} else if (role == Qt::ToolTipRole) {
+		switch (index.column()) {
+		case MissingFilesColumn::OriginalPath:
+			result = files[index.row()].originalPath;
+			break;
+		case MissingFilesColumn::NewPath:
+			result = files[index.row()].newPath;
+			break;
+		default:
+			break;
+		}
+	}
+
+	return result;
+}
+
+Qt::ItemFlags MissingFilesModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() == MissingFilesColumn::OriginalPath) {
+		flags &= ~Qt::ItemIsEditable;
+	} else if (index.column() == MissingFilesColumn::NewPath && index.row() != files.length()) {
+		flags |= Qt::ItemIsEditable;
+	}
+
+	return flags;
+}
+
+void MissingFilesModel::fileCheckLoop(QList<MissingFileEntry> files, QString path, bool skipPrompt)
+{
+	loop = false;
+	QUrl url = QUrl().fromLocalFile(path);
+	QString dir = url.toDisplayString(QUrl::RemoveScheme | QUrl::RemoveFilename | QUrl::PreferLocalFile);
+
+	bool prompted = skipPrompt;
+
+	for (int i = 0; i < files.length(); i++) {
+		if (files[i].state != MissingFilesState::Missing)
+			continue;
+
+		QUrl origFile = QUrl().fromLocalFile(files[i].originalPath);
+		QString filename = origFile.fileName();
+		QString testFile = dir + filename;
+
+		if (os_file_exists(testFile.toStdString().c_str())) {
+			if (!prompted) {
+				QMessageBox::StandardButton button =
+					QMessageBox::question(nullptr, QTStr("MissingFiles.AutoSearch"),
+							      QTStr("MissingFiles.AutoSearchText"));
+
+				if (button == QMessageBox::No)
+					break;
+
+				prompted = true;
+			}
+			QModelIndex in = index(i, MissingFilesColumn::NewPath);
+			setData(in, testFile, 0);
+		}
+	}
+	loop = true;
+}
+
+bool MissingFilesModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+	bool success = false;
+
+	if (role == MissingFilesRole::NewPathsToProcessRole) {
+		QStringList list = value.toStringList();
+
+		int row = index.row() + 1;
+		beginInsertRows(QModelIndex(), row, row);
+
+		MissingFileEntry entry;
+		entry.originalPath = list[0].replace("\\", "/");
+		entry.source = list[1];
+
+		files.insert(row, entry);
+		row++;
+
+		endInsertRows();
+
+		success = true;
+	} else {
+		QString path = value.toString();
+		if (index.column() == MissingFilesColumn::NewPath) {
+			files[index.row()].newPath = value.toString();
+			QString fileName = QUrl(path).fileName();
+			QString origFileName = QUrl(files[index.row()].originalPath).fileName();
+
+			if (path.isEmpty()) {
+				files[index.row()].state = MissingFilesState::Missing;
+			} else if (path.compare(QTStr("MissingFiles.Clear")) == 0) {
+				files[index.row()].state = MissingFilesState::Cleared;
+			} else if (fileName.compare(origFileName) == 0) {
+				files[index.row()].state = MissingFilesState::Found;
+
+				if (loop)
+					fileCheckLoop(files, path, false);
+			} else {
+				files[index.row()].state = MissingFilesState::Replaced;
+
+				if (loop)
+					fileCheckLoop(files, path, false);
+			}
+
+			emit dataChanged(index, index);
+			success = true;
+		}
+	}
+
+	return success;
+}
+
+QVariant MissingFilesModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	QVariant result = QVariant();
+
+	if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case MissingFilesColumn::State:
+			result = QTStr("MissingFiles.State");
+			break;
+		case MissingFilesColumn::Source:
+			result = QTStr("Basic.Main.Source");
+			break;
+		case MissingFilesColumn::OriginalPath:
+			result = QTStr("MissingFiles.MissingFile");
+			break;
+		case MissingFilesColumn::NewPath:
+			result = QTStr("MissingFiles.NewFile");
+			break;
+		}
+	}
+
+	return result;
+}
+
+OBSMissingFiles::OBSMissingFiles(obs_missing_files_t *files, QWidget *parent)
+	: QDialog(parent),
+	  filesModel(new MissingFilesModel),
+	  ui(new Ui::OBSMissingFiles)
+{
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	ui->tableView->setModel(filesModel);
+	ui->tableView->setItemDelegateForColumn(MissingFilesColumn::OriginalPath,
+						new MissingFilesPathItemDelegate(false, ""));
+	ui->tableView->setItemDelegateForColumn(MissingFilesColumn::NewPath,
+						new MissingFilesPathItemDelegate(true, ""));
+	ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::Source,
+								QHeaderView::ResizeMode::ResizeToContents);
+	ui->tableView->horizontalHeader()->setMaximumSectionSize(width() / 3);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::State,
+								QHeaderView::ResizeMode::ResizeToContents);
+	ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+
+	ui->warningIcon->setPixmap(filesModel->warningIcon.pixmap(QSize(32, 32)));
+
+	for (size_t i = 0; i < obs_missing_files_count(files); i++) {
+		obs_missing_file_t *f = obs_missing_files_get_file(files, (int)i);
+
+		const char *oldPath = obs_missing_file_get_path(f);
+		const char *name = obs_missing_file_get_source_name(f);
+
+		addMissingFile(oldPath, name);
+	}
+
+	QString found = QTStr("MissingFiles.NumFound").arg("0", QString::number(obs_missing_files_count(files)));
+
+	ui->found->setText(found);
+
+	fileStore = files;
+
+	connect(ui->doneButton, &QPushButton::clicked, this, &OBSMissingFiles::saveFiles);
+	connect(ui->browseButton, &QPushButton::clicked, this, &OBSMissingFiles::browseFolders);
+	connect(ui->cancelButton, &QPushButton::clicked, this, &OBSMissingFiles::close);
+	connect(filesModel, &MissingFilesModel::dataChanged, this, &OBSMissingFiles::dataChanged);
+
+	QModelIndex index = filesModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection,
+				  Q_ARG(const QModelIndex &, index));
+}
+
+OBSMissingFiles::~OBSMissingFiles()
+{
+	obs_missing_files_destroy(fileStore);
+}
+
+void OBSMissingFiles::addMissingFile(const char *originalPath, const char *sourceName)
+{
+	QStringList list;
+
+	list.append(originalPath);
+	list.append(sourceName);
+
+	QModelIndex insertIndex = filesModel->index(filesModel->rowCount() - 1, MissingFilesColumn::Source);
+
+	filesModel->setData(insertIndex, list, MissingFilesRole::NewPathsToProcessRole);
+}
+
+void OBSMissingFiles::saveFiles()
+{
+	for (int i = 0; i < filesModel->files.length(); i++) {
+		MissingFilesState state = filesModel->files[i].state;
+		if (state != MissingFilesState::Missing) {
+			obs_missing_file_t *f = obs_missing_files_get_file(fileStore, i);
+
+			QString path = filesModel->files[i].newPath;
+
+			if (state == MissingFilesState::Cleared) {
+				obs_missing_file_issue_callback(f, "");
+			} else {
+				char *p = bstrdup(path.toStdString().c_str());
+				obs_missing_file_issue_callback(f, p);
+				bfree(p);
+			}
+		}
+	}
+
+	QDialog::accept();
+}
+
+void OBSMissingFiles::browseFolders()
+{
+	QString dir = QFileDialog::getExistingDirectory(this, QTStr("MissingFiles.SelectDir"), "",
+							QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
+
+	if (dir != "") {
+		dir += "/";
+		filesModel->fileCheckLoop(filesModel->files, dir, true);
+	}
+}
+
+void OBSMissingFiles::dataChanged()
+{
+	QString found =
+		QTStr("MissingFiles.NumFound")
+			.arg(QString::number(filesModel->found()), QString::number(obs_missing_files_count(fileStore)));
+
+	ui->found->setText(found);
+
+	ui->tableView->resizeColumnToContents(MissingFilesColumn::State);
+	ui->tableView->resizeColumnToContents(MissingFilesColumn::Source);
+}
+
+QIcon OBSMissingFiles::GetWarningIcon()
+{
+	return filesModel->warningIcon;
+}
+
+void OBSMissingFiles::SetWarningIcon(const QIcon &icon)
+{
+	ui->warningIcon->setPixmap(icon.pixmap(QSize(32, 32)));
+	filesModel->warningIcon = icon;
+}

+ 112 - 0
frontend/utility/MissingFilesModel.hpp

@@ -0,0 +1,112 @@
+/******************************************************************************
+    Copyright (C) 2019 by Dillon Pentz <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QPointer>
+#include <QStyledItemDelegate>
+#include "obs-app.hpp"
+#include "ui_OBSMissingFiles.h"
+
+class MissingFilesModel;
+
+enum MissingFilesState { Missing, Found, Replaced, Cleared };
+Q_DECLARE_METATYPE(MissingFilesState);
+
+class OBSMissingFiles : public QDialog {
+	Q_OBJECT
+	Q_PROPERTY(QIcon warningIcon READ GetWarningIcon WRITE SetWarningIcon DESIGNABLE true)
+
+	QPointer<MissingFilesModel> filesModel;
+	std::unique_ptr<Ui::OBSMissingFiles> ui;
+
+public:
+	explicit OBSMissingFiles(obs_missing_files_t *files, QWidget *parent = nullptr);
+	virtual ~OBSMissingFiles() override;
+
+	void addMissingFile(const char *originalPath, const char *sourceName);
+
+	QIcon GetWarningIcon();
+	void SetWarningIcon(const QIcon &icon);
+
+private:
+	void saveFiles();
+	void browseFolders();
+
+	obs_missing_files_t *fileStore;
+
+public slots:
+	void dataChanged();
+};
+
+class MissingFilesModel : public QAbstractTableModel {
+	Q_OBJECT
+
+	friend class OBSMissingFiles;
+
+public:
+	explicit MissingFilesModel(QObject *parent = 0);
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const;
+	int found() const;
+	QVariant data(const QModelIndex &index, int role) const;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const;
+	Qt::ItemFlags flags(const QModelIndex &index) const;
+	bool setData(const QModelIndex &index, const QVariant &value, int role);
+
+	bool loop = true;
+
+	QIcon warningIcon;
+
+private:
+	struct MissingFileEntry {
+		MissingFilesState state = MissingFilesState::Missing;
+
+		QString source;
+
+		QString originalPath;
+		QString newPath;
+	};
+
+	QList<MissingFileEntry> files;
+
+	void fileCheckLoop(QList<MissingFileEntry> files, QString path, bool skipPrompt);
+};
+
+class MissingFilesPathItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath);
+
+	virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+				      const QModelIndex &index) const override;
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+	virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
+	virtual void paint(QPainter *painter, const QStyleOptionViewItem &option,
+			   const QModelIndex &index) const override;
+
+private:
+	bool isOutput;
+	QString defaultPath;
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container);
+	void handleClear(QWidget *container);
+};

+ 542 - 0
frontend/utility/MissingFilesPathItemDelegate.cpp

@@ -0,0 +1,542 @@
+/******************************************************************************
+    Copyright (C) 2019 by Dillon Pentz <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "moc_window-missing-files.cpp"
+#include "window-basic-main.hpp"
+
+#include "obs-app.hpp"
+
+#include <QLineEdit>
+#include <QToolButton>
+#include <QFileDialog>
+
+#include <qt-wrappers.hpp>
+
+enum MissingFilesColumn {
+	Source,
+	OriginalPath,
+	NewPath,
+	State,
+
+	Count
+};
+
+enum MissingFilesRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole };
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+MissingFilesPathItemDelegate::MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath)
+	: QStyledItemDelegate(),
+	  isOutput(isOutput),
+	  defaultPath(defaultPath)
+{
+}
+
+QWidget *MissingFilesPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+						    const QModelIndex &) const
+{
+	QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding,
+				     QSizePolicy::ControlType::PushButton);
+
+	QWidget *container = new QWidget(parent);
+
+	auto browseCallback = [this, container]() {
+		const_cast<MissingFilesPathItemDelegate *>(this)->handleBrowse(container);
+	};
+
+	auto clearCallback = [this, container]() {
+		const_cast<MissingFilesPathItemDelegate *>(this)->handleClear(container);
+	};
+
+	QHBoxLayout *layout = new QHBoxLayout();
+	layout->setContentsMargins(0, 0, 0, 0);
+	layout->setSpacing(0);
+
+	QLineEdit *text = new QLineEdit();
+	text->setObjectName(QStringLiteral("text"));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	layout->addWidget(text);
+
+	QToolButton *browseButton = new QToolButton();
+	browseButton->setText("...");
+	browseButton->setSizePolicy(buttonSizePolicy);
+	layout->addWidget(browseButton);
+
+	container->connect(browseButton, &QToolButton::clicked, browseCallback);
+
+	// The "clear" button is not shown in input cells
+	if (isOutput) {
+		QToolButton *clearButton = new QToolButton();
+		clearButton->setText("X");
+		clearButton->setSizePolicy(buttonSizePolicy);
+		layout->addWidget(clearButton);
+
+		container->connect(clearButton, &QToolButton::clicked, clearCallback);
+	}
+
+	container->setLayout(layout);
+	container->setFocusProxy(text);
+
+	return container;
+}
+
+void MissingFilesPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = editor->findChild<QLineEdit *>();
+	text->setText(index.data().toString());
+
+	editor->setProperty(PATH_LIST_PROP, QVariant());
+}
+
+void MissingFilesPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
+						const QModelIndex &index) const
+{
+	// We use the PATH_LIST_PROP property to pass a list of
+	// path strings from the editor widget into the model's
+	// NewPathsToProcessRole. This is only used when paths
+	// are selected through the "browse" or "delete" buttons
+	// in the editor. If the user enters new text in the
+	// text box, we simply pass that text on to the model
+	// as normal text data in the default role.
+	QVariant pathListProp = editor->property(PATH_LIST_PROP);
+	if (pathListProp.isValid()) {
+		QStringList list = editor->property(PATH_LIST_PROP).toStringList();
+		if (isOutput) {
+			model->setData(index, list);
+		} else
+			model->setData(index, list, MissingFilesRole::NewPathsToProcessRole);
+	} else {
+		QLineEdit *lineEdit = editor->findChild<QLineEdit *>();
+		model->setData(index, lineEdit->text(), 0);
+	}
+}
+
+void MissingFilesPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+					 const QModelIndex &index) const
+{
+	QStyleOptionViewItem localOption = option;
+	initStyleOption(&localOption, index);
+
+	QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter);
+}
+
+void MissingFilesPathItemDelegate::handleBrowse(QWidget *container)
+{
+
+	QLineEdit *text = container->findChild<QLineEdit *>();
+
+	QString currentPath = text->text();
+	if (currentPath.isEmpty() || currentPath.compare(QTStr("MissingFiles.Clear")) == 0)
+		currentPath = defaultPath;
+
+	bool isSet = false;
+	if (isOutput) {
+		QString newPath =
+			QFileDialog::getOpenFileName(container, QTStr("MissingFiles.SelectFile"), currentPath, nullptr);
+
+#ifdef __APPLE__
+		// TODO: Revisit when QTBUG-42661 is fixed
+		container->window()->raise();
+#endif
+
+		if (!newPath.isEmpty()) {
+			container->setProperty(PATH_LIST_PROP, QStringList() << newPath);
+			isSet = true;
+		}
+	}
+
+	if (isSet)
+		emit commitData(container);
+}
+
+void MissingFilesPathItemDelegate::handleClear(QWidget *container)
+{
+	// An empty string list will indicate that the entry is being
+	// blanked and should be deleted.
+	container->setProperty(PATH_LIST_PROP, QStringList() << QTStr("MissingFiles.Clear"));
+	container->findChild<QLineEdit *>()->clearFocus();
+	((QWidget *)container->parent())->setFocus();
+	emit commitData(container);
+}
+
+/**
+	Model
+**/
+
+MissingFilesModel::MissingFilesModel(QObject *parent) : QAbstractTableModel(parent)
+{
+	QStyle *style = QApplication::style();
+
+	warningIcon = style->standardIcon(QStyle::SP_MessageBoxWarning);
+}
+
+int MissingFilesModel::rowCount(const QModelIndex &) const
+{
+	return files.length();
+}
+
+int MissingFilesModel::columnCount(const QModelIndex &) const
+{
+	return MissingFilesColumn::Count;
+}
+
+int MissingFilesModel::found() const
+{
+	int res = 0;
+
+	for (int i = 0; i < files.length(); i++) {
+		if (files[i].state != Missing && files[i].state != Cleared)
+			res++;
+	}
+
+	return res;
+}
+
+QVariant MissingFilesModel::data(const QModelIndex &index, int role) const
+{
+	QVariant result = QVariant();
+
+	if (index.row() >= files.length()) {
+		return QVariant();
+	} else if (role == Qt::DisplayRole) {
+		QFileInfo fi(files[index.row()].originalPath);
+
+		switch (index.column()) {
+		case MissingFilesColumn::Source:
+			result = files[index.row()].source;
+			break;
+		case MissingFilesColumn::OriginalPath:
+			result = fi.fileName();
+			break;
+		case MissingFilesColumn::NewPath:
+			result = files[index.row()].newPath;
+			break;
+		case MissingFilesColumn::State:
+			switch (files[index.row()].state) {
+			case MissingFilesState::Missing:
+				result = QTStr("MissingFiles.Missing");
+				break;
+
+			case MissingFilesState::Replaced:
+				result = QTStr("MissingFiles.Replaced");
+				break;
+
+			case MissingFilesState::Found:
+				result = QTStr("MissingFiles.Found");
+				break;
+
+			case MissingFilesState::Cleared:
+				result = QTStr("MissingFiles.Cleared");
+				break;
+			}
+			break;
+		}
+	} else if (role == Qt::DecorationRole && index.column() == MissingFilesColumn::Source) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSourceAutoRelease source = obs_get_source_by_name(files[index.row()].source.toStdString().c_str());
+
+		if (source) {
+			result = main->GetSourceIcon(obs_source_get_id(source));
+		}
+	} else if (role == Qt::FontRole && index.column() == MissingFilesColumn::State) {
+		QFont font = QFont();
+		font.setBold(true);
+
+		result = font;
+	} else if (role == Qt::ToolTipRole && index.column() == MissingFilesColumn::State) {
+		switch (files[index.row()].state) {
+		case MissingFilesState::Missing:
+			result = QTStr("MissingFiles.Missing");
+			break;
+
+		case MissingFilesState::Replaced:
+			result = QTStr("MissingFiles.Replaced");
+			break;
+
+		case MissingFilesState::Found:
+			result = QTStr("MissingFiles.Found");
+			break;
+
+		case MissingFilesState::Cleared:
+			result = QTStr("MissingFiles.Cleared");
+			break;
+
+		default:
+			break;
+		}
+	} else if (role == Qt::ToolTipRole) {
+		switch (index.column()) {
+		case MissingFilesColumn::OriginalPath:
+			result = files[index.row()].originalPath;
+			break;
+		case MissingFilesColumn::NewPath:
+			result = files[index.row()].newPath;
+			break;
+		default:
+			break;
+		}
+	}
+
+	return result;
+}
+
+Qt::ItemFlags MissingFilesModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() == MissingFilesColumn::OriginalPath) {
+		flags &= ~Qt::ItemIsEditable;
+	} else if (index.column() == MissingFilesColumn::NewPath && index.row() != files.length()) {
+		flags |= Qt::ItemIsEditable;
+	}
+
+	return flags;
+}
+
+void MissingFilesModel::fileCheckLoop(QList<MissingFileEntry> files, QString path, bool skipPrompt)
+{
+	loop = false;
+	QUrl url = QUrl().fromLocalFile(path);
+	QString dir = url.toDisplayString(QUrl::RemoveScheme | QUrl::RemoveFilename | QUrl::PreferLocalFile);
+
+	bool prompted = skipPrompt;
+
+	for (int i = 0; i < files.length(); i++) {
+		if (files[i].state != MissingFilesState::Missing)
+			continue;
+
+		QUrl origFile = QUrl().fromLocalFile(files[i].originalPath);
+		QString filename = origFile.fileName();
+		QString testFile = dir + filename;
+
+		if (os_file_exists(testFile.toStdString().c_str())) {
+			if (!prompted) {
+				QMessageBox::StandardButton button =
+					QMessageBox::question(nullptr, QTStr("MissingFiles.AutoSearch"),
+							      QTStr("MissingFiles.AutoSearchText"));
+
+				if (button == QMessageBox::No)
+					break;
+
+				prompted = true;
+			}
+			QModelIndex in = index(i, MissingFilesColumn::NewPath);
+			setData(in, testFile, 0);
+		}
+	}
+	loop = true;
+}
+
+bool MissingFilesModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+	bool success = false;
+
+	if (role == MissingFilesRole::NewPathsToProcessRole) {
+		QStringList list = value.toStringList();
+
+		int row = index.row() + 1;
+		beginInsertRows(QModelIndex(), row, row);
+
+		MissingFileEntry entry;
+		entry.originalPath = list[0].replace("\\", "/");
+		entry.source = list[1];
+
+		files.insert(row, entry);
+		row++;
+
+		endInsertRows();
+
+		success = true;
+	} else {
+		QString path = value.toString();
+		if (index.column() == MissingFilesColumn::NewPath) {
+			files[index.row()].newPath = value.toString();
+			QString fileName = QUrl(path).fileName();
+			QString origFileName = QUrl(files[index.row()].originalPath).fileName();
+
+			if (path.isEmpty()) {
+				files[index.row()].state = MissingFilesState::Missing;
+			} else if (path.compare(QTStr("MissingFiles.Clear")) == 0) {
+				files[index.row()].state = MissingFilesState::Cleared;
+			} else if (fileName.compare(origFileName) == 0) {
+				files[index.row()].state = MissingFilesState::Found;
+
+				if (loop)
+					fileCheckLoop(files, path, false);
+			} else {
+				files[index.row()].state = MissingFilesState::Replaced;
+
+				if (loop)
+					fileCheckLoop(files, path, false);
+			}
+
+			emit dataChanged(index, index);
+			success = true;
+		}
+	}
+
+	return success;
+}
+
+QVariant MissingFilesModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	QVariant result = QVariant();
+
+	if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case MissingFilesColumn::State:
+			result = QTStr("MissingFiles.State");
+			break;
+		case MissingFilesColumn::Source:
+			result = QTStr("Basic.Main.Source");
+			break;
+		case MissingFilesColumn::OriginalPath:
+			result = QTStr("MissingFiles.MissingFile");
+			break;
+		case MissingFilesColumn::NewPath:
+			result = QTStr("MissingFiles.NewFile");
+			break;
+		}
+	}
+
+	return result;
+}
+
+OBSMissingFiles::OBSMissingFiles(obs_missing_files_t *files, QWidget *parent)
+	: QDialog(parent),
+	  filesModel(new MissingFilesModel),
+	  ui(new Ui::OBSMissingFiles)
+{
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	ui->tableView->setModel(filesModel);
+	ui->tableView->setItemDelegateForColumn(MissingFilesColumn::OriginalPath,
+						new MissingFilesPathItemDelegate(false, ""));
+	ui->tableView->setItemDelegateForColumn(MissingFilesColumn::NewPath,
+						new MissingFilesPathItemDelegate(true, ""));
+	ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::Source,
+								QHeaderView::ResizeMode::ResizeToContents);
+	ui->tableView->horizontalHeader()->setMaximumSectionSize(width() / 3);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::State,
+								QHeaderView::ResizeMode::ResizeToContents);
+	ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+
+	ui->warningIcon->setPixmap(filesModel->warningIcon.pixmap(QSize(32, 32)));
+
+	for (size_t i = 0; i < obs_missing_files_count(files); i++) {
+		obs_missing_file_t *f = obs_missing_files_get_file(files, (int)i);
+
+		const char *oldPath = obs_missing_file_get_path(f);
+		const char *name = obs_missing_file_get_source_name(f);
+
+		addMissingFile(oldPath, name);
+	}
+
+	QString found = QTStr("MissingFiles.NumFound").arg("0", QString::number(obs_missing_files_count(files)));
+
+	ui->found->setText(found);
+
+	fileStore = files;
+
+	connect(ui->doneButton, &QPushButton::clicked, this, &OBSMissingFiles::saveFiles);
+	connect(ui->browseButton, &QPushButton::clicked, this, &OBSMissingFiles::browseFolders);
+	connect(ui->cancelButton, &QPushButton::clicked, this, &OBSMissingFiles::close);
+	connect(filesModel, &MissingFilesModel::dataChanged, this, &OBSMissingFiles::dataChanged);
+
+	QModelIndex index = filesModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection,
+				  Q_ARG(const QModelIndex &, index));
+}
+
+OBSMissingFiles::~OBSMissingFiles()
+{
+	obs_missing_files_destroy(fileStore);
+}
+
+void OBSMissingFiles::addMissingFile(const char *originalPath, const char *sourceName)
+{
+	QStringList list;
+
+	list.append(originalPath);
+	list.append(sourceName);
+
+	QModelIndex insertIndex = filesModel->index(filesModel->rowCount() - 1, MissingFilesColumn::Source);
+
+	filesModel->setData(insertIndex, list, MissingFilesRole::NewPathsToProcessRole);
+}
+
+void OBSMissingFiles::saveFiles()
+{
+	for (int i = 0; i < filesModel->files.length(); i++) {
+		MissingFilesState state = filesModel->files[i].state;
+		if (state != MissingFilesState::Missing) {
+			obs_missing_file_t *f = obs_missing_files_get_file(fileStore, i);
+
+			QString path = filesModel->files[i].newPath;
+
+			if (state == MissingFilesState::Cleared) {
+				obs_missing_file_issue_callback(f, "");
+			} else {
+				char *p = bstrdup(path.toStdString().c_str());
+				obs_missing_file_issue_callback(f, p);
+				bfree(p);
+			}
+		}
+	}
+
+	QDialog::accept();
+}
+
+void OBSMissingFiles::browseFolders()
+{
+	QString dir = QFileDialog::getExistingDirectory(this, QTStr("MissingFiles.SelectDir"), "",
+							QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
+
+	if (dir != "") {
+		dir += "/";
+		filesModel->fileCheckLoop(filesModel->files, dir, true);
+	}
+}
+
+void OBSMissingFiles::dataChanged()
+{
+	QString found =
+		QTStr("MissingFiles.NumFound")
+			.arg(QString::number(filesModel->found()), QString::number(obs_missing_files_count(fileStore)));
+
+	ui->found->setText(found);
+
+	ui->tableView->resizeColumnToContents(MissingFilesColumn::State);
+	ui->tableView->resizeColumnToContents(MissingFilesColumn::Source);
+}
+
+QIcon OBSMissingFiles::GetWarningIcon()
+{
+	return filesModel->warningIcon;
+}
+
+void OBSMissingFiles::SetWarningIcon(const QIcon &icon)
+{
+	ui->warningIcon->setPixmap(icon.pixmap(QSize(32, 32)));
+	filesModel->warningIcon = icon;
+}

+ 112 - 0
frontend/utility/MissingFilesPathItemDelegate.hpp

@@ -0,0 +1,112 @@
+/******************************************************************************
+    Copyright (C) 2019 by Dillon Pentz <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QPointer>
+#include <QStyledItemDelegate>
+#include "obs-app.hpp"
+#include "ui_OBSMissingFiles.h"
+
+class MissingFilesModel;
+
+enum MissingFilesState { Missing, Found, Replaced, Cleared };
+Q_DECLARE_METATYPE(MissingFilesState);
+
+class OBSMissingFiles : public QDialog {
+	Q_OBJECT
+	Q_PROPERTY(QIcon warningIcon READ GetWarningIcon WRITE SetWarningIcon DESIGNABLE true)
+
+	QPointer<MissingFilesModel> filesModel;
+	std::unique_ptr<Ui::OBSMissingFiles> ui;
+
+public:
+	explicit OBSMissingFiles(obs_missing_files_t *files, QWidget *parent = nullptr);
+	virtual ~OBSMissingFiles() override;
+
+	void addMissingFile(const char *originalPath, const char *sourceName);
+
+	QIcon GetWarningIcon();
+	void SetWarningIcon(const QIcon &icon);
+
+private:
+	void saveFiles();
+	void browseFolders();
+
+	obs_missing_files_t *fileStore;
+
+public slots:
+	void dataChanged();
+};
+
+class MissingFilesModel : public QAbstractTableModel {
+	Q_OBJECT
+
+	friend class OBSMissingFiles;
+
+public:
+	explicit MissingFilesModel(QObject *parent = 0);
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const;
+	int found() const;
+	QVariant data(const QModelIndex &index, int role) const;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const;
+	Qt::ItemFlags flags(const QModelIndex &index) const;
+	bool setData(const QModelIndex &index, const QVariant &value, int role);
+
+	bool loop = true;
+
+	QIcon warningIcon;
+
+private:
+	struct MissingFileEntry {
+		MissingFilesState state = MissingFilesState::Missing;
+
+		QString source;
+
+		QString originalPath;
+		QString newPath;
+	};
+
+	QList<MissingFileEntry> files;
+
+	void fileCheckLoop(QList<MissingFileEntry> files, QString path, bool skipPrompt);
+};
+
+class MissingFilesPathItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath);
+
+	virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+				      const QModelIndex &index) const override;
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+	virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
+	virtual void paint(QPainter *painter, const QStyleOptionViewItem &option,
+			   const QModelIndex &index) const override;
+
+private:
+	bool isOutput;
+	QString defaultPath;
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container);
+	void handleClear(QWidget *container);
+};

+ 82 - 0
frontend/utility/OBSEventFilter.hpp

@@ -0,0 +1,82 @@
+/******************************************************************************
+    Copyright (C) 2023 by Lain Bailey <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QDialog>
+#include <memory>
+#include <functional>
+
+#include <obs.hpp>
+#include <properties-view.hpp>
+
+class OBSBasic;
+
+#include "ui_OBSBasicInteraction.h"
+
+class OBSEventFilter;
+
+class OBSBasicInteraction : public QDialog {
+	Q_OBJECT
+
+private:
+	OBSBasic *main;
+
+	std::unique_ptr<Ui::OBSBasicInteraction> ui;
+	OBSSource source;
+	OBSSignal removedSignal;
+	OBSSignal renamedSignal;
+	std::unique_ptr<OBSEventFilter> eventFilter;
+
+	static void SourceRemoved(void *data, calldata_t *params);
+	static void SourceRenamed(void *data, calldata_t *params);
+	static void DrawPreview(void *data, uint32_t cx, uint32_t cy);
+
+	bool GetSourceRelativeXY(int mouseX, int mouseY, int &x, int &y);
+
+	bool HandleMouseClickEvent(QMouseEvent *event);
+	bool HandleMouseMoveEvent(QMouseEvent *event);
+	bool HandleMouseWheelEvent(QWheelEvent *event);
+	bool HandleFocusEvent(QFocusEvent *event);
+	bool HandleKeyEvent(QKeyEvent *event);
+
+	OBSEventFilter *BuildEventFilter();
+
+public:
+	OBSBasicInteraction(QWidget *parent, OBSSource source_);
+	~OBSBasicInteraction();
+
+	void Init();
+
+protected:
+	virtual void closeEvent(QCloseEvent *event) override;
+	virtual bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
+};
+
+typedef std::function<bool(QObject *, QEvent *)> EventFilterFunc;
+
+class OBSEventFilter : public QObject {
+	Q_OBJECT
+public:
+	OBSEventFilter(EventFilterFunc filter_) : filter(filter_) {}
+
+protected:
+	bool eventFilter(QObject *obj, QEvent *event) { return filter(obj, event); }
+
+public:
+	EventFilterFunc filter;
+};

+ 924 - 0
frontend/utility/RemuxEntryPathItemDelegate.cpp

@@ -0,0 +1,924 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "moc_window-remux.cpp"
+
+#include "obs-app.hpp"
+
+#include <QCloseEvent>
+#include <QDirIterator>
+#include <QItemDelegate>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QMimeData>
+#include <QPainter>
+#include <QPushButton>
+#include <QStandardItemModel>
+#include <QStyledItemDelegate>
+#include <QToolButton>
+#include <QTimer>
+#include <qt-wrappers.hpp>
+
+#include "window-basic-main.hpp"
+
+#include <memory>
+#include <cmath>
+
+using namespace std;
+
+enum RemuxEntryColumn {
+	State,
+	InputPath,
+	OutputPath,
+
+	Count
+};
+
+enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole };
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath)
+	: QStyledItemDelegate(),
+	  isOutput(isOutput),
+	  defaultPath(defaultPath)
+{
+}
+
+QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+						  const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+	if (state == RemuxEntryState::Pending || state == RemuxEntryState::InProgress) {
+		// Never allow modification of rows that are
+		// in progress.
+		return Q_NULLPTR;
+	} else if (isOutput && state != RemuxEntryState::Ready) {
+		// Do not allow modification of output rows
+		// that aren't associated with a valid input.
+		return Q_NULLPTR;
+	} else if (!isOutput && state == RemuxEntryState::Complete) {
+		// Don't allow modification of rows that are
+		// already complete.
+		return Q_NULLPTR;
+	} else {
+		QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding,
+					     QSizePolicy::ControlType::PushButton);
+
+		QWidget *container = new QWidget(parent);
+
+		auto browseCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleBrowse(container);
+		};
+
+		auto clearCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleClear(container);
+		};
+
+		QHBoxLayout *layout = new QHBoxLayout();
+		layout->setContentsMargins(0, 0, 0, 0);
+		layout->setSpacing(0);
+
+		QLineEdit *text = new QLineEdit();
+		text->setObjectName(QStringLiteral("text"));
+		text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+						QSizePolicy::ControlType::LineEdit));
+		layout->addWidget(text);
+
+		QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText);
+
+		QToolButton *browseButton = new QToolButton();
+		browseButton->setText("...");
+		browseButton->setSizePolicy(buttonSizePolicy);
+		layout->addWidget(browseButton);
+
+		container->connect(browseButton, &QToolButton::clicked, browseCallback);
+
+		// The "clear" button is not shown in output cells
+		// or the insertion point's input cell.
+		if (!isOutput && state != RemuxEntryState::Empty) {
+			QToolButton *clearButton = new QToolButton();
+			clearButton->setText("X");
+			clearButton->setSizePolicy(buttonSizePolicy);
+			layout->addWidget(clearButton);
+
+			container->connect(clearButton, &QToolButton::clicked, clearCallback);
+		}
+
+		container->setLayout(layout);
+		container->setFocusProxy(text);
+		return container;
+	}
+}
+
+void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = editor->findChild<QLineEdit *>();
+	text->setText(index.data().toString());
+	editor->setProperty(PATH_LIST_PROP, QVariant());
+}
+
+void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
+					      const QModelIndex &index) const
+{
+	// We use the PATH_LIST_PROP property to pass a list of
+	// path strings from the editor widget into the model's
+	// NewPathsToProcessRole. This is only used when paths
+	// are selected through the "browse" or "delete" buttons
+	// in the editor. If the user enters new text in the
+	// text box, we simply pass that text on to the model
+	// as normal text data in the default role.
+	QVariant pathListProp = editor->property(PATH_LIST_PROP);
+	if (pathListProp.isValid()) {
+		QStringList list = editor->property(PATH_LIST_PROP).toStringList();
+		if (isOutput) {
+			if (list.size() > 0)
+				model->setData(index, list);
+		} else
+			model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole);
+	} else {
+		QLineEdit *lineEdit = editor->findChild<QLineEdit *>();
+		model->setData(index, lineEdit->text());
+	}
+}
+
+void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+				       const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+
+	QStyleOptionViewItem localOption = option;
+	initStyleOption(&localOption, index);
+
+	if (isOutput) {
+		if (state != Ready) {
+			QColor background =
+				localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window);
+
+			localOption.backgroundBrush = QBrush(background);
+		}
+	}
+
+	QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter);
+}
+
+void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container)
+{
+	QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)";
+
+	QLineEdit *text = container->findChild<QLineEdit *>();
+
+	QString currentPath = text->text();
+	if (currentPath.isEmpty())
+		currentPath = defaultPath;
+
+	bool isSet = false;
+	if (isOutput) {
+		QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern);
+
+		if (!newPath.isEmpty()) {
+			container->setProperty(PATH_LIST_PROP, QStringList() << newPath);
+			isSet = true;
+		}
+	} else {
+		QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath,
+					      QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern);
+
+		if (!paths.empty()) {
+			container->setProperty(PATH_LIST_PROP, paths);
+			isSet = true;
+		}
+#ifdef __APPLE__
+		// TODO: Revisit when QTBUG-42661 is fixed
+		container->window()->raise();
+#endif
+	}
+
+	if (isSet)
+		emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::handleClear(QWidget *container)
+{
+	// An empty string list will indicate that the entry is being
+	// blanked and should be deleted.
+	container->setProperty(PATH_LIST_PROP, QStringList());
+
+	emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::updateText()
+{
+	QLineEdit *lineEdit = dynamic_cast<QLineEdit *>(sender());
+	QWidget *editor = lineEdit->parentWidget();
+	emit commitData(editor);
+}
+
+/**********************************************************
+  Model - Manages the queue's data
+**********************************************************/
+
+int RemuxQueueModel::rowCount(const QModelIndex &) const
+{
+	return queue.length() + (isProcessing ? 0 : 1);
+}
+
+int RemuxQueueModel::columnCount(const QModelIndex &) const
+{
+	return RemuxEntryColumn::Count;
+}
+
+QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const
+{
+	QVariant result = QVariant();
+
+	if (index.row() >= queue.length()) {
+		return QVariant();
+	} else if (role == Qt::DisplayRole) {
+		switch (index.column()) {
+		case RemuxEntryColumn::InputPath:
+			result = queue[index.row()].sourcePath;
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = queue[index.row()].targetPath;
+			break;
+		}
+	} else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) {
+		result = getIcon(queue[index.row()].state);
+	} else if (role == RemuxEntryRole::EntryStateRole) {
+		result = queue[index.row()].state;
+	}
+
+	return result;
+}
+
+QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	QVariant result = QVariant();
+
+	if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case RemuxEntryColumn::State:
+			result = QString();
+			break;
+		case RemuxEntryColumn::InputPath:
+			result = QTStr("Remux.SourceFile");
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = QTStr("Remux.TargetFile");
+			break;
+		}
+	}
+
+	return result;
+}
+
+Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() == RemuxEntryColumn::InputPath) {
+		flags |= Qt::ItemIsEditable;
+	} else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) {
+		flags |= Qt::ItemIsEditable;
+	}
+
+	return flags;
+}
+
+bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+	bool success = false;
+
+	if (role == RemuxEntryRole::NewPathsToProcessRole) {
+		QStringList pathList = value.toStringList();
+
+		if (pathList.size() == 0) {
+			if (index.row() < queue.size()) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			if (pathList.size() >= 1 && index.row() < queue.length()) {
+				queue[index.row()].sourcePath = pathList[0];
+				checkInputPath(index.row());
+
+				pathList.removeAt(0);
+
+				success = true;
+			}
+
+			if (pathList.size() > 0) {
+				int row = index.row();
+				int lastRow = row + pathList.size() - 1;
+				beginInsertRows(QModelIndex(), row, lastRow);
+
+				for (QString path : pathList) {
+					RemuxQueueEntry entry;
+					entry.sourcePath = path;
+					entry.state = RemuxEntryState::Empty;
+
+					queue.insert(row, entry);
+					row++;
+				}
+				endInsertRows();
+
+				for (row = index.row(); row <= lastRow; row++) {
+					checkInputPath(row);
+				}
+
+				success = true;
+			}
+		}
+	} else if (index.row() == queue.length()) {
+		QString path = value.toString();
+
+		if (!path.isEmpty()) {
+			RemuxQueueEntry entry;
+			entry.sourcePath = path;
+			entry.state = RemuxEntryState::Empty;
+
+			beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1);
+			queue.append(entry);
+			endInsertRows();
+
+			checkInputPath(index.row());
+			success = true;
+		}
+	} else {
+		QString path = value.toString();
+
+		if (path.isEmpty()) {
+			if (index.column() == RemuxEntryColumn::InputPath) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			switch (index.column()) {
+			case RemuxEntryColumn::InputPath:
+				queue[index.row()].sourcePath = value.toString();
+				checkInputPath(index.row());
+				success = true;
+				break;
+			case RemuxEntryColumn::OutputPath:
+				queue[index.row()].targetPath = value.toString();
+				emit dataChanged(index, index);
+				success = true;
+				break;
+			}
+		}
+	}
+
+	return success;
+}
+
+QVariant RemuxQueueModel::getIcon(RemuxEntryState state)
+{
+	QVariant icon;
+	QStyle *style = QApplication::style();
+
+	switch (state) {
+	case RemuxEntryState::Complete:
+		icon = style->standardIcon(QStyle::SP_DialogApplyButton);
+		break;
+
+	case RemuxEntryState::InProgress:
+		icon = style->standardIcon(QStyle::SP_ArrowRight);
+		break;
+
+	case RemuxEntryState::Error:
+		icon = style->standardIcon(QStyle::SP_DialogCancelButton);
+		break;
+
+	case RemuxEntryState::InvalidPath:
+		icon = style->standardIcon(QStyle::SP_MessageBoxWarning);
+		break;
+
+	default:
+		break;
+	}
+
+	return icon;
+}
+
+void RemuxQueueModel::checkInputPath(int row)
+{
+	RemuxQueueEntry &entry = queue[row];
+
+	if (entry.sourcePath.isEmpty()) {
+		entry.state = RemuxEntryState::Empty;
+	} else {
+		entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath);
+		QFileInfo fileInfo(entry.sourcePath);
+		if (fileInfo.exists())
+			entry.state = RemuxEntryState::Ready;
+		else
+			entry.state = RemuxEntryState::InvalidPath;
+
+		QString newExt = ".mp4";
+		QString suffix = fileInfo.suffix();
+
+		if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) {
+			newExt = ".remuxed." + suffix;
+		}
+
+		if (entry.state == RemuxEntryState::Ready)
+			entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() +
+								    fileInfo.completeBaseName() + newExt);
+	}
+
+	if (entry.state == RemuxEntryState::Ready && isProcessing)
+		entry.state = RemuxEntryState::Pending;
+
+	emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count));
+}
+
+QFileInfoList RemuxQueueModel::checkForOverwrites() const
+{
+	QFileInfoList list;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Ready) {
+			QFileInfo fileInfo(entry.targetPath);
+			if (fileInfo.exists()) {
+				list.append(fileInfo);
+			}
+		}
+	}
+
+	return list;
+}
+
+bool RemuxQueueModel::checkForErrors() const
+{
+	bool hasErrors = false;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Error) {
+			hasErrors = true;
+			break;
+		}
+	}
+
+	return hasErrors;
+}
+
+void RemuxQueueModel::clearAll()
+{
+	beginRemoveRows(QModelIndex(), 0, queue.size() - 1);
+	queue.clear();
+	endRemoveRows();
+}
+
+void RemuxQueueModel::clearFinished()
+{
+	int index = 0;
+
+	for (index = 0; index < queue.size(); index++) {
+		const RemuxQueueEntry &entry = queue[index];
+		if (entry.state == RemuxEntryState::Complete) {
+			beginRemoveRows(QModelIndex(), index, index);
+			queue.removeAt(index);
+			endRemoveRows();
+			index--;
+		}
+	}
+}
+
+bool RemuxQueueModel::canClearFinished() const
+{
+	bool canClearFinished = false;
+	for (const RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Complete) {
+			canClearFinished = true;
+			break;
+		}
+
+	return canClearFinished;
+}
+
+void RemuxQueueModel::beginProcessing()
+{
+	for (RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Ready)
+			entry.state = RemuxEntryState::Pending;
+
+	// Signal that the insertion point no longer exists.
+	beginRemoveRows(QModelIndex(), queue.length(), queue.length());
+	endRemoveRows();
+
+	isProcessing = true;
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+void RemuxQueueModel::endProcessing()
+{
+	for (RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::Ready;
+		}
+	}
+
+	// Signal that the insertion point exists again.
+	isProcessing = false;
+	if (!autoRemux) {
+		beginInsertRows(QModelIndex(), queue.length(), queue.length());
+		endInsertRows();
+	}
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath)
+{
+	bool anyStarted = false;
+
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::InProgress;
+
+			inputPath = entry.sourcePath;
+			outputPath = entry.targetPath;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			anyStarted = true;
+			break;
+		}
+	}
+
+	return anyStarted;
+}
+
+void RemuxQueueModel::finishEntry(bool success)
+{
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::InProgress) {
+			if (success)
+				entry.state = RemuxEntryState::Complete;
+			else
+				entry.state = RemuxEntryState::Error;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			break;
+		}
+	}
+}
+
+/**********************************************************
+  The actual remux window implementation
+**********************************************************/
+
+OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_)
+	: QDialog(parent),
+	  queueModel(new RemuxQueueModel),
+	  worker(new RemuxWorker()),
+	  ui(new Ui::OBSRemux),
+	  recPath(path),
+	  autoRemux(autoRemux_)
+{
+	setAcceptDrops(true);
+
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	ui->progressBar->setVisible(false);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+
+	if (autoRemux) {
+		resize(280, 40);
+		ui->tableView->hide();
+		ui->buttonBox->hide();
+		ui->label->hide();
+	}
+
+	ui->progressBar->setMinimum(0);
+	ui->progressBar->setMaximum(1000);
+	ui->progressBar->setValue(0);
+
+	ui->tableView->setModel(queueModel);
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath,
+						new RemuxEntryPathItemDelegate(false, recPath));
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath,
+						new RemuxEntryPathItemDelegate(true, recPath));
+	ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State,
+								QHeaderView::ResizeMode::Fixed);
+	ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+	ui->tableView->setTextElideMode(Qt::ElideMiddle);
+	ui->tableView->setWordWrap(false);
+
+	installEventFilter(CreateShortcutFilter());
+
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished"));
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true);
+
+	connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux);
+	connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished);
+	connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this,
+		&OBSRemux::clearAll);
+	connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close);
+
+	worker->moveToThread(&remuxer);
+	remuxer.start();
+
+	connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress);
+	connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater);
+	connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished);
+	connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux);
+
+	connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged);
+	connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged);
+
+	QModelIndex index = queueModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection,
+				  Q_ARG(const QModelIndex &, index));
+}
+
+bool OBSRemux::stopRemux()
+{
+	if (!worker->isWorking)
+		return true;
+
+	// By locking the worker thread's mutex, we ensure that its
+	// update poll will be blocked as long as we're in here with
+	// the popup open.
+	QMutexLocker lock(&worker->updateMutex);
+
+	bool exit = false;
+
+	if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"),
+				  QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
+		exit = true;
+	}
+
+	if (exit) {
+		// Inform the worker it should no longer be
+		// working. It will interrupt accordingly in
+		// its next update callback.
+		worker->isWorking = false;
+	}
+
+	return exit;
+}
+
+OBSRemux::~OBSRemux()
+{
+	stopRemux();
+	remuxer.quit();
+	remuxer.wait();
+}
+
+void OBSRemux::rowCountChanged(const QModelIndex &, int, int)
+{
+	// See if there are still any rows ready to remux. Change
+	// the state of the "go" button accordingly.
+	// There must be more than one row, since there will always be
+	// at least one row for the empty insertion point.
+	if (queueModel->rowCount() > 1) {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+	} else {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false);
+	}
+}
+
+void OBSRemux::dropEvent(QDropEvent *ev)
+{
+	QStringList urlList;
+
+	for (QUrl url : ev->mimeData()->urls()) {
+		QFileInfo fileInfo(url.toLocalFile());
+
+		if (fileInfo.isDir()) {
+			QStringList directoryFilter;
+			directoryFilter << "*.flv"
+					<< "*.mp4"
+					<< "*.mov"
+					<< "*.mkv"
+					<< "*.ts"
+					<< "*.m3u8";
+
+			QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files,
+					     QDirIterator::Subdirectories);
+
+			while (dirIter.hasNext()) {
+				urlList.append(dirIter.next());
+			}
+		} else {
+			urlList.append(fileInfo.canonicalFilePath());
+		}
+	}
+
+	if (urlList.empty()) {
+		QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"),
+					 QMessageBox::Ok);
+	} else if (!autoRemux) {
+		QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath);
+		queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole);
+	}
+}
+
+void OBSRemux::dragEnterEvent(QDragEnterEvent *ev)
+{
+	if (ev->mimeData()->hasUrls() && !worker->isWorking)
+		ev->accept();
+}
+
+void OBSRemux::beginRemux()
+{
+	if (worker->isWorking) {
+		stopRemux();
+		return;
+	}
+
+	bool proceedWithRemux = true;
+	QFileInfoList overwriteFiles = queueModel->checkForOverwrites();
+
+	if (!overwriteFiles.empty()) {
+		QString message = QTStr("Remux.FileExists");
+		message += "\n\n";
+
+		for (QFileInfo fileInfo : overwriteFiles)
+			message += fileInfo.canonicalFilePath() + "\n";
+
+		if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes)
+			proceedWithRemux = false;
+	}
+
+	if (!proceedWithRemux)
+		return;
+
+	// Set all jobs to "pending" first.
+	queueModel->beginProcessing();
+
+	ui->progressBar->setVisible(true);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop"));
+	setAcceptDrops(false);
+
+	remuxNextEntry();
+}
+
+void OBSRemux::AutoRemux(QString inFile, QString outFile)
+{
+	if (inFile != "" && outFile != "" && autoRemux) {
+		ui->progressBar->setVisible(true);
+		emit remux(inFile, outFile);
+		autoRemuxFile = outFile;
+	}
+}
+
+void OBSRemux::remuxNextEntry()
+{
+	worker->lastProgress = 0.f;
+
+	QString inputPath, outputPath;
+	if (queueModel->beginNextEntry(inputPath, outputPath)) {
+		emit remux(inputPath, outputPath);
+	} else {
+		queueModel->autoRemux = autoRemux;
+		queueModel->endProcessing();
+
+		if (!autoRemux) {
+			OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"),
+						   queueModel->checkForErrors() ? QTStr("Remux.FinishedError")
+										: QTStr("Remux.Finished"));
+		}
+
+		ui->progressBar->setVisible(autoRemux);
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+		setAcceptDrops(true);
+	}
+}
+
+void OBSRemux::closeEvent(QCloseEvent *event)
+{
+	if (!stopRemux())
+		event->ignore();
+	else
+		QDialog::closeEvent(event);
+}
+
+void OBSRemux::reject()
+{
+	if (!stopRemux())
+		return;
+
+	QDialog::reject();
+}
+
+void OBSRemux::updateProgress(float percent)
+{
+	ui->progressBar->setValue(percent * 10);
+}
+
+void OBSRemux::remuxFinished(bool success)
+{
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+
+	queueModel->finishEntry(success);
+
+	if (autoRemux && autoRemuxFile != "") {
+		QTimer::singleShot(3000, this, &OBSRemux::close);
+
+		OBSBasic *main = OBSBasic::Get();
+		main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile));
+	}
+
+	remuxNextEntry();
+}
+
+void OBSRemux::clearFinished()
+{
+	queueModel->clearFinished();
+}
+
+void OBSRemux::clearAll()
+{
+	queueModel->clearAll();
+}
+
+/**********************************************************
+  Worker thread - Executes the libobs remux operation as a
+                  background process.
+**********************************************************/
+
+void RemuxWorker::UpdateProgress(float percent)
+{
+	if (abs(lastProgress - percent) < 0.1f)
+		return;
+
+	emit updateProgress(percent);
+	lastProgress = percent;
+}
+
+void RemuxWorker::remux(const QString &source, const QString &target)
+{
+	isWorking = true;
+
+	auto callback = [](void *data, float percent) {
+		RemuxWorker *rw = static_cast<RemuxWorker *>(data);
+
+		QMutexLocker lock(&rw->updateMutex);
+
+		rw->UpdateProgress(percent);
+
+		return rw->isWorking;
+	};
+
+	bool stopped = false;
+	bool success = false;
+
+	media_remux_job_t mr_job = nullptr;
+	if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) {
+
+		success = media_remux_job_process(mr_job, callback, this);
+
+		media_remux_job_destroy(mr_job);
+
+		stopped = !isWorking;
+	}
+
+	isWorking = false;
+
+	emit remuxFinished(!stopped && success);
+}

+ 173 - 0
frontend/utility/RemuxEntryPathItemDelegate.hpp

@@ -0,0 +1,173 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFileInfo>
+#include <QMutex>
+#include <QPointer>
+#include <QThread>
+#include <QStyledItemDelegate>
+#include <memory>
+#include "ui_OBSRemux.h"
+
+#include <media-io/media-remux.h>
+#include <util/threading.h>
+
+class RemuxQueueModel;
+class RemuxWorker;
+
+enum RemuxEntryState { Empty, Ready, Pending, InProgress, Complete, InvalidPath, Error };
+Q_DECLARE_METATYPE(RemuxEntryState);
+
+class OBSRemux : public QDialog {
+	Q_OBJECT
+
+	QPointer<RemuxQueueModel> queueModel;
+	QThread remuxer;
+	QPointer<RemuxWorker> worker;
+
+	std::unique_ptr<Ui::OBSRemux> ui;
+
+	const char *recPath;
+
+	virtual void closeEvent(QCloseEvent *event) override;
+	virtual void reject() override;
+
+	bool autoRemux;
+	QString autoRemuxFile;
+
+public:
+	explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false);
+	virtual ~OBSRemux() override;
+
+	using job_t = std::shared_ptr<struct media_remux_job>;
+
+	void AutoRemux(QString inFile, QString outFile);
+
+protected:
+	virtual void dropEvent(QDropEvent *ev) override;
+	virtual void dragEnterEvent(QDragEnterEvent *ev) override;
+
+	void remuxNextEntry();
+
+private slots:
+	void rowCountChanged(const QModelIndex &parent, int first, int last);
+
+public slots:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+	void beginRemux();
+	bool stopRemux();
+	void clearFinished();
+	void clearAll();
+
+signals:
+	void remux(const QString &source, const QString &target);
+};
+
+class RemuxQueueModel : public QAbstractTableModel {
+	Q_OBJECT
+
+	friend class OBSRemux;
+
+public:
+	RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {}
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const;
+	QVariant data(const QModelIndex &index, int role) const;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const;
+	Qt::ItemFlags flags(const QModelIndex &index) const;
+	bool setData(const QModelIndex &index, const QVariant &value, int role);
+
+	QFileInfoList checkForOverwrites() const;
+	bool checkForErrors() const;
+	void beginProcessing();
+	void endProcessing();
+	bool beginNextEntry(QString &inputPath, QString &outputPath);
+	void finishEntry(bool success);
+	bool canClearFinished() const;
+	void clearFinished();
+	void clearAll();
+
+	bool autoRemux = false;
+
+private:
+	struct RemuxQueueEntry {
+		RemuxEntryState state;
+
+		QString sourcePath;
+		QString targetPath;
+	};
+
+	QList<RemuxQueueEntry> queue;
+	bool isProcessing;
+
+	static QVariant getIcon(RemuxEntryState state);
+
+	void checkInputPath(int row);
+};
+
+class RemuxWorker : public QObject {
+	Q_OBJECT
+
+	QMutex updateMutex;
+
+	bool isWorking;
+
+	float lastProgress;
+	void UpdateProgress(float percent);
+
+	explicit RemuxWorker() : isWorking(false) {}
+	virtual ~RemuxWorker(){};
+
+private slots:
+	void remux(const QString &source, const QString &target);
+
+signals:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+
+	friend class OBSRemux;
+};
+
+class RemuxEntryPathItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath);
+
+	virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+				      const QModelIndex &index) const override;
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+	virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
+	virtual void paint(QPainter *painter, const QStyleOptionViewItem &option,
+			   const QModelIndex &index) const override;
+
+private:
+	bool isOutput;
+	QString defaultPath;
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container);
+	void handleClear(QWidget *container);
+
+private slots:
+	void updateText();
+};

+ 924 - 0
frontend/utility/RemuxQueueModel.cpp

@@ -0,0 +1,924 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "moc_window-remux.cpp"
+
+#include "obs-app.hpp"
+
+#include <QCloseEvent>
+#include <QDirIterator>
+#include <QItemDelegate>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QMimeData>
+#include <QPainter>
+#include <QPushButton>
+#include <QStandardItemModel>
+#include <QStyledItemDelegate>
+#include <QToolButton>
+#include <QTimer>
+#include <qt-wrappers.hpp>
+
+#include "window-basic-main.hpp"
+
+#include <memory>
+#include <cmath>
+
+using namespace std;
+
+enum RemuxEntryColumn {
+	State,
+	InputPath,
+	OutputPath,
+
+	Count
+};
+
+enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole };
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath)
+	: QStyledItemDelegate(),
+	  isOutput(isOutput),
+	  defaultPath(defaultPath)
+{
+}
+
+QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+						  const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+	if (state == RemuxEntryState::Pending || state == RemuxEntryState::InProgress) {
+		// Never allow modification of rows that are
+		// in progress.
+		return Q_NULLPTR;
+	} else if (isOutput && state != RemuxEntryState::Ready) {
+		// Do not allow modification of output rows
+		// that aren't associated with a valid input.
+		return Q_NULLPTR;
+	} else if (!isOutput && state == RemuxEntryState::Complete) {
+		// Don't allow modification of rows that are
+		// already complete.
+		return Q_NULLPTR;
+	} else {
+		QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding,
+					     QSizePolicy::ControlType::PushButton);
+
+		QWidget *container = new QWidget(parent);
+
+		auto browseCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleBrowse(container);
+		};
+
+		auto clearCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleClear(container);
+		};
+
+		QHBoxLayout *layout = new QHBoxLayout();
+		layout->setContentsMargins(0, 0, 0, 0);
+		layout->setSpacing(0);
+
+		QLineEdit *text = new QLineEdit();
+		text->setObjectName(QStringLiteral("text"));
+		text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+						QSizePolicy::ControlType::LineEdit));
+		layout->addWidget(text);
+
+		QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText);
+
+		QToolButton *browseButton = new QToolButton();
+		browseButton->setText("...");
+		browseButton->setSizePolicy(buttonSizePolicy);
+		layout->addWidget(browseButton);
+
+		container->connect(browseButton, &QToolButton::clicked, browseCallback);
+
+		// The "clear" button is not shown in output cells
+		// or the insertion point's input cell.
+		if (!isOutput && state != RemuxEntryState::Empty) {
+			QToolButton *clearButton = new QToolButton();
+			clearButton->setText("X");
+			clearButton->setSizePolicy(buttonSizePolicy);
+			layout->addWidget(clearButton);
+
+			container->connect(clearButton, &QToolButton::clicked, clearCallback);
+		}
+
+		container->setLayout(layout);
+		container->setFocusProxy(text);
+		return container;
+	}
+}
+
+void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = editor->findChild<QLineEdit *>();
+	text->setText(index.data().toString());
+	editor->setProperty(PATH_LIST_PROP, QVariant());
+}
+
+void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
+					      const QModelIndex &index) const
+{
+	// We use the PATH_LIST_PROP property to pass a list of
+	// path strings from the editor widget into the model's
+	// NewPathsToProcessRole. This is only used when paths
+	// are selected through the "browse" or "delete" buttons
+	// in the editor. If the user enters new text in the
+	// text box, we simply pass that text on to the model
+	// as normal text data in the default role.
+	QVariant pathListProp = editor->property(PATH_LIST_PROP);
+	if (pathListProp.isValid()) {
+		QStringList list = editor->property(PATH_LIST_PROP).toStringList();
+		if (isOutput) {
+			if (list.size() > 0)
+				model->setData(index, list);
+		} else
+			model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole);
+	} else {
+		QLineEdit *lineEdit = editor->findChild<QLineEdit *>();
+		model->setData(index, lineEdit->text());
+	}
+}
+
+void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+				       const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+
+	QStyleOptionViewItem localOption = option;
+	initStyleOption(&localOption, index);
+
+	if (isOutput) {
+		if (state != Ready) {
+			QColor background =
+				localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window);
+
+			localOption.backgroundBrush = QBrush(background);
+		}
+	}
+
+	QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter);
+}
+
+void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container)
+{
+	QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)";
+
+	QLineEdit *text = container->findChild<QLineEdit *>();
+
+	QString currentPath = text->text();
+	if (currentPath.isEmpty())
+		currentPath = defaultPath;
+
+	bool isSet = false;
+	if (isOutput) {
+		QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern);
+
+		if (!newPath.isEmpty()) {
+			container->setProperty(PATH_LIST_PROP, QStringList() << newPath);
+			isSet = true;
+		}
+	} else {
+		QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath,
+					      QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern);
+
+		if (!paths.empty()) {
+			container->setProperty(PATH_LIST_PROP, paths);
+			isSet = true;
+		}
+#ifdef __APPLE__
+		// TODO: Revisit when QTBUG-42661 is fixed
+		container->window()->raise();
+#endif
+	}
+
+	if (isSet)
+		emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::handleClear(QWidget *container)
+{
+	// An empty string list will indicate that the entry is being
+	// blanked and should be deleted.
+	container->setProperty(PATH_LIST_PROP, QStringList());
+
+	emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::updateText()
+{
+	QLineEdit *lineEdit = dynamic_cast<QLineEdit *>(sender());
+	QWidget *editor = lineEdit->parentWidget();
+	emit commitData(editor);
+}
+
+/**********************************************************
+  Model - Manages the queue's data
+**********************************************************/
+
+int RemuxQueueModel::rowCount(const QModelIndex &) const
+{
+	return queue.length() + (isProcessing ? 0 : 1);
+}
+
+int RemuxQueueModel::columnCount(const QModelIndex &) const
+{
+	return RemuxEntryColumn::Count;
+}
+
+QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const
+{
+	QVariant result = QVariant();
+
+	if (index.row() >= queue.length()) {
+		return QVariant();
+	} else if (role == Qt::DisplayRole) {
+		switch (index.column()) {
+		case RemuxEntryColumn::InputPath:
+			result = queue[index.row()].sourcePath;
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = queue[index.row()].targetPath;
+			break;
+		}
+	} else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) {
+		result = getIcon(queue[index.row()].state);
+	} else if (role == RemuxEntryRole::EntryStateRole) {
+		result = queue[index.row()].state;
+	}
+
+	return result;
+}
+
+QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	QVariant result = QVariant();
+
+	if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case RemuxEntryColumn::State:
+			result = QString();
+			break;
+		case RemuxEntryColumn::InputPath:
+			result = QTStr("Remux.SourceFile");
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = QTStr("Remux.TargetFile");
+			break;
+		}
+	}
+
+	return result;
+}
+
+Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() == RemuxEntryColumn::InputPath) {
+		flags |= Qt::ItemIsEditable;
+	} else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) {
+		flags |= Qt::ItemIsEditable;
+	}
+
+	return flags;
+}
+
+bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+	bool success = false;
+
+	if (role == RemuxEntryRole::NewPathsToProcessRole) {
+		QStringList pathList = value.toStringList();
+
+		if (pathList.size() == 0) {
+			if (index.row() < queue.size()) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			if (pathList.size() >= 1 && index.row() < queue.length()) {
+				queue[index.row()].sourcePath = pathList[0];
+				checkInputPath(index.row());
+
+				pathList.removeAt(0);
+
+				success = true;
+			}
+
+			if (pathList.size() > 0) {
+				int row = index.row();
+				int lastRow = row + pathList.size() - 1;
+				beginInsertRows(QModelIndex(), row, lastRow);
+
+				for (QString path : pathList) {
+					RemuxQueueEntry entry;
+					entry.sourcePath = path;
+					entry.state = RemuxEntryState::Empty;
+
+					queue.insert(row, entry);
+					row++;
+				}
+				endInsertRows();
+
+				for (row = index.row(); row <= lastRow; row++) {
+					checkInputPath(row);
+				}
+
+				success = true;
+			}
+		}
+	} else if (index.row() == queue.length()) {
+		QString path = value.toString();
+
+		if (!path.isEmpty()) {
+			RemuxQueueEntry entry;
+			entry.sourcePath = path;
+			entry.state = RemuxEntryState::Empty;
+
+			beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1);
+			queue.append(entry);
+			endInsertRows();
+
+			checkInputPath(index.row());
+			success = true;
+		}
+	} else {
+		QString path = value.toString();
+
+		if (path.isEmpty()) {
+			if (index.column() == RemuxEntryColumn::InputPath) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			switch (index.column()) {
+			case RemuxEntryColumn::InputPath:
+				queue[index.row()].sourcePath = value.toString();
+				checkInputPath(index.row());
+				success = true;
+				break;
+			case RemuxEntryColumn::OutputPath:
+				queue[index.row()].targetPath = value.toString();
+				emit dataChanged(index, index);
+				success = true;
+				break;
+			}
+		}
+	}
+
+	return success;
+}
+
+QVariant RemuxQueueModel::getIcon(RemuxEntryState state)
+{
+	QVariant icon;
+	QStyle *style = QApplication::style();
+
+	switch (state) {
+	case RemuxEntryState::Complete:
+		icon = style->standardIcon(QStyle::SP_DialogApplyButton);
+		break;
+
+	case RemuxEntryState::InProgress:
+		icon = style->standardIcon(QStyle::SP_ArrowRight);
+		break;
+
+	case RemuxEntryState::Error:
+		icon = style->standardIcon(QStyle::SP_DialogCancelButton);
+		break;
+
+	case RemuxEntryState::InvalidPath:
+		icon = style->standardIcon(QStyle::SP_MessageBoxWarning);
+		break;
+
+	default:
+		break;
+	}
+
+	return icon;
+}
+
+void RemuxQueueModel::checkInputPath(int row)
+{
+	RemuxQueueEntry &entry = queue[row];
+
+	if (entry.sourcePath.isEmpty()) {
+		entry.state = RemuxEntryState::Empty;
+	} else {
+		entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath);
+		QFileInfo fileInfo(entry.sourcePath);
+		if (fileInfo.exists())
+			entry.state = RemuxEntryState::Ready;
+		else
+			entry.state = RemuxEntryState::InvalidPath;
+
+		QString newExt = ".mp4";
+		QString suffix = fileInfo.suffix();
+
+		if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) {
+			newExt = ".remuxed." + suffix;
+		}
+
+		if (entry.state == RemuxEntryState::Ready)
+			entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() +
+								    fileInfo.completeBaseName() + newExt);
+	}
+
+	if (entry.state == RemuxEntryState::Ready && isProcessing)
+		entry.state = RemuxEntryState::Pending;
+
+	emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count));
+}
+
+QFileInfoList RemuxQueueModel::checkForOverwrites() const
+{
+	QFileInfoList list;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Ready) {
+			QFileInfo fileInfo(entry.targetPath);
+			if (fileInfo.exists()) {
+				list.append(fileInfo);
+			}
+		}
+	}
+
+	return list;
+}
+
+bool RemuxQueueModel::checkForErrors() const
+{
+	bool hasErrors = false;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Error) {
+			hasErrors = true;
+			break;
+		}
+	}
+
+	return hasErrors;
+}
+
+void RemuxQueueModel::clearAll()
+{
+	beginRemoveRows(QModelIndex(), 0, queue.size() - 1);
+	queue.clear();
+	endRemoveRows();
+}
+
+void RemuxQueueModel::clearFinished()
+{
+	int index = 0;
+
+	for (index = 0; index < queue.size(); index++) {
+		const RemuxQueueEntry &entry = queue[index];
+		if (entry.state == RemuxEntryState::Complete) {
+			beginRemoveRows(QModelIndex(), index, index);
+			queue.removeAt(index);
+			endRemoveRows();
+			index--;
+		}
+	}
+}
+
+bool RemuxQueueModel::canClearFinished() const
+{
+	bool canClearFinished = false;
+	for (const RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Complete) {
+			canClearFinished = true;
+			break;
+		}
+
+	return canClearFinished;
+}
+
+void RemuxQueueModel::beginProcessing()
+{
+	for (RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Ready)
+			entry.state = RemuxEntryState::Pending;
+
+	// Signal that the insertion point no longer exists.
+	beginRemoveRows(QModelIndex(), queue.length(), queue.length());
+	endRemoveRows();
+
+	isProcessing = true;
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+void RemuxQueueModel::endProcessing()
+{
+	for (RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::Ready;
+		}
+	}
+
+	// Signal that the insertion point exists again.
+	isProcessing = false;
+	if (!autoRemux) {
+		beginInsertRows(QModelIndex(), queue.length(), queue.length());
+		endInsertRows();
+	}
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath)
+{
+	bool anyStarted = false;
+
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::InProgress;
+
+			inputPath = entry.sourcePath;
+			outputPath = entry.targetPath;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			anyStarted = true;
+			break;
+		}
+	}
+
+	return anyStarted;
+}
+
+void RemuxQueueModel::finishEntry(bool success)
+{
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::InProgress) {
+			if (success)
+				entry.state = RemuxEntryState::Complete;
+			else
+				entry.state = RemuxEntryState::Error;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			break;
+		}
+	}
+}
+
+/**********************************************************
+  The actual remux window implementation
+**********************************************************/
+
+OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_)
+	: QDialog(parent),
+	  queueModel(new RemuxQueueModel),
+	  worker(new RemuxWorker()),
+	  ui(new Ui::OBSRemux),
+	  recPath(path),
+	  autoRemux(autoRemux_)
+{
+	setAcceptDrops(true);
+
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	ui->progressBar->setVisible(false);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+
+	if (autoRemux) {
+		resize(280, 40);
+		ui->tableView->hide();
+		ui->buttonBox->hide();
+		ui->label->hide();
+	}
+
+	ui->progressBar->setMinimum(0);
+	ui->progressBar->setMaximum(1000);
+	ui->progressBar->setValue(0);
+
+	ui->tableView->setModel(queueModel);
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath,
+						new RemuxEntryPathItemDelegate(false, recPath));
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath,
+						new RemuxEntryPathItemDelegate(true, recPath));
+	ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State,
+								QHeaderView::ResizeMode::Fixed);
+	ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+	ui->tableView->setTextElideMode(Qt::ElideMiddle);
+	ui->tableView->setWordWrap(false);
+
+	installEventFilter(CreateShortcutFilter());
+
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished"));
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true);
+
+	connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux);
+	connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished);
+	connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this,
+		&OBSRemux::clearAll);
+	connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close);
+
+	worker->moveToThread(&remuxer);
+	remuxer.start();
+
+	connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress);
+	connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater);
+	connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished);
+	connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux);
+
+	connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged);
+	connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged);
+
+	QModelIndex index = queueModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection,
+				  Q_ARG(const QModelIndex &, index));
+}
+
+bool OBSRemux::stopRemux()
+{
+	if (!worker->isWorking)
+		return true;
+
+	// By locking the worker thread's mutex, we ensure that its
+	// update poll will be blocked as long as we're in here with
+	// the popup open.
+	QMutexLocker lock(&worker->updateMutex);
+
+	bool exit = false;
+
+	if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"),
+				  QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
+		exit = true;
+	}
+
+	if (exit) {
+		// Inform the worker it should no longer be
+		// working. It will interrupt accordingly in
+		// its next update callback.
+		worker->isWorking = false;
+	}
+
+	return exit;
+}
+
+OBSRemux::~OBSRemux()
+{
+	stopRemux();
+	remuxer.quit();
+	remuxer.wait();
+}
+
+void OBSRemux::rowCountChanged(const QModelIndex &, int, int)
+{
+	// See if there are still any rows ready to remux. Change
+	// the state of the "go" button accordingly.
+	// There must be more than one row, since there will always be
+	// at least one row for the empty insertion point.
+	if (queueModel->rowCount() > 1) {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+	} else {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false);
+	}
+}
+
+void OBSRemux::dropEvent(QDropEvent *ev)
+{
+	QStringList urlList;
+
+	for (QUrl url : ev->mimeData()->urls()) {
+		QFileInfo fileInfo(url.toLocalFile());
+
+		if (fileInfo.isDir()) {
+			QStringList directoryFilter;
+			directoryFilter << "*.flv"
+					<< "*.mp4"
+					<< "*.mov"
+					<< "*.mkv"
+					<< "*.ts"
+					<< "*.m3u8";
+
+			QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files,
+					     QDirIterator::Subdirectories);
+
+			while (dirIter.hasNext()) {
+				urlList.append(dirIter.next());
+			}
+		} else {
+			urlList.append(fileInfo.canonicalFilePath());
+		}
+	}
+
+	if (urlList.empty()) {
+		QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"),
+					 QMessageBox::Ok);
+	} else if (!autoRemux) {
+		QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath);
+		queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole);
+	}
+}
+
+void OBSRemux::dragEnterEvent(QDragEnterEvent *ev)
+{
+	if (ev->mimeData()->hasUrls() && !worker->isWorking)
+		ev->accept();
+}
+
+void OBSRemux::beginRemux()
+{
+	if (worker->isWorking) {
+		stopRemux();
+		return;
+	}
+
+	bool proceedWithRemux = true;
+	QFileInfoList overwriteFiles = queueModel->checkForOverwrites();
+
+	if (!overwriteFiles.empty()) {
+		QString message = QTStr("Remux.FileExists");
+		message += "\n\n";
+
+		for (QFileInfo fileInfo : overwriteFiles)
+			message += fileInfo.canonicalFilePath() + "\n";
+
+		if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes)
+			proceedWithRemux = false;
+	}
+
+	if (!proceedWithRemux)
+		return;
+
+	// Set all jobs to "pending" first.
+	queueModel->beginProcessing();
+
+	ui->progressBar->setVisible(true);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop"));
+	setAcceptDrops(false);
+
+	remuxNextEntry();
+}
+
+void OBSRemux::AutoRemux(QString inFile, QString outFile)
+{
+	if (inFile != "" && outFile != "" && autoRemux) {
+		ui->progressBar->setVisible(true);
+		emit remux(inFile, outFile);
+		autoRemuxFile = outFile;
+	}
+}
+
+void OBSRemux::remuxNextEntry()
+{
+	worker->lastProgress = 0.f;
+
+	QString inputPath, outputPath;
+	if (queueModel->beginNextEntry(inputPath, outputPath)) {
+		emit remux(inputPath, outputPath);
+	} else {
+		queueModel->autoRemux = autoRemux;
+		queueModel->endProcessing();
+
+		if (!autoRemux) {
+			OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"),
+						   queueModel->checkForErrors() ? QTStr("Remux.FinishedError")
+										: QTStr("Remux.Finished"));
+		}
+
+		ui->progressBar->setVisible(autoRemux);
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+		setAcceptDrops(true);
+	}
+}
+
+void OBSRemux::closeEvent(QCloseEvent *event)
+{
+	if (!stopRemux())
+		event->ignore();
+	else
+		QDialog::closeEvent(event);
+}
+
+void OBSRemux::reject()
+{
+	if (!stopRemux())
+		return;
+
+	QDialog::reject();
+}
+
+void OBSRemux::updateProgress(float percent)
+{
+	ui->progressBar->setValue(percent * 10);
+}
+
+void OBSRemux::remuxFinished(bool success)
+{
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+
+	queueModel->finishEntry(success);
+
+	if (autoRemux && autoRemuxFile != "") {
+		QTimer::singleShot(3000, this, &OBSRemux::close);
+
+		OBSBasic *main = OBSBasic::Get();
+		main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile));
+	}
+
+	remuxNextEntry();
+}
+
+void OBSRemux::clearFinished()
+{
+	queueModel->clearFinished();
+}
+
+void OBSRemux::clearAll()
+{
+	queueModel->clearAll();
+}
+
+/**********************************************************
+  Worker thread - Executes the libobs remux operation as a
+                  background process.
+**********************************************************/
+
+void RemuxWorker::UpdateProgress(float percent)
+{
+	if (abs(lastProgress - percent) < 0.1f)
+		return;
+
+	emit updateProgress(percent);
+	lastProgress = percent;
+}
+
+void RemuxWorker::remux(const QString &source, const QString &target)
+{
+	isWorking = true;
+
+	auto callback = [](void *data, float percent) {
+		RemuxWorker *rw = static_cast<RemuxWorker *>(data);
+
+		QMutexLocker lock(&rw->updateMutex);
+
+		rw->UpdateProgress(percent);
+
+		return rw->isWorking;
+	};
+
+	bool stopped = false;
+	bool success = false;
+
+	media_remux_job_t mr_job = nullptr;
+	if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) {
+
+		success = media_remux_job_process(mr_job, callback, this);
+
+		media_remux_job_destroy(mr_job);
+
+		stopped = !isWorking;
+	}
+
+	isWorking = false;
+
+	emit remuxFinished(!stopped && success);
+}

+ 173 - 0
frontend/utility/RemuxQueueModel.hpp

@@ -0,0 +1,173 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFileInfo>
+#include <QMutex>
+#include <QPointer>
+#include <QThread>
+#include <QStyledItemDelegate>
+#include <memory>
+#include "ui_OBSRemux.h"
+
+#include <media-io/media-remux.h>
+#include <util/threading.h>
+
+class RemuxQueueModel;
+class RemuxWorker;
+
+enum RemuxEntryState { Empty, Ready, Pending, InProgress, Complete, InvalidPath, Error };
+Q_DECLARE_METATYPE(RemuxEntryState);
+
+class OBSRemux : public QDialog {
+	Q_OBJECT
+
+	QPointer<RemuxQueueModel> queueModel;
+	QThread remuxer;
+	QPointer<RemuxWorker> worker;
+
+	std::unique_ptr<Ui::OBSRemux> ui;
+
+	const char *recPath;
+
+	virtual void closeEvent(QCloseEvent *event) override;
+	virtual void reject() override;
+
+	bool autoRemux;
+	QString autoRemuxFile;
+
+public:
+	explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false);
+	virtual ~OBSRemux() override;
+
+	using job_t = std::shared_ptr<struct media_remux_job>;
+
+	void AutoRemux(QString inFile, QString outFile);
+
+protected:
+	virtual void dropEvent(QDropEvent *ev) override;
+	virtual void dragEnterEvent(QDragEnterEvent *ev) override;
+
+	void remuxNextEntry();
+
+private slots:
+	void rowCountChanged(const QModelIndex &parent, int first, int last);
+
+public slots:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+	void beginRemux();
+	bool stopRemux();
+	void clearFinished();
+	void clearAll();
+
+signals:
+	void remux(const QString &source, const QString &target);
+};
+
+class RemuxQueueModel : public QAbstractTableModel {
+	Q_OBJECT
+
+	friend class OBSRemux;
+
+public:
+	RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {}
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const;
+	QVariant data(const QModelIndex &index, int role) const;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const;
+	Qt::ItemFlags flags(const QModelIndex &index) const;
+	bool setData(const QModelIndex &index, const QVariant &value, int role);
+
+	QFileInfoList checkForOverwrites() const;
+	bool checkForErrors() const;
+	void beginProcessing();
+	void endProcessing();
+	bool beginNextEntry(QString &inputPath, QString &outputPath);
+	void finishEntry(bool success);
+	bool canClearFinished() const;
+	void clearFinished();
+	void clearAll();
+
+	bool autoRemux = false;
+
+private:
+	struct RemuxQueueEntry {
+		RemuxEntryState state;
+
+		QString sourcePath;
+		QString targetPath;
+	};
+
+	QList<RemuxQueueEntry> queue;
+	bool isProcessing;
+
+	static QVariant getIcon(RemuxEntryState state);
+
+	void checkInputPath(int row);
+};
+
+class RemuxWorker : public QObject {
+	Q_OBJECT
+
+	QMutex updateMutex;
+
+	bool isWorking;
+
+	float lastProgress;
+	void UpdateProgress(float percent);
+
+	explicit RemuxWorker() : isWorking(false) {}
+	virtual ~RemuxWorker(){};
+
+private slots:
+	void remux(const QString &source, const QString &target);
+
+signals:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+
+	friend class OBSRemux;
+};
+
+class RemuxEntryPathItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath);
+
+	virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+				      const QModelIndex &index) const override;
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+	virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
+	virtual void paint(QPainter *painter, const QStyleOptionViewItem &option,
+			   const QModelIndex &index) const override;
+
+private:
+	bool isOutput;
+	QString defaultPath;
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container);
+	void handleClear(QWidget *container);
+
+private slots:
+	void updateText();
+};

+ 924 - 0
frontend/utility/RemuxWorker.cpp

@@ -0,0 +1,924 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "moc_window-remux.cpp"
+
+#include "obs-app.hpp"
+
+#include <QCloseEvent>
+#include <QDirIterator>
+#include <QItemDelegate>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QMimeData>
+#include <QPainter>
+#include <QPushButton>
+#include <QStandardItemModel>
+#include <QStyledItemDelegate>
+#include <QToolButton>
+#include <QTimer>
+#include <qt-wrappers.hpp>
+
+#include "window-basic-main.hpp"
+
+#include <memory>
+#include <cmath>
+
+using namespace std;
+
+enum RemuxEntryColumn {
+	State,
+	InputPath,
+	OutputPath,
+
+	Count
+};
+
+enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole };
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath)
+	: QStyledItemDelegate(),
+	  isOutput(isOutput),
+	  defaultPath(defaultPath)
+{
+}
+
+QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+						  const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+	if (state == RemuxEntryState::Pending || state == RemuxEntryState::InProgress) {
+		// Never allow modification of rows that are
+		// in progress.
+		return Q_NULLPTR;
+	} else if (isOutput && state != RemuxEntryState::Ready) {
+		// Do not allow modification of output rows
+		// that aren't associated with a valid input.
+		return Q_NULLPTR;
+	} else if (!isOutput && state == RemuxEntryState::Complete) {
+		// Don't allow modification of rows that are
+		// already complete.
+		return Q_NULLPTR;
+	} else {
+		QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding,
+					     QSizePolicy::ControlType::PushButton);
+
+		QWidget *container = new QWidget(parent);
+
+		auto browseCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleBrowse(container);
+		};
+
+		auto clearCallback = [this, container]() {
+			const_cast<RemuxEntryPathItemDelegate *>(this)->handleClear(container);
+		};
+
+		QHBoxLayout *layout = new QHBoxLayout();
+		layout->setContentsMargins(0, 0, 0, 0);
+		layout->setSpacing(0);
+
+		QLineEdit *text = new QLineEdit();
+		text->setObjectName(QStringLiteral("text"));
+		text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+						QSizePolicy::ControlType::LineEdit));
+		layout->addWidget(text);
+
+		QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText);
+
+		QToolButton *browseButton = new QToolButton();
+		browseButton->setText("...");
+		browseButton->setSizePolicy(buttonSizePolicy);
+		layout->addWidget(browseButton);
+
+		container->connect(browseButton, &QToolButton::clicked, browseCallback);
+
+		// The "clear" button is not shown in output cells
+		// or the insertion point's input cell.
+		if (!isOutput && state != RemuxEntryState::Empty) {
+			QToolButton *clearButton = new QToolButton();
+			clearButton->setText("X");
+			clearButton->setSizePolicy(buttonSizePolicy);
+			layout->addWidget(clearButton);
+
+			container->connect(clearButton, &QToolButton::clicked, clearCallback);
+		}
+
+		container->setLayout(layout);
+		container->setFocusProxy(text);
+		return container;
+	}
+}
+
+void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = editor->findChild<QLineEdit *>();
+	text->setText(index.data().toString());
+	editor->setProperty(PATH_LIST_PROP, QVariant());
+}
+
+void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model,
+					      const QModelIndex &index) const
+{
+	// We use the PATH_LIST_PROP property to pass a list of
+	// path strings from the editor widget into the model's
+	// NewPathsToProcessRole. This is only used when paths
+	// are selected through the "browse" or "delete" buttons
+	// in the editor. If the user enters new text in the
+	// text box, we simply pass that text on to the model
+	// as normal text data in the default role.
+	QVariant pathListProp = editor->property(PATH_LIST_PROP);
+	if (pathListProp.isValid()) {
+		QStringList list = editor->property(PATH_LIST_PROP).toStringList();
+		if (isOutput) {
+			if (list.size() > 0)
+				model->setData(index, list);
+		} else
+			model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole);
+	} else {
+		QLineEdit *lineEdit = editor->findChild<QLineEdit *>();
+		model->setData(index, lineEdit->text());
+	}
+}
+
+void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+				       const QModelIndex &index) const
+{
+	RemuxEntryState state = index.model()
+					->index(index.row(), RemuxEntryColumn::State)
+					.data(RemuxEntryRole::EntryStateRole)
+					.value<RemuxEntryState>();
+
+	QStyleOptionViewItem localOption = option;
+	initStyleOption(&localOption, index);
+
+	if (isOutput) {
+		if (state != Ready) {
+			QColor background =
+				localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window);
+
+			localOption.backgroundBrush = QBrush(background);
+		}
+	}
+
+	QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter);
+}
+
+void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container)
+{
+	QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)";
+
+	QLineEdit *text = container->findChild<QLineEdit *>();
+
+	QString currentPath = text->text();
+	if (currentPath.isEmpty())
+		currentPath = defaultPath;
+
+	bool isSet = false;
+	if (isOutput) {
+		QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern);
+
+		if (!newPath.isEmpty()) {
+			container->setProperty(PATH_LIST_PROP, QStringList() << newPath);
+			isSet = true;
+		}
+	} else {
+		QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath,
+					      QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern);
+
+		if (!paths.empty()) {
+			container->setProperty(PATH_LIST_PROP, paths);
+			isSet = true;
+		}
+#ifdef __APPLE__
+		// TODO: Revisit when QTBUG-42661 is fixed
+		container->window()->raise();
+#endif
+	}
+
+	if (isSet)
+		emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::handleClear(QWidget *container)
+{
+	// An empty string list will indicate that the entry is being
+	// blanked and should be deleted.
+	container->setProperty(PATH_LIST_PROP, QStringList());
+
+	emit commitData(container);
+}
+
+void RemuxEntryPathItemDelegate::updateText()
+{
+	QLineEdit *lineEdit = dynamic_cast<QLineEdit *>(sender());
+	QWidget *editor = lineEdit->parentWidget();
+	emit commitData(editor);
+}
+
+/**********************************************************
+  Model - Manages the queue's data
+**********************************************************/
+
+int RemuxQueueModel::rowCount(const QModelIndex &) const
+{
+	return queue.length() + (isProcessing ? 0 : 1);
+}
+
+int RemuxQueueModel::columnCount(const QModelIndex &) const
+{
+	return RemuxEntryColumn::Count;
+}
+
+QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const
+{
+	QVariant result = QVariant();
+
+	if (index.row() >= queue.length()) {
+		return QVariant();
+	} else if (role == Qt::DisplayRole) {
+		switch (index.column()) {
+		case RemuxEntryColumn::InputPath:
+			result = queue[index.row()].sourcePath;
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = queue[index.row()].targetPath;
+			break;
+		}
+	} else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) {
+		result = getIcon(queue[index.row()].state);
+	} else if (role == RemuxEntryRole::EntryStateRole) {
+		result = queue[index.row()].state;
+	}
+
+	return result;
+}
+
+QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	QVariant result = QVariant();
+
+	if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case RemuxEntryColumn::State:
+			result = QString();
+			break;
+		case RemuxEntryColumn::InputPath:
+			result = QTStr("Remux.SourceFile");
+			break;
+		case RemuxEntryColumn::OutputPath:
+			result = QTStr("Remux.TargetFile");
+			break;
+		}
+	}
+
+	return result;
+}
+
+Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() == RemuxEntryColumn::InputPath) {
+		flags |= Qt::ItemIsEditable;
+	} else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) {
+		flags |= Qt::ItemIsEditable;
+	}
+
+	return flags;
+}
+
+bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role)
+{
+	bool success = false;
+
+	if (role == RemuxEntryRole::NewPathsToProcessRole) {
+		QStringList pathList = value.toStringList();
+
+		if (pathList.size() == 0) {
+			if (index.row() < queue.size()) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			if (pathList.size() >= 1 && index.row() < queue.length()) {
+				queue[index.row()].sourcePath = pathList[0];
+				checkInputPath(index.row());
+
+				pathList.removeAt(0);
+
+				success = true;
+			}
+
+			if (pathList.size() > 0) {
+				int row = index.row();
+				int lastRow = row + pathList.size() - 1;
+				beginInsertRows(QModelIndex(), row, lastRow);
+
+				for (QString path : pathList) {
+					RemuxQueueEntry entry;
+					entry.sourcePath = path;
+					entry.state = RemuxEntryState::Empty;
+
+					queue.insert(row, entry);
+					row++;
+				}
+				endInsertRows();
+
+				for (row = index.row(); row <= lastRow; row++) {
+					checkInputPath(row);
+				}
+
+				success = true;
+			}
+		}
+	} else if (index.row() == queue.length()) {
+		QString path = value.toString();
+
+		if (!path.isEmpty()) {
+			RemuxQueueEntry entry;
+			entry.sourcePath = path;
+			entry.state = RemuxEntryState::Empty;
+
+			beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1);
+			queue.append(entry);
+			endInsertRows();
+
+			checkInputPath(index.row());
+			success = true;
+		}
+	} else {
+		QString path = value.toString();
+
+		if (path.isEmpty()) {
+			if (index.column() == RemuxEntryColumn::InputPath) {
+				beginRemoveRows(QModelIndex(), index.row(), index.row());
+				queue.removeAt(index.row());
+				endRemoveRows();
+			}
+		} else {
+			switch (index.column()) {
+			case RemuxEntryColumn::InputPath:
+				queue[index.row()].sourcePath = value.toString();
+				checkInputPath(index.row());
+				success = true;
+				break;
+			case RemuxEntryColumn::OutputPath:
+				queue[index.row()].targetPath = value.toString();
+				emit dataChanged(index, index);
+				success = true;
+				break;
+			}
+		}
+	}
+
+	return success;
+}
+
+QVariant RemuxQueueModel::getIcon(RemuxEntryState state)
+{
+	QVariant icon;
+	QStyle *style = QApplication::style();
+
+	switch (state) {
+	case RemuxEntryState::Complete:
+		icon = style->standardIcon(QStyle::SP_DialogApplyButton);
+		break;
+
+	case RemuxEntryState::InProgress:
+		icon = style->standardIcon(QStyle::SP_ArrowRight);
+		break;
+
+	case RemuxEntryState::Error:
+		icon = style->standardIcon(QStyle::SP_DialogCancelButton);
+		break;
+
+	case RemuxEntryState::InvalidPath:
+		icon = style->standardIcon(QStyle::SP_MessageBoxWarning);
+		break;
+
+	default:
+		break;
+	}
+
+	return icon;
+}
+
+void RemuxQueueModel::checkInputPath(int row)
+{
+	RemuxQueueEntry &entry = queue[row];
+
+	if (entry.sourcePath.isEmpty()) {
+		entry.state = RemuxEntryState::Empty;
+	} else {
+		entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath);
+		QFileInfo fileInfo(entry.sourcePath);
+		if (fileInfo.exists())
+			entry.state = RemuxEntryState::Ready;
+		else
+			entry.state = RemuxEntryState::InvalidPath;
+
+		QString newExt = ".mp4";
+		QString suffix = fileInfo.suffix();
+
+		if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) {
+			newExt = ".remuxed." + suffix;
+		}
+
+		if (entry.state == RemuxEntryState::Ready)
+			entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() +
+								    fileInfo.completeBaseName() + newExt);
+	}
+
+	if (entry.state == RemuxEntryState::Ready && isProcessing)
+		entry.state = RemuxEntryState::Pending;
+
+	emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count));
+}
+
+QFileInfoList RemuxQueueModel::checkForOverwrites() const
+{
+	QFileInfoList list;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Ready) {
+			QFileInfo fileInfo(entry.targetPath);
+			if (fileInfo.exists()) {
+				list.append(fileInfo);
+			}
+		}
+	}
+
+	return list;
+}
+
+bool RemuxQueueModel::checkForErrors() const
+{
+	bool hasErrors = false;
+
+	for (const RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Error) {
+			hasErrors = true;
+			break;
+		}
+	}
+
+	return hasErrors;
+}
+
+void RemuxQueueModel::clearAll()
+{
+	beginRemoveRows(QModelIndex(), 0, queue.size() - 1);
+	queue.clear();
+	endRemoveRows();
+}
+
+void RemuxQueueModel::clearFinished()
+{
+	int index = 0;
+
+	for (index = 0; index < queue.size(); index++) {
+		const RemuxQueueEntry &entry = queue[index];
+		if (entry.state == RemuxEntryState::Complete) {
+			beginRemoveRows(QModelIndex(), index, index);
+			queue.removeAt(index);
+			endRemoveRows();
+			index--;
+		}
+	}
+}
+
+bool RemuxQueueModel::canClearFinished() const
+{
+	bool canClearFinished = false;
+	for (const RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Complete) {
+			canClearFinished = true;
+			break;
+		}
+
+	return canClearFinished;
+}
+
+void RemuxQueueModel::beginProcessing()
+{
+	for (RemuxQueueEntry &entry : queue)
+		if (entry.state == RemuxEntryState::Ready)
+			entry.state = RemuxEntryState::Pending;
+
+	// Signal that the insertion point no longer exists.
+	beginRemoveRows(QModelIndex(), queue.length(), queue.length());
+	endRemoveRows();
+
+	isProcessing = true;
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+void RemuxQueueModel::endProcessing()
+{
+	for (RemuxQueueEntry &entry : queue) {
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::Ready;
+		}
+	}
+
+	// Signal that the insertion point exists again.
+	isProcessing = false;
+	if (!autoRemux) {
+		beginInsertRows(QModelIndex(), queue.length(), queue.length());
+		endInsertRows();
+	}
+
+	emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State));
+}
+
+bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath)
+{
+	bool anyStarted = false;
+
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::Pending) {
+			entry.state = RemuxEntryState::InProgress;
+
+			inputPath = entry.sourcePath;
+			outputPath = entry.targetPath;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			anyStarted = true;
+			break;
+		}
+	}
+
+	return anyStarted;
+}
+
+void RemuxQueueModel::finishEntry(bool success)
+{
+	for (int row = 0; row < queue.length(); row++) {
+		RemuxQueueEntry &entry = queue[row];
+		if (entry.state == RemuxEntryState::InProgress) {
+			if (success)
+				entry.state = RemuxEntryState::Complete;
+			else
+				entry.state = RemuxEntryState::Error;
+
+			QModelIndex index = this->index(row, RemuxEntryColumn::State);
+			emit dataChanged(index, index);
+
+			break;
+		}
+	}
+}
+
+/**********************************************************
+  The actual remux window implementation
+**********************************************************/
+
+OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_)
+	: QDialog(parent),
+	  queueModel(new RemuxQueueModel),
+	  worker(new RemuxWorker()),
+	  ui(new Ui::OBSRemux),
+	  recPath(path),
+	  autoRemux(autoRemux_)
+{
+	setAcceptDrops(true);
+
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	ui->progressBar->setVisible(false);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+
+	if (autoRemux) {
+		resize(280, 40);
+		ui->tableView->hide();
+		ui->buttonBox->hide();
+		ui->label->hide();
+	}
+
+	ui->progressBar->setMinimum(0);
+	ui->progressBar->setMaximum(1000);
+	ui->progressBar->setValue(0);
+
+	ui->tableView->setModel(queueModel);
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath,
+						new RemuxEntryPathItemDelegate(false, recPath));
+	ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath,
+						new RemuxEntryPathItemDelegate(true, recPath));
+	ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State,
+								QHeaderView::ResizeMode::Fixed);
+	ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+	ui->tableView->setTextElideMode(Qt::ElideMiddle);
+	ui->tableView->setWordWrap(false);
+
+	installEventFilter(CreateShortcutFilter());
+
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished"));
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll"));
+	ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true);
+
+	connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux);
+	connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished);
+	connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this,
+		&OBSRemux::clearAll);
+	connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close);
+
+	worker->moveToThread(&remuxer);
+	remuxer.start();
+
+	connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress);
+	connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater);
+	connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished);
+	connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux);
+
+	connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged);
+	connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged);
+
+	QModelIndex index = queueModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection,
+				  Q_ARG(const QModelIndex &, index));
+}
+
+bool OBSRemux::stopRemux()
+{
+	if (!worker->isWorking)
+		return true;
+
+	// By locking the worker thread's mutex, we ensure that its
+	// update poll will be blocked as long as we're in here with
+	// the popup open.
+	QMutexLocker lock(&worker->updateMutex);
+
+	bool exit = false;
+
+	if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"),
+				  QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
+		exit = true;
+	}
+
+	if (exit) {
+		// Inform the worker it should no longer be
+		// working. It will interrupt accordingly in
+		// its next update callback.
+		worker->isWorking = false;
+	}
+
+	return exit;
+}
+
+OBSRemux::~OBSRemux()
+{
+	stopRemux();
+	remuxer.quit();
+	remuxer.wait();
+}
+
+void OBSRemux::rowCountChanged(const QModelIndex &, int, int)
+{
+	// See if there are still any rows ready to remux. Change
+	// the state of the "go" button accordingly.
+	// There must be more than one row, since there will always be
+	// at least one row for the empty insertion point.
+	if (queueModel->rowCount() > 1) {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+	} else {
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false);
+	}
+}
+
+void OBSRemux::dropEvent(QDropEvent *ev)
+{
+	QStringList urlList;
+
+	for (QUrl url : ev->mimeData()->urls()) {
+		QFileInfo fileInfo(url.toLocalFile());
+
+		if (fileInfo.isDir()) {
+			QStringList directoryFilter;
+			directoryFilter << "*.flv"
+					<< "*.mp4"
+					<< "*.mov"
+					<< "*.mkv"
+					<< "*.ts"
+					<< "*.m3u8";
+
+			QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files,
+					     QDirIterator::Subdirectories);
+
+			while (dirIter.hasNext()) {
+				urlList.append(dirIter.next());
+			}
+		} else {
+			urlList.append(fileInfo.canonicalFilePath());
+		}
+	}
+
+	if (urlList.empty()) {
+		QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"),
+					 QMessageBox::Ok);
+	} else if (!autoRemux) {
+		QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath);
+		queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole);
+	}
+}
+
+void OBSRemux::dragEnterEvent(QDragEnterEvent *ev)
+{
+	if (ev->mimeData()->hasUrls() && !worker->isWorking)
+		ev->accept();
+}
+
+void OBSRemux::beginRemux()
+{
+	if (worker->isWorking) {
+		stopRemux();
+		return;
+	}
+
+	bool proceedWithRemux = true;
+	QFileInfoList overwriteFiles = queueModel->checkForOverwrites();
+
+	if (!overwriteFiles.empty()) {
+		QString message = QTStr("Remux.FileExists");
+		message += "\n\n";
+
+		for (QFileInfo fileInfo : overwriteFiles)
+			message += fileInfo.canonicalFilePath() + "\n";
+
+		if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes)
+			proceedWithRemux = false;
+	}
+
+	if (!proceedWithRemux)
+		return;
+
+	// Set all jobs to "pending" first.
+	queueModel->beginProcessing();
+
+	ui->progressBar->setVisible(true);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop"));
+	setAcceptDrops(false);
+
+	remuxNextEntry();
+}
+
+void OBSRemux::AutoRemux(QString inFile, QString outFile)
+{
+	if (inFile != "" && outFile != "" && autoRemux) {
+		ui->progressBar->setVisible(true);
+		emit remux(inFile, outFile);
+		autoRemuxFile = outFile;
+	}
+}
+
+void OBSRemux::remuxNextEntry()
+{
+	worker->lastProgress = 0.f;
+
+	QString inputPath, outputPath;
+	if (queueModel->beginNextEntry(inputPath, outputPath)) {
+		emit remux(inputPath, outputPath);
+	} else {
+		queueModel->autoRemux = autoRemux;
+		queueModel->endProcessing();
+
+		if (!autoRemux) {
+			OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"),
+						   queueModel->checkForErrors() ? QTStr("Remux.FinishedError")
+										: QTStr("Remux.Finished"));
+		}
+
+		ui->progressBar->setVisible(autoRemux);
+		ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux"));
+		ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true);
+		ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished());
+		setAcceptDrops(true);
+	}
+}
+
+void OBSRemux::closeEvent(QCloseEvent *event)
+{
+	if (!stopRemux())
+		event->ignore();
+	else
+		QDialog::closeEvent(event);
+}
+
+void OBSRemux::reject()
+{
+	if (!stopRemux())
+		return;
+
+	QDialog::reject();
+}
+
+void OBSRemux::updateProgress(float percent)
+{
+	ui->progressBar->setValue(percent * 10);
+}
+
+void OBSRemux::remuxFinished(bool success)
+{
+	ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+
+	queueModel->finishEntry(success);
+
+	if (autoRemux && autoRemuxFile != "") {
+		QTimer::singleShot(3000, this, &OBSRemux::close);
+
+		OBSBasic *main = OBSBasic::Get();
+		main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile));
+	}
+
+	remuxNextEntry();
+}
+
+void OBSRemux::clearFinished()
+{
+	queueModel->clearFinished();
+}
+
+void OBSRemux::clearAll()
+{
+	queueModel->clearAll();
+}
+
+/**********************************************************
+  Worker thread - Executes the libobs remux operation as a
+                  background process.
+**********************************************************/
+
+void RemuxWorker::UpdateProgress(float percent)
+{
+	if (abs(lastProgress - percent) < 0.1f)
+		return;
+
+	emit updateProgress(percent);
+	lastProgress = percent;
+}
+
+void RemuxWorker::remux(const QString &source, const QString &target)
+{
+	isWorking = true;
+
+	auto callback = [](void *data, float percent) {
+		RemuxWorker *rw = static_cast<RemuxWorker *>(data);
+
+		QMutexLocker lock(&rw->updateMutex);
+
+		rw->UpdateProgress(percent);
+
+		return rw->isWorking;
+	};
+
+	bool stopped = false;
+	bool success = false;
+
+	media_remux_job_t mr_job = nullptr;
+	if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) {
+
+		success = media_remux_job_process(mr_job, callback, this);
+
+		media_remux_job_destroy(mr_job);
+
+		stopped = !isWorking;
+	}
+
+	isWorking = false;
+
+	emit remuxFinished(!stopped && success);
+}

+ 173 - 0
frontend/utility/RemuxWorker.hpp

@@ -0,0 +1,173 @@
+/******************************************************************************
+    Copyright (C) 2014 by Ruwen Hahn <[email protected]>
+
+    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, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <QFileInfo>
+#include <QMutex>
+#include <QPointer>
+#include <QThread>
+#include <QStyledItemDelegate>
+#include <memory>
+#include "ui_OBSRemux.h"
+
+#include <media-io/media-remux.h>
+#include <util/threading.h>
+
+class RemuxQueueModel;
+class RemuxWorker;
+
+enum RemuxEntryState { Empty, Ready, Pending, InProgress, Complete, InvalidPath, Error };
+Q_DECLARE_METATYPE(RemuxEntryState);
+
+class OBSRemux : public QDialog {
+	Q_OBJECT
+
+	QPointer<RemuxQueueModel> queueModel;
+	QThread remuxer;
+	QPointer<RemuxWorker> worker;
+
+	std::unique_ptr<Ui::OBSRemux> ui;
+
+	const char *recPath;
+
+	virtual void closeEvent(QCloseEvent *event) override;
+	virtual void reject() override;
+
+	bool autoRemux;
+	QString autoRemuxFile;
+
+public:
+	explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false);
+	virtual ~OBSRemux() override;
+
+	using job_t = std::shared_ptr<struct media_remux_job>;
+
+	void AutoRemux(QString inFile, QString outFile);
+
+protected:
+	virtual void dropEvent(QDropEvent *ev) override;
+	virtual void dragEnterEvent(QDragEnterEvent *ev) override;
+
+	void remuxNextEntry();
+
+private slots:
+	void rowCountChanged(const QModelIndex &parent, int first, int last);
+
+public slots:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+	void beginRemux();
+	bool stopRemux();
+	void clearFinished();
+	void clearAll();
+
+signals:
+	void remux(const QString &source, const QString &target);
+};
+
+class RemuxQueueModel : public QAbstractTableModel {
+	Q_OBJECT
+
+	friend class OBSRemux;
+
+public:
+	RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {}
+
+	int rowCount(const QModelIndex &parent = QModelIndex()) const;
+	int columnCount(const QModelIndex &parent = QModelIndex()) const;
+	QVariant data(const QModelIndex &index, int role) const;
+	QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const;
+	Qt::ItemFlags flags(const QModelIndex &index) const;
+	bool setData(const QModelIndex &index, const QVariant &value, int role);
+
+	QFileInfoList checkForOverwrites() const;
+	bool checkForErrors() const;
+	void beginProcessing();
+	void endProcessing();
+	bool beginNextEntry(QString &inputPath, QString &outputPath);
+	void finishEntry(bool success);
+	bool canClearFinished() const;
+	void clearFinished();
+	void clearAll();
+
+	bool autoRemux = false;
+
+private:
+	struct RemuxQueueEntry {
+		RemuxEntryState state;
+
+		QString sourcePath;
+		QString targetPath;
+	};
+
+	QList<RemuxQueueEntry> queue;
+	bool isProcessing;
+
+	static QVariant getIcon(RemuxEntryState state);
+
+	void checkInputPath(int row);
+};
+
+class RemuxWorker : public QObject {
+	Q_OBJECT
+
+	QMutex updateMutex;
+
+	bool isWorking;
+
+	float lastProgress;
+	void UpdateProgress(float percent);
+
+	explicit RemuxWorker() : isWorking(false) {}
+	virtual ~RemuxWorker(){};
+
+private slots:
+	void remux(const QString &source, const QString &target);
+
+signals:
+	void updateProgress(float percent);
+	void remuxFinished(bool success);
+
+	friend class OBSRemux;
+};
+
+class RemuxEntryPathItemDelegate : public QStyledItemDelegate {
+	Q_OBJECT
+
+public:
+	RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath);
+
+	virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */,
+				      const QModelIndex &index) const override;
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override;
+	virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
+	virtual void paint(QPainter *painter, const QStyleOptionViewItem &option,
+			   const QModelIndex &index) const override;
+
+private:
+	bool isOutput;
+	QString defaultPath;
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container);
+	void handleClear(QWidget *container);
+
+private slots:
+	void updateText();
+};