From 797b3dc1211c674ccf6cd8b162b144bb615bd233 Mon Sep 17 00:00:00 2001 From: nleseul Date: Mon, 15 Jan 2018 19:26:49 -0500 Subject: [PATCH] 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 --- UI/data/locale/en-US.ini | 7 +- UI/data/themes/Dark.qss | 12 + UI/data/themes/Rachni.qss | 16 +- UI/forms/OBSRemux.ui | 117 +++-- UI/window-remux.cpp | 934 +++++++++++++++++++++++++++++++++----- UI/window-remux.hpp | 94 +++- 6 files changed, 997 insertions(+), 183 deletions(-) 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);