Browse Source

UI: Batch remux and drag/drop support on remux dialog

This changes the remux dialog to support a collection of input/output
pairs, presented as a QTableView.  Standard Qt icons are used to
indicate the state of each entry during a remux operation.  Drag/drop
support is added to populate the list quickly.  Both Dark and Rachni
themes are updated to make QTableView look reasonable.

Relevant text is added in the localization files.

Closes obsproject/obs-studio#1153
nleseul 7 years ago
parent
commit
797b3dc121
6 changed files with 991 additions and 177 deletions
  1. 5 2
      UI/data/locale/en-US.ini
  2. 12 0
      UI/data/themes/Dark.qss
  3. 15 1
      UI/data/themes/Rachni.qss
  4. 51 66
      UI/forms/OBSRemux.ui
  5. 827 95
      UI/window-remux.cpp
  6. 81 13
      UI/window-remux.hpp

+ 5 - 2
UI/data/locale/en-US.ini

@@ -271,16 +271,19 @@ LogReturnDialog.ErrorUploadingLog="Error uploading log file"
 Remux.SourceFile="OBS Recording"
 Remux.SourceFile="OBS Recording"
 Remux.TargetFile="Target File"
 Remux.TargetFile="Target File"
 Remux.Remux="Remux"
 Remux.Remux="Remux"
+Remux.ClearFinished="Clear Finished Items"
+Remux.ClearAll="Clear All Items"
 Remux.OBSRecording="OBS Recording"
 Remux.OBSRecording="OBS Recording"
 Remux.FinishedTitle="Remuxing finished"
 Remux.FinishedTitle="Remuxing finished"
 Remux.Finished="Recording remuxed"
 Remux.Finished="Recording remuxed"
 Remux.FinishedError="Recording remuxed, but the file may be incomplete"
 Remux.FinishedError="Recording remuxed, but the file may be incomplete"
 Remux.SelectRecording="Select OBS Recording …"
 Remux.SelectRecording="Select OBS Recording …"
 Remux.SelectTarget="Select target file …"
 Remux.SelectTarget="Select target file …"
-Remux.FileExistsTitle="Target file exists"
-Remux.FileExists="Target file exists, do you want to replace it?"
+Remux.FileExistsTitle="Target files exist"
+Remux.FileExists="The following target files already exist. Do you want to replace them?"
 Remux.ExitUnfinishedTitle="Remuxing in progress"
 Remux.ExitUnfinishedTitle="Remuxing in progress"
 Remux.ExitUnfinished="Remuxing is not finished, stopping now may render the target file unusable.\nAre you sure you want to stop remuxing?"
 Remux.ExitUnfinished="Remuxing is not finished, stopping now may render the target file unusable.\nAre you sure you want to stop remuxing?"
+Remux.HelpText="Drop files in this window to remux, or select an empty \"OBS Recording\" cell to browse for a file."
 
 
 # update dialog
 # update dialog
 UpdateAvailable="New Update Available"
 UpdateAvailable="New Update Available"

+ 12 - 0
UI/data/themes/Dark.qss

@@ -579,6 +579,18 @@ QStatusBar::item {
     border: none;
     border: none;
 }
 }
 
 
+/* Table View */
+
+QTableView {
+    gridline-color: rgb(88,87,88); /* kindaDark */
+}
+
+QHeaderView::section {
+    background-color: rgb(58,57,58); /* dark */
+    color: rgb(225,224,225); /* veryLight */
+    border: 1px solid rgb(31,30,31); /* veryDark */;
+    border-radius: 5px;
+}
 
 
 /* Mute CheckBox */
 /* Mute CheckBox */
 
 

+ 15 - 1
UI/data/themes/Rachni.qss

@@ -1165,6 +1165,21 @@ QSlider::handle:hover {
 QSlider::handle:disabled {
 QSlider::handle:disabled {
 	background-color: rgb(122, 121, 122);
 	background-color: rgb(122, 121, 122);
 }
 }
+/**********************/
+/* --- Table View --- */
+/**********************/
+
+QTableView {
+    gridline-color: rgb(118, 121, 124); /* Light Gray */
+}
+
+QHeaderView::section {
+    background-color: rgb(35, 38, 41); /* Dark Gray */
+    color: rgb(239, 240, 241); /* "White" */
+    border: 1px solid rgb(118, 121, 124); /* Light Gray */
+    border-radius: 2px;
+    padding: 4px;
+}
 
 
 /****************/
 /****************/
 /* --- Misc --- */
 /* --- Misc --- */
@@ -1191,7 +1206,6 @@ QFrame[frameShape="0"] {
 	border: 1px transparent;
 	border: 1px transparent;
 }
 }
 
 
-
 /* Misc style tweaks for dark themes */
 /* Misc style tweaks for dark themes */
 * [themeID="error"] {
 * [themeID="error"] {
 	color: rgb(255, 89, 76); /* Red Error */
 	color: rgb(255, 89, 76); /* Red Error */

+ 51 - 66
UI/forms/OBSRemux.ui

@@ -6,77 +6,62 @@
    <rect>
    <rect>
     <x>0</x>
     <x>0</x>
     <y>0</y>
     <y>0</y>
-    <width>491</width>
-    <height>124</height>
+    <width>850</width>
+    <height>400</height>
    </rect>
    </rect>
   </property>
   </property>
   <property name="windowTitle">
   <property name="windowTitle">
    <string>RemuxRecordings</string>
    <string>RemuxRecordings</string>
   </property>
   </property>
-   <layout class="QGridLayout" name="formLayout">
-    <item row="1" column="0">
-     <widget class="QLabel" name="label">
-      <property name="text">
-       <string>Remux.SourceFile</string>
-      </property>
-     </widget>
-    </item>
-    <item row="2" column="0">
-     <widget class="QLabel" name="label_2">
-      <property name="text">
-       <string>Remux.TargetFile</string>
-      </property>
-     </widget>
-    </item>
-    <item row="1" column="1">
-     <layout class="QHBoxLayout" name="horizontalLayout_2">
-      <item>
-       <widget class="QLineEdit" name="sourceFile">
-       </widget>
-      </item>
-      <item>
-       <widget class="QPushButton" name="browseSource">
-        <property name="text">
-         <string>Browse</string>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </item>
-    <item row="2" column="1">
-     <layout class="QHBoxLayout" name="horizontalLayout_3">
-      <item>
-       <widget class="QLineEdit" name="targetFile">
-       </widget>
-      </item>
-      <item>
-       <widget class="QPushButton" name="browseTarget">
-        <property name="text">
-         <string>Browse</string>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </item>
-    <item row="3" column="0" colspan="2">
-     <widget class="QProgressBar" name="progressBar">
-      <property name="value">
-       <number>24</number>
-      </property>
-     </widget>
-    </item>
-    <item row="4" column="1">
-     <layout class="QHBoxLayout" name="horizontalLayout_4">
-      <item>
-       <widget class="QDialogButtonBox" name="buttonBox">
-        <property name="standardButtons">
-         <set>QDialogButtonBox::Ok|QDialogButtonBox::Close</set>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </item>
-   </layout>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="0" column="0">
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>Remux.HelpText</string>
+     </property>
+    </widget>
+   </item>
+   <item row="3" column="0">
+    <layout class="QHBoxLayout" name="horizontalLayout_4">
+     <property name="spacing">
+      <number>6</number>
+     </property>
+     <item>
+      <widget class="QDialogButtonBox" name="buttonBox">
+       <property name="standardButtons">
+        <set>QDialogButtonBox::Close|QDialogButtonBox::Ok|QDialogButtonBox::Reset|QDialogButtonBox::RestoreDefaults</set>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+   <item row="1" column="0">
+    <widget class="QTableView" name="tableView">
+     <property name="selectionMode">
+      <enum>QAbstractItemView::NoSelection</enum>
+     </property>
+     <attribute name="horizontalHeaderDefaultSectionSize">
+      <number>23</number>
+     </attribute>
+     <attribute name="horizontalHeaderMinimumSectionSize">
+      <number>23</number>
+     </attribute>
+     <attribute name="verticalHeaderVisible">
+      <bool>false</bool>
+     </attribute>
+     <attribute name="verticalHeaderDefaultSectionSize">
+      <number>23</number>
+     </attribute>
+    </widget>
+   </item>
+   <item row="2" column="0">
+    <widget class="QProgressBar" name="progressBar">
+     <property name="value">
+      <number>24</number>
+     </property>
+    </widget>
+   </item>
+  </layout>
  </widget>
  </widget>
  <resources/>
  <resources/>
  <connections/>
  <connections/>

+ 827 - 95
UI/window-remux.cpp

@@ -20,9 +20,17 @@
 #include "obs-app.hpp"
 #include "obs-app.hpp"
 
 
 #include <QCloseEvent>
 #include <QCloseEvent>
+#include <QDirIterator>
 #include <QFileDialog>
 #include <QFileDialog>
-#include <QFileInfo>
+#include <QItemDelegate>
+#include <QLineEdit>
 #include <QMessageBox>
 #include <QMessageBox>
+#include <QMimeData>
+#include <QPainter>
+#include <QPushButton>
+#include <QStandardItemModel>
+#include <QStyledItemDelegate>
+#include <QToolButton>
 
 
 #include "qt-wrappers.hpp"
 #include "qt-wrappers.hpp"
 
 
@@ -31,42 +39,652 @@
 
 
 using namespace std;
 using namespace std;
 
 
+enum RemuxEntryColumn {
+	State,
+	InputPath,
+	OutputPath,
+
+	Count
+};
+
+enum RemuxEntryRole {
+	EntryStateRole = Qt::UserRole,
+	NewPathsToProcessRole
+};
+
+/**********************************************************
+  Delegate - Presents cells in the grid.
+**********************************************************/
+
+class RemuxEntryPathItemDelegate : public QStyledItemDelegate {
+public:
+
+	RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath)
+		: QStyledItemDelegate(),
+		  isOutput(isOutput),
+		  defaultPath(defaultPath)
+	{
+	}
+
+	virtual QWidget *createEditor(QWidget *parent,
+			const QStyleOptionViewItem & /* option */,
+			const QModelIndex &index) const override
+	{
+		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->setMargin(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 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;
+		}
+	}
+
+	virtual void setEditorData(QWidget *editor, const QModelIndex &index)
+			const override
+	{
+		QLineEdit *text = editor->findChild<QLineEdit *>();
+		text->setText(index.data().toString());
+
+		editor->setProperty(PATH_LIST_PROP, QVariant());
+	}
+
+	virtual void setModelData(QWidget *editor,
+			QAbstractItemModel *model,
+			const QModelIndex &index) const override
+	{
+		// 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());
+		}
+	}
+
+	virtual void paint(QPainter *painter,
+			const QStyleOptionViewItem &option,
+			const QModelIndex &index) const override
+	{
+		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::Background);
+
+				localOption.backgroundBrush = QBrush(background);
+			}
+		}
+
+		QApplication::style()->drawControl(QStyle::CE_ItemViewItem,
+				&localOption, painter);
+	}
+
+private:
+	bool isOutput;
+	QString defaultPath;
+
+	const char *PATH_LIST_PROP = "pathList";
+
+	void handleBrowse(QWidget *container)
+	{
+		QString OutputPattern =
+				"(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)";
+		QString InputPattern =
+				"(*.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 = QFileDialog::getSaveFileName(
+					container, QTStr("Remux.SelectTarget"),
+					currentPath, OutputPattern);
+
+			if (!newPath.isEmpty()) {
+				container->setProperty(PATH_LIST_PROP,
+						QStringList() << newPath);
+				isSet = true;
+			}
+		} else {
+			QStringList paths = QFileDialog::getOpenFileNames(
+					container,
+					QTStr("Remux.SelectRecording"),
+					currentPath,
+					QTStr("Remux.OBSRecording")
+					+ QString(" ") + InputPattern);
+
+			if (!paths.empty()) {
+				container->setProperty(PATH_LIST_PROP, paths);
+				isSet = true;
+			}
+		}
+
+		if (isSet)
+			emit commitData(container);
+	}
+
+	void 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);
+	}
+};
+
+/**********************************************************
+  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;
+
+					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;
+
+			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;
+	}
+
+	return icon;
+}
+
+void RemuxQueueModel::checkInputPath(int row)
+{
+	RemuxQueueEntry &entry = queue[row];
+
+	if (entry.sourcePath.isEmpty()) {
+		entry.state = RemuxEntryState::Empty;
+	} else {
+		QFileInfo fileInfo(entry.sourcePath);
+		if (fileInfo.exists())
+			entry.state = RemuxEntryState::Ready;
+		else
+			entry.state = RemuxEntryState::InvalidPath;
+
+		if (entry.state == RemuxEntryState::Ready)
+			entry.targetPath = fileInfo.path() + QDir::separator()
+				+ fileInfo.baseName() + ".mp4";
+	}
+
+	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.
+	beginInsertRows(QModelIndex(), queue.length(), queue.length());
+	endInsertRows();
+
+	isProcessing = false;
+
+	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)
 OBSRemux::OBSRemux(const char *path, QWidget *parent)
-	: QDialog (parent),
-	  worker  (new RemuxWorker),
-	  ui      (new Ui::OBSRemux),
-	  recPath (path)
+	: QDialog   (parent),
+	  queueModel(new RemuxQueueModel),
+	  worker    (new RemuxWorker()),
+	  ui        (new Ui::OBSRemux),
+	  recPath   (path)
 {
 {
+	setAcceptDrops(true);
+
 	ui->setupUi(this);
 	ui->setupUi(this);
 
 
 	ui->progressBar->setVisible(false);
 	ui->progressBar->setVisible(false);
 	ui->buttonBox->button(QDialogButtonBox::Ok)->
 	ui->buttonBox->button(QDialogButtonBox::Ok)->
 			setEnabled(false);
 			setEnabled(false);
-	ui->targetFile->setEnabled(false);
-	ui->browseTarget->setEnabled(false);
+	ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->
+			setEnabled(false);
 
 
 	ui->progressBar->setMinimum(0);
 	ui->progressBar->setMinimum(0);
 	ui->progressBar->setMaximum(1000);
 	ui->progressBar->setMaximum(1000);
 	ui->progressBar->setValue(0);
 	ui->progressBar->setValue(0);
 
 
-	installEventFilter(CreateShortcutFilter());
-
-	connect(ui->browseSource, &QPushButton::clicked,
-			[&]() { BrowseInput(); });
-	connect(ui->browseTarget, &QPushButton::clicked,
-			[&]() { BrowseOutput(); });
+	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);
 
 
-	connect(ui->sourceFile, &QLineEdit::textChanged,
-			this, &OBSRemux::inputChanged);
+	installEventFilter(CreateShortcutFilter());
 
 
 	ui->buttonBox->button(QDialogButtonBox::Ok)->
 	ui->buttonBox->button(QDialogButtonBox::Ok)->
 			setText(QTStr("Remux.Remux"));
 			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),
 	connect(ui->buttonBox->button(QDialogButtonBox::Ok),
-		SIGNAL(clicked()), this, SLOT(Remux()));
-
+			SIGNAL(clicked()), this, SLOT(beginRemux()));
+	connect(ui->buttonBox->button(QDialogButtonBox::Reset),
+			SIGNAL(clicked()), this, SLOT(clearFinished()));
+	connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults),
+			SIGNAL(clicked()), this, SLOT(clearAll()));
 	connect(ui->buttonBox->button(QDialogButtonBox::Close),
 	connect(ui->buttonBox->button(QDialogButtonBox::Close),
-		SIGNAL(clicked()), this, SLOT(close()));
+			SIGNAL(clicked()), this, SLOT(close()));
 
 
 	worker->moveToThread(&remuxer);
 	worker->moveToThread(&remuxer);
 	remuxer.start();
 	remuxer.start();
@@ -79,107 +697,201 @@ OBSRemux::OBSRemux(const char *path, QWidget *parent)
 	connect(worker_, &RemuxWorker::remuxFinished,
 	connect(worker_, &RemuxWorker::remuxFinished,
 			this, &OBSRemux::remuxFinished);
 			this, &OBSRemux::remuxFinished);
 	connect(this, &OBSRemux::remux, worker_, &RemuxWorker::remux);
 	connect(this, &OBSRemux::remux, worker_, &RemuxWorker::remux);
+
+	// Guessing the GCC bug mentioned above would also affect
+	// QPointer<RemuxQueueModel>? Unsure.
+	RemuxQueueModel *queueModel_ = queueModel;
+	connect(queueModel_,
+			SIGNAL(rowsInserted(const QModelIndex &, int, int)),
+			this,
+			SLOT(rowCountChanged(const QModelIndex &, int, int)));
+	connect(queueModel_,
+			SIGNAL(rowsRemoved(const QModelIndex &, int, int)),
+			this,
+			SLOT(rowCountChanged(const QModelIndex &, int, int)));
+
+	QModelIndex index = queueModel->createIndex(0, 1);
+	QMetaObject::invokeMethod(ui->tableView,
+			"setCurrentIndex", Qt::QueuedConnection,
+			Q_ARG(const QModelIndex &, index));
 }
 }
 
 
-bool OBSRemux::Stop()
+bool OBSRemux::stopRemux()
 {
 {
-	if (!worker->job)
+	if (!worker->isWorking)
 		return true;
 		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,
 	if (QMessageBox::critical(nullptr,
-				QTStr("Remux.ExitUnfinishedTitle"),
-				QTStr("Remux.ExitUnfinished"),
-				QMessageBox::Yes | QMessageBox::No,
-				QMessageBox::No) ==
+			QTStr("Remux.ExitUnfinishedTitle"),
+			QTStr("Remux.ExitUnfinished"),
+			QMessageBox::Yes | QMessageBox::No,
+			QMessageBox::No) ==
 			QMessageBox::Yes) {
 			QMessageBox::Yes) {
-		os_event_signal(worker->stop);
-		return true;
+		exit = true;
 	}
 	}
 
 
-	return false;
+	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()
 OBSRemux::~OBSRemux()
 {
 {
-	Stop();
+	stopRemux();
 	remuxer.quit();
 	remuxer.quit();
 	remuxer.wait();
 	remuxer.wait();
 }
 }
 
 
-#define RECORDING_PATTERN "(*.flv *.mp4 *.mov *.mkv *.ts *.m3u8)"
+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::BrowseInput()
+void OBSRemux::dropEvent(QDropEvent *ev)
 {
 {
-	QString path = ui->sourceFile->text();
-	if (path.isEmpty())
-		path = recPath;
+	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());
+		}
+	}
 
 
-	path = QFileDialog::getOpenFileName(this,
-			QTStr("Remux.SelectRecording"), path,
-			QTStr("Remux.OBSRecording") + QString(" ") +
-			RECORDING_PATTERN);
+	if (urlList.empty()) {
+		QMessageBox::information(nullptr,
+				QTStr("Remux.NoFilesAddedTitle"),
+				QTStr("Remux.NoFilesAdded"), QMessageBox::Ok);
+	} else {
+		QModelIndex insertIndex = queueModel->index(
+				queueModel->rowCount() - 1,
+				RemuxEntryColumn::InputPath);
+		queueModel->setData(insertIndex, urlList,
+				RemuxEntryRole::NewPathsToProcessRole);
+	}
+}
 
 
-	inputChanged(path);
+void OBSRemux::dragEnterEvent(QDragEnterEvent *ev)
+{
+	if (ev->mimeData()->hasUrls() && !worker->isWorking)
+		ev->accept();
 }
 }
 
 
-void OBSRemux::inputChanged(const QString &path)
+void OBSRemux::beginRemux()
 {
 {
-	if (!QFileInfo::exists(path)) {
-		ui->buttonBox->button(QDialogButtonBox::Ok)->
-			setEnabled(false);
+	if (worker->isWorking) {
+		stopRemux();
 		return;
 		return;
 	}
 	}
 
 
-	ui->sourceFile->setText(path);
-	ui->buttonBox->button(QDialogButtonBox::Ok)->
-			setEnabled(true);
+	bool proceedWithRemux = true;
+	QFileInfoList overwriteFiles = queueModel->checkForOverwrites();
 
 
-	QFileInfo fi(path);
-	QString mp4 = fi.path() + "/" + fi.baseName() + ".mp4";
-	ui->targetFile->setText(mp4);
+	if (!overwriteFiles.empty()) {
+		QString message = QTStr("Remux.FileExists");
+		message += "\n\n";
 
 
-	ui->targetFile->setEnabled(true);
-	ui->browseTarget->setEnabled(true);
-}
+		for (QFileInfo fileInfo : overwriteFiles)
+			message += fileInfo.canonicalFilePath() + "\n";
 
 
-void OBSRemux::BrowseOutput()
-{
-	QString path(ui->targetFile->text());
-	path = QFileDialog::getSaveFileName(this, QTStr("Remux.SelectTarget"),
-				path, RECORDING_PATTERN);
+		if (OBSMessageBox::question(this,
+			QTStr("Remux.FileExistsTitle"), message)
+			!= QMessageBox::Yes)
+			proceedWithRemux = false;
+	}
 
 
-	if (path.isEmpty())
+	if (!proceedWithRemux)
 		return;
 		return;
 
 
-	ui->targetFile->setText(path);
-}
+	// Set all jobs to "pending" first.
+	queueModel->beginProcessing();
 
 
-void OBSRemux::Remux()
-{
-	if (QFileInfo::exists(ui->targetFile->text()))
-		if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"),
-					QTStr("Remux.FileExists")) !=
-				QMessageBox::Yes)
-			return;
+	ui->progressBar->setVisible(true);
+	ui->buttonBox->button(QDialogButtonBox::Ok)->
+			setText(QTStr("Remux.Stop"));
+	setAcceptDrops(false);
 
 
-	media_remux_job_t mr_job = nullptr;
-	if (!media_remux_job_create(&mr_job, QT_TO_UTF8(ui->sourceFile->text()),
-				QT_TO_UTF8(ui->targetFile->text())))
-		return;
+	remuxNextEntry();
+
+}
 
 
-	worker->job = job_t(mr_job, media_remux_job_destroy);
+void OBSRemux::remuxNextEntry()
+{
 	worker->lastProgress = 0.f;
 	worker->lastProgress = 0.f;
 
 
-	ui->progressBar->setVisible(true);
-	ui->buttonBox->button(QDialogButtonBox::Ok)->
-			setEnabled(false);
+	QString inputPath, outputPath;
+	if (queueModel->beginNextEntry(inputPath, outputPath)) {
+		emit remux(inputPath, outputPath);
+	} else {
+		queueModel->endProcessing();
+
+		OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"),
+				queueModel->checkForErrors()
+				? QTStr("Remux.FinishedError")
+				: QTStr("Remux.Finished"));
 
 
-	emit remux();
+		ui->progressBar->setVisible(false);
+		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)
 void OBSRemux::closeEvent(QCloseEvent *event)
 {
 {
-	if (!Stop())
+	if (!stopRemux())
 		event->ignore();
 		event->ignore();
 	else
 	else
 		QDialog::closeEvent(event);
 		QDialog::closeEvent(event);
@@ -187,7 +899,7 @@ void OBSRemux::closeEvent(QCloseEvent *event)
 
 
 void OBSRemux::reject()
 void OBSRemux::reject()
 {
 {
-	if (!Stop())
+	if (!stopRemux())
 		return;
 		return;
 
 
 	QDialog::reject();
 	QDialog::reject();
@@ -200,27 +912,25 @@ void OBSRemux::updateProgress(float percent)
 
 
 void OBSRemux::remuxFinished(bool success)
 void OBSRemux::remuxFinished(bool success)
 {
 {
-	worker->job.reset();
-
-	OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"),
-			success ?
-			QTStr("Remux.Finished") : QTStr("Remux.FinishedError"));
-
-	ui->progressBar->setVisible(false);
-	ui->buttonBox->button(QDialogButtonBox::Ok)->
-			setEnabled(true);
+	queueModel->finishEntry(success);
+	remuxNextEntry();
 }
 }
 
 
-RemuxWorker::RemuxWorker()
+void OBSRemux::clearFinished()
 {
 {
-	os_event_init(&stop, OS_EVENT_TYPE_MANUAL);
+	queueModel->clearFinished();
 }
 }
 
 
-RemuxWorker::~RemuxWorker()
+void OBSRemux::clearAll()
 {
 {
-	os_event_destroy(stop);
+	queueModel->clearAll();
 }
 }
 
 
+/**********************************************************
+  Worker thread - Executes the libobs remux operation as a
+                  background process.
+**********************************************************/
+
 void RemuxWorker::UpdateProgress(float percent)
 void RemuxWorker::UpdateProgress(float percent)
 {
 {
 	if (abs(lastProgress - percent) < 0.1f)
 	if (abs(lastProgress - percent) < 0.1f)
@@ -230,16 +940,38 @@ void RemuxWorker::UpdateProgress(float percent)
 	lastProgress = percent;
 	lastProgress = percent;
 }
 }
 
 
-void RemuxWorker::remux()
+void RemuxWorker::remux(const QString &source, const QString &target)
 {
 {
+	isWorking = true;
+
 	auto callback = [](void *data, float percent)
 	auto callback = [](void *data, float percent)
 	{
 	{
-		auto rw = static_cast<RemuxWorker*>(data);
+		RemuxWorker *rw = static_cast<RemuxWorker*>(data);
+
+		QMutexLocker lock(&rw->updateMutex);
+
 		rw->UpdateProgress(percent);
 		rw->UpdateProgress(percent);
-		return !!os_event_try(rw->stop);
+
+		return rw->isWorking;
 	};
 	};
 
 
-	bool success = media_remux_job_process(job.get(), callback, this);
+	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(os_event_try(stop) && success);
+	emit remuxFinished(!stopped && success);
 }
 }

+ 81 - 13
UI/window-remux.hpp

@@ -17,6 +17,8 @@
 
 
 #pragma once
 #pragma once
 
 
+#include <QFileInfo>
+#include <QMutex>
 #include <QPointer>
 #include <QPointer>
 #include <QThread>
 #include <QThread>
 #include <memory>
 #include <memory>
@@ -25,11 +27,25 @@
 #include <media-io/media-remux.h>
 #include <media-io/media-remux.h>
 #include <util/threading.h>
 #include <util/threading.h>
 
 
+class RemuxQueueModel;
 class RemuxWorker;
 class RemuxWorker;
 
 
+enum RemuxEntryState
+{
+	Empty,
+	Ready,
+	Pending,
+	InProgress,
+	Complete,
+	InvalidPath,
+	Error
+};
+Q_DECLARE_METATYPE(RemuxEntryState);
+
 class OBSRemux : public QDialog {
 class OBSRemux : public QDialog {
 	Q_OBJECT
 	Q_OBJECT
 
 
+	QPointer<RemuxQueueModel> queueModel;
 	QThread remuxer;
 	QThread remuxer;
 	QPointer<RemuxWorker> worker;
 	QPointer<RemuxWorker> worker;
 
 
@@ -37,11 +53,6 @@ class OBSRemux : public QDialog {
 
 
 	const char *recPath;
 	const char *recPath;
 
 
-	void BrowseInput();
-	void BrowseOutput();
-
-	bool Stop();
-
 	virtual void closeEvent(QCloseEvent *event) override;
 	virtual void closeEvent(QCloseEvent *event) override;
 	virtual void reject() override;
 	virtual void reject() override;
 
 
@@ -51,32 +62,89 @@ public:
 
 
 	using job_t = std::shared_ptr<struct media_remux_job>;
 	using job_t = std::shared_ptr<struct media_remux_job>;
 
 
+protected:
+	void dropEvent(QDropEvent *ev);
+	void dragEnterEvent(QDragEnterEvent *ev);
+
+	void remuxNextEntry();
+
 private slots:
 private slots:
-	void inputChanged(const QString &str);
+	void rowCountChanged(const QModelIndex &parent, int first, int last);
 
 
 public slots:
 public slots:
 	void updateProgress(float percent);
 	void updateProgress(float percent);
 	void remuxFinished(bool success);
 	void remuxFinished(bool success);
-	void Remux();
+	void beginRemux();
+	bool stopRemux();
+	void clearFinished();
+	void clearAll();
 
 
 signals:
 signals:
-	void remux();
+	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();
+
+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 {
 class RemuxWorker : public QObject {
 	Q_OBJECT
 	Q_OBJECT
 
 
-	OBSRemux::job_t job;
-	os_event_t *stop;
+	QMutex updateMutex;
+
+	bool isWorking;
 
 
 	float lastProgress;
 	float lastProgress;
 	void UpdateProgress(float percent);
 	void UpdateProgress(float percent);
 
 
-	explicit RemuxWorker();
-	virtual ~RemuxWorker();
+	explicit RemuxWorker()
+		: isWorking(false) { }
+	virtual ~RemuxWorker() {};
 
 
 private slots:
 private slots:
-	void remux();
+	void remux(const QString &source, const QString &target);
 
 
 signals:
 signals:
 	void updateProgress(float percent);
 	void updateProgress(float percent);