diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini
index 002c5a0f7..b88ca2ef8 100644
--- a/UI/data/locale/en-US.ini
+++ b/UI/data/locale/en-US.ini
@@ -271,16 +271,19 @@ LogReturnDialog.ErrorUploadingLog="Error uploading log file"
Remux.SourceFile="OBS Recording"
Remux.TargetFile="Target File"
Remux.Remux="Remux"
+Remux.ClearFinished="Clear Finished Items"
+Remux.ClearAll="Clear All Items"
Remux.OBSRecording="OBS Recording"
Remux.FinishedTitle="Remuxing finished"
Remux.Finished="Recording remuxed"
Remux.FinishedError="Recording remuxed, but the file may be incomplete"
Remux.SelectRecording="Select OBS Recording …"
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.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
UpdateAvailable="New Update Available"
diff --git a/UI/data/themes/Dark.qss b/UI/data/themes/Dark.qss
index d2ba23720..2d0e8a48a 100644
--- a/UI/data/themes/Dark.qss
+++ b/UI/data/themes/Dark.qss
@@ -579,6 +579,18 @@ QStatusBar::item {
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 */
diff --git a/UI/data/themes/Rachni.qss b/UI/data/themes/Rachni.qss
index 66e93e2b1..62be5decb 100644
--- a/UI/data/themes/Rachni.qss
+++ b/UI/data/themes/Rachni.qss
@@ -1165,6 +1165,21 @@ QSlider::handle:hover {
QSlider::handle:disabled {
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 --- */
@@ -1191,7 +1206,6 @@ QFrame[frameShape="0"] {
border: 1px transparent;
}
-
/* Misc style tweaks for dark themes */
* [themeID="error"] {
color: rgb(255, 89, 76); /* Red Error */
diff --git a/UI/forms/OBSRemux.ui b/UI/forms/OBSRemux.ui
index 4c74e56b1..0f762b30c 100644
--- a/UI/forms/OBSRemux.ui
+++ b/UI/forms/OBSRemux.ui
@@ -6,77 +6,62 @@
0
0
- 491
- 124
+ 850
+ 400
RemuxRecordings
-
- -
-
-
- Remux.SourceFile
-
-
-
- -
-
-
- Remux.TargetFile
-
-
-
- -
-
-
-
-
-
-
- -
-
-
- Browse
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
- Browse
-
-
-
-
-
- -
-
-
- 24
-
-
-
- -
-
-
-
-
-
- QDialogButtonBox::Ok|QDialogButtonBox::Close
-
-
-
-
-
-
+
+ -
+
+
+ Remux.HelpText
+
+
+
+ -
+
+
+ 6
+
+
-
+
+
+ QDialogButtonBox::Close|QDialogButtonBox::Ok|QDialogButtonBox::Reset|QDialogButtonBox::RestoreDefaults
+
+
+
+
+
+ -
+
+
+ QAbstractItemView::NoSelection
+
+
+ 23
+
+
+ 23
+
+
+ false
+
+
+ 23
+
+
+
+ -
+
+
+ 24
+
+
+
+
diff --git a/UI/window-remux.cpp b/UI/window-remux.cpp
index 92de804b9..5671d389b 100644
--- a/UI/window-remux.cpp
+++ b/UI/window-remux.cpp
@@ -20,9 +20,17 @@
#include "obs-app.hpp"
#include
+#include
#include
-#include
+#include
+#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include "qt-wrappers.hpp"
@@ -31,42 +39,652 @@
using namespace std;
-OBSRemux::OBSRemux(const char *path, QWidget *parent)
- : QDialog (parent),
- worker (new RemuxWorker),
- ui (new Ui::OBSRemux),
- recPath (path)
+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();
+ 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(this)
+ ->handleBrowse(container);
+ };
+
+ auto clearCallback = [this, container]()
+ {
+ const_cast(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();
+ 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();
+ 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();
+
+ 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();
+
+ 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)
+ : QDialog (parent),
+ queueModel(new RemuxQueueModel),
+ worker (new RemuxWorker()),
+ ui (new Ui::OBSRemux),
+ recPath (path)
+{
+ setAcceptDrops(true);
+
ui->setupUi(this);
ui->progressBar->setVisible(false);
ui->buttonBox->button(QDialogButtonBox::Ok)->
setEnabled(false);
- ui->targetFile->setEnabled(false);
- ui->browseTarget->setEnabled(false);
+ ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->
+ setEnabled(false);
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);
+
installEventFilter(CreateShortcutFilter());
- connect(ui->browseSource, &QPushButton::clicked,
- [&]() { BrowseInput(); });
- connect(ui->browseTarget, &QPushButton::clicked,
- [&]() { BrowseOutput(); });
-
- connect(ui->sourceFile, &QLineEdit::textChanged,
- this, &OBSRemux::inputChanged);
-
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),
- 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),
- SIGNAL(clicked()), this, SLOT(close()));
+ SIGNAL(clicked()), this, SLOT(close()));
worker->moveToThread(&remuxer);
remuxer.start();
@@ -79,107 +697,201 @@ OBSRemux::OBSRemux(const char *path, QWidget *parent)
connect(worker_, &RemuxWorker::remuxFinished,
this, &OBSRemux::remuxFinished);
connect(this, &OBSRemux::remux, worker_, &RemuxWorker::remux);
+
+ // Guessing the GCC bug mentioned above would also affect
+ // QPointer? 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;
+ // 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) ==
+ QTStr("Remux.ExitUnfinishedTitle"),
+ QTStr("Remux.ExitUnfinished"),
+ QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::No) ==
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()
{
- Stop();
+ stopRemux();
remuxer.quit();
remuxer.wait();
}
-#define RECORDING_PATTERN "(*.flv *.mp4 *.mov *.mkv *.ts *.m3u8)"
-
-void OBSRemux::BrowseInput()
+void OBSRemux::rowCountChanged(const QModelIndex &, int, int)
{
- QString path = ui->sourceFile->text();
- if (path.isEmpty())
- path = recPath;
-
- path = QFileDialog::getOpenFileName(this,
- QTStr("Remux.SelectRecording"), path,
- QTStr("Remux.OBSRecording") + QString(" ") +
- RECORDING_PATTERN);
-
- inputChanged(path);
+ // 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::inputChanged(const QString &path)
+void OBSRemux::dropEvent(QDropEvent *ev)
{
- if (!QFileInfo::exists(path)) {
- ui->buttonBox->button(QDialogButtonBox::Ok)->
- setEnabled(false);
+ 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 {
+ 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;
}
- 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;
- ui->targetFile->setText(path);
-}
-
-void OBSRemux::Remux()
-{
- if (QFileInfo::exists(ui->targetFile->text()))
- if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"),
- QTStr("Remux.FileExists")) !=
- QMessageBox::Yes)
- return;
-
- 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;
-
- worker->job = job_t(mr_job, media_remux_job_destroy);
- worker->lastProgress = 0.f;
+ // Set all jobs to "pending" first.
+ queueModel->beginProcessing();
ui->progressBar->setVisible(true);
ui->buttonBox->button(QDialogButtonBox::Ok)->
- setEnabled(false);
+ setText(QTStr("Remux.Stop"));
+ setAcceptDrops(false);
- emit remux();
+ remuxNextEntry();
+
+}
+
+void OBSRemux::remuxNextEntry()
+{
+ worker->lastProgress = 0.f;
+
+ 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"));
+
+ 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)
{
- if (!Stop())
+ if (!stopRemux())
event->ignore();
else
QDialog::closeEvent(event);
@@ -187,7 +899,7 @@ void OBSRemux::closeEvent(QCloseEvent *event)
void OBSRemux::reject()
{
- if (!Stop())
+ if (!stopRemux())
return;
QDialog::reject();
@@ -200,27 +912,25 @@ void OBSRemux::updateProgress(float percent)
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)
{
if (abs(lastProgress - percent) < 0.1f)
@@ -230,16 +940,38 @@ void RemuxWorker::UpdateProgress(float percent)
lastProgress = percent;
}
-void RemuxWorker::remux()
+void RemuxWorker::remux(const QString &source, const QString &target)
{
+ isWorking = true;
+
auto callback = [](void *data, float percent)
{
- auto rw = static_cast(data);
+ RemuxWorker *rw = static_cast(data);
+
+ QMutexLocker lock(&rw->updateMutex);
+
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;
- emit remuxFinished(os_event_try(stop) && success);
+ 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);
}
diff --git a/UI/window-remux.hpp b/UI/window-remux.hpp
index d68982871..69ec6451a 100644
--- a/UI/window-remux.hpp
+++ b/UI/window-remux.hpp
@@ -17,6 +17,8 @@
#pragma once
+#include
+#include
#include
#include
#include
@@ -25,11 +27,25 @@
#include
#include
+class RemuxQueueModel;
class RemuxWorker;
+enum RemuxEntryState
+{
+ Empty,
+ Ready,
+ Pending,
+ InProgress,
+ Complete,
+ InvalidPath,
+ Error
+};
+Q_DECLARE_METATYPE(RemuxEntryState);
+
class OBSRemux : public QDialog {
Q_OBJECT
+ QPointer queueModel;
QThread remuxer;
QPointer worker;
@@ -37,11 +53,6 @@ class OBSRemux : public QDialog {
const char *recPath;
- void BrowseInput();
- void BrowseOutput();
-
- bool Stop();
-
virtual void closeEvent(QCloseEvent *event) override;
virtual void reject() override;
@@ -51,32 +62,89 @@ public:
using job_t = std::shared_ptr;
+protected:
+ void dropEvent(QDropEvent *ev);
+ void dragEnterEvent(QDragEnterEvent *ev);
+
+ void remuxNextEntry();
+
private slots:
- void inputChanged(const QString &str);
+ void rowCountChanged(const QModelIndex &parent, int first, int last);
public slots:
void updateProgress(float percent);
void remuxFinished(bool success);
- void Remux();
+ void beginRemux();
+ bool stopRemux();
+ void clearFinished();
+ void clearAll();
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 queue;
+ bool isProcessing;
+
+ static QVariant getIcon(RemuxEntryState state);
+
+ void checkInputPath(int row);
};
class RemuxWorker : public QObject {
Q_OBJECT
- OBSRemux::job_t job;
- os_event_t *stop;
+ QMutex updateMutex;
+
+ bool isWorking;
float lastProgress;
void UpdateProgress(float percent);
- explicit RemuxWorker();
- virtual ~RemuxWorker();
+ explicit RemuxWorker()
+ : isWorking(false) { }
+ virtual ~RemuxWorker() {};
private slots:
- void remux();
+ void remux(const QString &source, const QString &target);
signals:
void updateProgress(float percent);