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);