diff --git a/UI/window-extra-browsers.cpp b/frontend/components/DelButton.hpp similarity index 100% rename from UI/window-extra-browsers.cpp rename to frontend/components/DelButton.hpp diff --git a/frontend/components/EditWidget.hpp b/frontend/components/EditWidget.hpp new file mode 100644 index 000000000..8f4232e1d --- /dev/null +++ b/frontend/components/EditWidget.hpp @@ -0,0 +1,562 @@ +#include "moc_window-extra-browsers.cpp" +#include "window-dock-browser.hpp" +#include "window-basic-main.hpp" + +#include +#include +#include +#include + +#include + +#include "ui_OBSExtraBrowsers.h" + +using namespace json11; + +#define OBJ_NAME_SUFFIX "_extraBrowser" + +enum class Column : int { + Title, + Url, + Delete, + + Count, +}; + +/* ------------------------------------------------------------------------- */ + +void ExtraBrowsersModel::Reset() +{ + items.clear(); + + OBSBasic *main = OBSBasic::Get(); + + for (int i = 0; i < main->extraBrowserDocks.size(); i++) { + Item item; + item.prevIdx = i; + item.title = main->extraBrowserDockNames[i]; + item.url = main->extraBrowserDockTargets[i]; + items.push_back(item); + } +} + +int ExtraBrowsersModel::rowCount(const QModelIndex &) const +{ + int count = items.size() + 1; + return count; +} + +int ExtraBrowsersModel::columnCount(const QModelIndex &) const +{ + return (int)Column::Count; +} + +QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const +{ + int column = index.column(); + int idx = index.row(); + int count = items.size(); + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (!validRole) + return QVariant(); + + if (idx >= 0 && idx < count) { + switch (column) { + case (int)Column::Title: + return items[idx].title; + case (int)Column::Url: + return items[idx].url; + } + } else if (idx == count) { + switch (column) { + case (int)Column::Title: + return newTitle; + case (int)Column::Url: + return newURL; + } + } + + return QVariant(); +} + +QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (validRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case (int)Column::Title: + return QTStr("ExtraBrowsers.DockName"); + case (int)Column::Url: + return QStringLiteral("URL"); + } + } + + return QVariant(); +} + +Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() != (int)Column::Delete) + flags |= Qt::ItemIsEditable; + + return flags; +} + +class DelButton : public QPushButton { +public: + inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {} + + QPersistentModelIndex index; +}; + +class EditWidget : public QLineEdit { +public: + inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {} + + QPersistentModelIndex index; +}; + +void ExtraBrowsersModel::AddDeleteButton(int idx) +{ + QTableView *widget = reinterpret_cast(parent()); + + QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr); + + QPushButton *del = new DelButton(index); + del->setProperty("class", "icon-trash"); + del->setObjectName("extraPanelDelete"); + del->setMinimumSize(QSize(20, 20)); + connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem); + + widget->setIndexWidget(index, del); + widget->setRowHeight(idx, 20); + widget->setColumnWidth(idx, 20); +} + +void ExtraBrowsersModel::CheckToAdd() +{ + if (newTitle.isEmpty() || newURL.isEmpty()) + return; + + int idx = items.size() + 1; + beginInsertRows(QModelIndex(), idx, idx); + + Item item; + item.prevIdx = -1; + item.title = newTitle; + item.url = newURL; + items.push_back(item); + + newTitle = ""; + newURL = ""; + + endInsertRows(); + + AddDeleteButton(idx - 1); +} + +void ExtraBrowsersModel::UpdateItem(Item &item) +{ + int idx = item.prevIdx; + + OBSBasic *main = OBSBasic::Get(); + BrowserDock *dock = reinterpret_cast(main->extraBrowserDocks[idx].get()); + dock->setWindowTitle(item.title); + dock->setObjectName(item.title + OBJ_NAME_SUFFIX); + + if (main->extraBrowserDockNames[idx] != item.title) { + main->extraBrowserDockNames[idx] = item.title; + dock->toggleViewAction()->setText(item.title); + dock->setTitle(item.title); + } + + if (main->extraBrowserDockTargets[idx] != item.url) { + dock->cefWidget->setURL(QT_TO_UTF8(item.url)); + main->extraBrowserDockTargets[idx] = item.url; + } +} + +void ExtraBrowsersModel::DeleteItem() +{ + QTableView *widget = reinterpret_cast(parent()); + + DelButton *del = reinterpret_cast(sender()); + int row = del->index.row(); + + /* there's some sort of internal bug in Qt and deleting certain index + * widgets or "editors" that can cause a crash inside Qt if the widget + * is not manually removed, at least on 5.7 */ + widget->setIndexWidget(del->index, nullptr); + del->deleteLater(); + + /* --------- */ + + beginRemoveRows(QModelIndex(), row, row); + + int prevIdx = items[row].prevIdx; + items.removeAt(row); + + if (prevIdx != -1) { + int i = 0; + for (; i < deleted.size() && deleted[i] < prevIdx; i++) + ; + deleted.insert(i, prevIdx); + } + + endRemoveRows(); +} + +void ExtraBrowsersModel::Apply() +{ + OBSBasic *main = OBSBasic::Get(); + + for (Item &item : items) { + if (item.prevIdx != -1) { + UpdateItem(item); + } else { + QString uuid = QUuid::createUuid().toString(); + uuid.replace(QRegularExpression("[{}-]"), ""); + main->AddExtraBrowserDock(item.title, item.url, uuid, true); + } + } + + for (int i = deleted.size() - 1; i >= 0; i--) { + int idx = deleted[i]; + main->extraBrowserDockTargets.removeAt(idx); + main->extraBrowserDockNames.removeAt(idx); + main->extraBrowserDocks.removeAt(idx); + } + + if (main->extraBrowserDocks.empty()) + main->extraBrowserMenuDocksSeparator.clear(); + + deleted.clear(); + + Reset(); +} + +void ExtraBrowsersModel::TabSelection(bool forward) +{ + QListView *widget = reinterpret_cast(parent()); + QItemSelectionModel *selModel = widget->selectionModel(); + + QModelIndex sel = selModel->currentIndex(); + int row = sel.row(); + int col = sel.column(); + + switch (sel.column()) { + case (int)Column::Title: + if (!forward) { + if (row == 0) { + return; + } + + row -= 1; + } + + col += 1; + break; + + case (int)Column::Url: + if (forward) { + if (row == items.size()) { + return; + } + + row += 1; + } + + col -= 1; + } + + sel = createIndex(row, col, nullptr); + selModel->setCurrentIndex(sel, QItemSelectionModel::Clear); +} + +void ExtraBrowsersModel::Init() +{ + for (int i = 0; i < items.count(); i++) + AddDeleteButton(i); +} + +/* ------------------------------------------------------------------------- */ + +QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &index) const +{ + QLineEdit *text = new EditWidget(parent, index); + text->installEventFilter(const_cast(this)); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + return text; +} + +void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = reinterpret_cast(editor); + text->blockSignals(true); + text->setText(index.data().toString()); + text->blockSignals(false); +} + +bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event) +{ + QLineEdit *edit = qobject_cast(object); + if (!edit) + return false; + + if (LineEditCanceled(event)) { + RevertText(edit); + } + if (LineEditChanged(event)) { + UpdateText(edit); + + if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Tab) { + model->TabSelection(true); + } else if (keyEvent->key() == Qt::Key_Backtab) { + model->TabSelection(false); + } + } + return true; + } + + return false; +} + +bool ExtraBrowsersDelegate::ValidName(const QString &name) const +{ + for (auto &item : model->items) { + if (name.compare(item.title, Qt::CaseInsensitive) == 0) { + return false; + } + } + return true; +} + +void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString oldText; + if (col == (int)Column::Title) { + oldText = newItem ? model->newTitle : model->items[row].title; + } else { + oldText = newItem ? model->newURL : model->items[row].url; + } + + edit->setText(oldText); +} + +bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString text = edit->text().trimmed(); + + if (!newItem && text.isEmpty()) { + return false; + } + + if (col == (int)Column::Title) { + QString oldText = newItem ? model->newTitle : model->items[row].title; + bool same = oldText.compare(text, Qt::CaseInsensitive) == 0; + + if (!same && !ValidName(text)) { + edit->setText(oldText); + return false; + } + } + + if (!newItem) { + /* if edited existing item, update it*/ + switch (col) { + case (int)Column::Title: + model->items[row].title = text; + break; + case (int)Column::Url: + model->items[row].url = text; + break; + } + } else { + /* if both new values filled out, create new one */ + switch (col) { + case (int)Column::Title: + model->newTitle = text; + break; + case (int)Column::Url: + model->newURL = text; + break; + } + + model->CheckToAdd(); + } + + emit commitData(edit); + return true; +} + +/* ------------------------------------------------------------------------- */ + +OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers) +{ + ui->setupUi(this); + + setAttribute(Qt::WA_DeleteOnClose, true); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + model = new ExtraBrowsersModel(ui->table); + + ui->table->setModel(model); + ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model)); + ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model)); + ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed); + ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); +} + +OBSExtraBrowsers::~OBSExtraBrowsers() {} + +void OBSExtraBrowsers::closeEvent(QCloseEvent *event) +{ + QDialog::closeEvent(event); + model->Apply(); +} + +void OBSExtraBrowsers::on_apply_clicked() +{ + model->Apply(); +} + +/* ------------------------------------------------------------------------- */ + +void OBSBasic::ClearExtraBrowserDocks() +{ + extraBrowserDockTargets.clear(); + extraBrowserDockNames.clear(); + extraBrowserDocks.clear(); +} + +void OBSBasic::LoadExtraBrowserDocks() +{ + const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks"); + + std::string err; + Json json = Json::parse(jsonStr, err); + if (!err.empty()) + return; + + Json::array array = json.array_items(); + if (!array.empty()) + extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator(); + + for (Json &item : array) { + std::string title = item["title"].string_value(); + std::string url = item["url"].string_value(); + std::string uuid = item["uuid"].string_value(); + + AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false); + } +} + +void OBSBasic::SaveExtraBrowserDocks() +{ + Json::array array; + for (int i = 0; i < extraBrowserDocks.size(); i++) { + QDockWidget *dock = extraBrowserDocks[i].get(); + QString title = extraBrowserDockNames[i]; + QString url = extraBrowserDockTargets[i]; + QString uuid = dock->property("uuid").toString(); + Json::object obj{ + {"title", QT_TO_UTF8(title)}, + {"url", QT_TO_UTF8(url)}, + {"uuid", QT_TO_UTF8(uuid)}, + }; + array.push_back(obj); + } + + std::string output = Json(array).dump(); + config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str()); +} + +void OBSBasic::ManageExtraBrowserDocks() +{ + if (!extraBrowsers.isNull()) { + extraBrowsers->show(); + extraBrowsers->raise(); + return; + } + + extraBrowsers = new OBSExtraBrowsers(this); + extraBrowsers->show(); +} + +void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate) +{ + static int panel_version = -1; + if (panel_version == -1) { + panel_version = obs_browser_qcef_version(); + } + + BrowserDock *dock = new BrowserDock(title); + QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid); + bId.replace(QRegularExpression("[{}-]"), ""); + dock->setProperty("uuid", bId); + dock->setObjectName(title + OBJ_NAME_SUFFIX); + dock->resize(460, 600); + dock->setMinimumSize(80, 80); + dock->setWindowTitle(title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + + QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr); + if (browser && panel_version >= 1) + browser->allowAllPopups(true); + + dock->SetWidget(browser); + + /* Add support for Twitch Dashboard panels */ + if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) { + QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/"); + QRegularExpressionMatch match = re.match(url); + QString username = match.captured(1); + if (username.length() > 0) { + std::string script; + script = "Object.defineProperty(document, 'referrer', { get: () => '"; + script += "https://twitch.tv/"; + script += QT_TO_UTF8(username); + script += "/dashboard/live"; + script += "'});"; + browser->setStartupScript(script); + } + } + + AddDockWidget(dock, Qt::RightDockWidgetArea, true); + extraBrowserDocks.push_back(std::shared_ptr(dock)); + extraBrowserDockNames.push_back(title); + extraBrowserDockTargets.push_back(url); + + if (firstCreate) { + dock->setFloating(true); + + QPoint curPos = pos(); + QSize wSizeD2 = size() / 2; + QSize dSizeD2 = dock->size() / 2; + + curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width()); + curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height()); + + dock->move(curPos); + dock->setVisible(true); + } +} diff --git a/UI/window-basic-interaction.cpp b/frontend/dialogs/OBSBasicInteraction.cpp similarity index 100% rename from UI/window-basic-interaction.cpp rename to frontend/dialogs/OBSBasicInteraction.cpp diff --git a/UI/window-basic-interaction.hpp b/frontend/dialogs/OBSBasicInteraction.hpp similarity index 100% rename from UI/window-basic-interaction.hpp rename to frontend/dialogs/OBSBasicInteraction.hpp diff --git a/frontend/dialogs/OBSExtraBrowsers.cpp b/frontend/dialogs/OBSExtraBrowsers.cpp new file mode 100644 index 000000000..8f4232e1d --- /dev/null +++ b/frontend/dialogs/OBSExtraBrowsers.cpp @@ -0,0 +1,562 @@ +#include "moc_window-extra-browsers.cpp" +#include "window-dock-browser.hpp" +#include "window-basic-main.hpp" + +#include +#include +#include +#include + +#include + +#include "ui_OBSExtraBrowsers.h" + +using namespace json11; + +#define OBJ_NAME_SUFFIX "_extraBrowser" + +enum class Column : int { + Title, + Url, + Delete, + + Count, +}; + +/* ------------------------------------------------------------------------- */ + +void ExtraBrowsersModel::Reset() +{ + items.clear(); + + OBSBasic *main = OBSBasic::Get(); + + for (int i = 0; i < main->extraBrowserDocks.size(); i++) { + Item item; + item.prevIdx = i; + item.title = main->extraBrowserDockNames[i]; + item.url = main->extraBrowserDockTargets[i]; + items.push_back(item); + } +} + +int ExtraBrowsersModel::rowCount(const QModelIndex &) const +{ + int count = items.size() + 1; + return count; +} + +int ExtraBrowsersModel::columnCount(const QModelIndex &) const +{ + return (int)Column::Count; +} + +QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const +{ + int column = index.column(); + int idx = index.row(); + int count = items.size(); + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (!validRole) + return QVariant(); + + if (idx >= 0 && idx < count) { + switch (column) { + case (int)Column::Title: + return items[idx].title; + case (int)Column::Url: + return items[idx].url; + } + } else if (idx == count) { + switch (column) { + case (int)Column::Title: + return newTitle; + case (int)Column::Url: + return newURL; + } + } + + return QVariant(); +} + +QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (validRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case (int)Column::Title: + return QTStr("ExtraBrowsers.DockName"); + case (int)Column::Url: + return QStringLiteral("URL"); + } + } + + return QVariant(); +} + +Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() != (int)Column::Delete) + flags |= Qt::ItemIsEditable; + + return flags; +} + +class DelButton : public QPushButton { +public: + inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {} + + QPersistentModelIndex index; +}; + +class EditWidget : public QLineEdit { +public: + inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {} + + QPersistentModelIndex index; +}; + +void ExtraBrowsersModel::AddDeleteButton(int idx) +{ + QTableView *widget = reinterpret_cast(parent()); + + QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr); + + QPushButton *del = new DelButton(index); + del->setProperty("class", "icon-trash"); + del->setObjectName("extraPanelDelete"); + del->setMinimumSize(QSize(20, 20)); + connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem); + + widget->setIndexWidget(index, del); + widget->setRowHeight(idx, 20); + widget->setColumnWidth(idx, 20); +} + +void ExtraBrowsersModel::CheckToAdd() +{ + if (newTitle.isEmpty() || newURL.isEmpty()) + return; + + int idx = items.size() + 1; + beginInsertRows(QModelIndex(), idx, idx); + + Item item; + item.prevIdx = -1; + item.title = newTitle; + item.url = newURL; + items.push_back(item); + + newTitle = ""; + newURL = ""; + + endInsertRows(); + + AddDeleteButton(idx - 1); +} + +void ExtraBrowsersModel::UpdateItem(Item &item) +{ + int idx = item.prevIdx; + + OBSBasic *main = OBSBasic::Get(); + BrowserDock *dock = reinterpret_cast(main->extraBrowserDocks[idx].get()); + dock->setWindowTitle(item.title); + dock->setObjectName(item.title + OBJ_NAME_SUFFIX); + + if (main->extraBrowserDockNames[idx] != item.title) { + main->extraBrowserDockNames[idx] = item.title; + dock->toggleViewAction()->setText(item.title); + dock->setTitle(item.title); + } + + if (main->extraBrowserDockTargets[idx] != item.url) { + dock->cefWidget->setURL(QT_TO_UTF8(item.url)); + main->extraBrowserDockTargets[idx] = item.url; + } +} + +void ExtraBrowsersModel::DeleteItem() +{ + QTableView *widget = reinterpret_cast(parent()); + + DelButton *del = reinterpret_cast(sender()); + int row = del->index.row(); + + /* there's some sort of internal bug in Qt and deleting certain index + * widgets or "editors" that can cause a crash inside Qt if the widget + * is not manually removed, at least on 5.7 */ + widget->setIndexWidget(del->index, nullptr); + del->deleteLater(); + + /* --------- */ + + beginRemoveRows(QModelIndex(), row, row); + + int prevIdx = items[row].prevIdx; + items.removeAt(row); + + if (prevIdx != -1) { + int i = 0; + for (; i < deleted.size() && deleted[i] < prevIdx; i++) + ; + deleted.insert(i, prevIdx); + } + + endRemoveRows(); +} + +void ExtraBrowsersModel::Apply() +{ + OBSBasic *main = OBSBasic::Get(); + + for (Item &item : items) { + if (item.prevIdx != -1) { + UpdateItem(item); + } else { + QString uuid = QUuid::createUuid().toString(); + uuid.replace(QRegularExpression("[{}-]"), ""); + main->AddExtraBrowserDock(item.title, item.url, uuid, true); + } + } + + for (int i = deleted.size() - 1; i >= 0; i--) { + int idx = deleted[i]; + main->extraBrowserDockTargets.removeAt(idx); + main->extraBrowserDockNames.removeAt(idx); + main->extraBrowserDocks.removeAt(idx); + } + + if (main->extraBrowserDocks.empty()) + main->extraBrowserMenuDocksSeparator.clear(); + + deleted.clear(); + + Reset(); +} + +void ExtraBrowsersModel::TabSelection(bool forward) +{ + QListView *widget = reinterpret_cast(parent()); + QItemSelectionModel *selModel = widget->selectionModel(); + + QModelIndex sel = selModel->currentIndex(); + int row = sel.row(); + int col = sel.column(); + + switch (sel.column()) { + case (int)Column::Title: + if (!forward) { + if (row == 0) { + return; + } + + row -= 1; + } + + col += 1; + break; + + case (int)Column::Url: + if (forward) { + if (row == items.size()) { + return; + } + + row += 1; + } + + col -= 1; + } + + sel = createIndex(row, col, nullptr); + selModel->setCurrentIndex(sel, QItemSelectionModel::Clear); +} + +void ExtraBrowsersModel::Init() +{ + for (int i = 0; i < items.count(); i++) + AddDeleteButton(i); +} + +/* ------------------------------------------------------------------------- */ + +QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &index) const +{ + QLineEdit *text = new EditWidget(parent, index); + text->installEventFilter(const_cast(this)); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + return text; +} + +void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = reinterpret_cast(editor); + text->blockSignals(true); + text->setText(index.data().toString()); + text->blockSignals(false); +} + +bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event) +{ + QLineEdit *edit = qobject_cast(object); + if (!edit) + return false; + + if (LineEditCanceled(event)) { + RevertText(edit); + } + if (LineEditChanged(event)) { + UpdateText(edit); + + if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Tab) { + model->TabSelection(true); + } else if (keyEvent->key() == Qt::Key_Backtab) { + model->TabSelection(false); + } + } + return true; + } + + return false; +} + +bool ExtraBrowsersDelegate::ValidName(const QString &name) const +{ + for (auto &item : model->items) { + if (name.compare(item.title, Qt::CaseInsensitive) == 0) { + return false; + } + } + return true; +} + +void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString oldText; + if (col == (int)Column::Title) { + oldText = newItem ? model->newTitle : model->items[row].title; + } else { + oldText = newItem ? model->newURL : model->items[row].url; + } + + edit->setText(oldText); +} + +bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString text = edit->text().trimmed(); + + if (!newItem && text.isEmpty()) { + return false; + } + + if (col == (int)Column::Title) { + QString oldText = newItem ? model->newTitle : model->items[row].title; + bool same = oldText.compare(text, Qt::CaseInsensitive) == 0; + + if (!same && !ValidName(text)) { + edit->setText(oldText); + return false; + } + } + + if (!newItem) { + /* if edited existing item, update it*/ + switch (col) { + case (int)Column::Title: + model->items[row].title = text; + break; + case (int)Column::Url: + model->items[row].url = text; + break; + } + } else { + /* if both new values filled out, create new one */ + switch (col) { + case (int)Column::Title: + model->newTitle = text; + break; + case (int)Column::Url: + model->newURL = text; + break; + } + + model->CheckToAdd(); + } + + emit commitData(edit); + return true; +} + +/* ------------------------------------------------------------------------- */ + +OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers) +{ + ui->setupUi(this); + + setAttribute(Qt::WA_DeleteOnClose, true); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + model = new ExtraBrowsersModel(ui->table); + + ui->table->setModel(model); + ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model)); + ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model)); + ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed); + ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); +} + +OBSExtraBrowsers::~OBSExtraBrowsers() {} + +void OBSExtraBrowsers::closeEvent(QCloseEvent *event) +{ + QDialog::closeEvent(event); + model->Apply(); +} + +void OBSExtraBrowsers::on_apply_clicked() +{ + model->Apply(); +} + +/* ------------------------------------------------------------------------- */ + +void OBSBasic::ClearExtraBrowserDocks() +{ + extraBrowserDockTargets.clear(); + extraBrowserDockNames.clear(); + extraBrowserDocks.clear(); +} + +void OBSBasic::LoadExtraBrowserDocks() +{ + const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks"); + + std::string err; + Json json = Json::parse(jsonStr, err); + if (!err.empty()) + return; + + Json::array array = json.array_items(); + if (!array.empty()) + extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator(); + + for (Json &item : array) { + std::string title = item["title"].string_value(); + std::string url = item["url"].string_value(); + std::string uuid = item["uuid"].string_value(); + + AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false); + } +} + +void OBSBasic::SaveExtraBrowserDocks() +{ + Json::array array; + for (int i = 0; i < extraBrowserDocks.size(); i++) { + QDockWidget *dock = extraBrowserDocks[i].get(); + QString title = extraBrowserDockNames[i]; + QString url = extraBrowserDockTargets[i]; + QString uuid = dock->property("uuid").toString(); + Json::object obj{ + {"title", QT_TO_UTF8(title)}, + {"url", QT_TO_UTF8(url)}, + {"uuid", QT_TO_UTF8(uuid)}, + }; + array.push_back(obj); + } + + std::string output = Json(array).dump(); + config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str()); +} + +void OBSBasic::ManageExtraBrowserDocks() +{ + if (!extraBrowsers.isNull()) { + extraBrowsers->show(); + extraBrowsers->raise(); + return; + } + + extraBrowsers = new OBSExtraBrowsers(this); + extraBrowsers->show(); +} + +void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate) +{ + static int panel_version = -1; + if (panel_version == -1) { + panel_version = obs_browser_qcef_version(); + } + + BrowserDock *dock = new BrowserDock(title); + QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid); + bId.replace(QRegularExpression("[{}-]"), ""); + dock->setProperty("uuid", bId); + dock->setObjectName(title + OBJ_NAME_SUFFIX); + dock->resize(460, 600); + dock->setMinimumSize(80, 80); + dock->setWindowTitle(title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + + QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr); + if (browser && panel_version >= 1) + browser->allowAllPopups(true); + + dock->SetWidget(browser); + + /* Add support for Twitch Dashboard panels */ + if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) { + QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/"); + QRegularExpressionMatch match = re.match(url); + QString username = match.captured(1); + if (username.length() > 0) { + std::string script; + script = "Object.defineProperty(document, 'referrer', { get: () => '"; + script += "https://twitch.tv/"; + script += QT_TO_UTF8(username); + script += "/dashboard/live"; + script += "'});"; + browser->setStartupScript(script); + } + } + + AddDockWidget(dock, Qt::RightDockWidgetArea, true); + extraBrowserDocks.push_back(std::shared_ptr(dock)); + extraBrowserDockNames.push_back(title); + extraBrowserDockTargets.push_back(url); + + if (firstCreate) { + dock->setFloating(true); + + QPoint curPos = pos(); + QSize wSizeD2 = size() / 2; + QSize dSizeD2 = dock->size() / 2; + + curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width()); + curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height()); + + dock->move(curPos); + dock->setVisible(true); + } +} diff --git a/UI/window-extra-browsers.hpp b/frontend/dialogs/OBSExtraBrowsers.hpp similarity index 100% rename from UI/window-extra-browsers.hpp rename to frontend/dialogs/OBSExtraBrowsers.hpp diff --git a/UI/window-missing-files.cpp b/frontend/dialogs/OBSMissingFiles.cpp similarity index 100% rename from UI/window-missing-files.cpp rename to frontend/dialogs/OBSMissingFiles.cpp diff --git a/UI/window-missing-files.hpp b/frontend/dialogs/OBSMissingFiles.hpp similarity index 100% rename from UI/window-missing-files.hpp rename to frontend/dialogs/OBSMissingFiles.hpp diff --git a/UI/window-remux.cpp b/frontend/dialogs/OBSRemux.cpp similarity index 100% rename from UI/window-remux.cpp rename to frontend/dialogs/OBSRemux.cpp diff --git a/UI/window-remux.hpp b/frontend/dialogs/OBSRemux.hpp similarity index 100% rename from UI/window-remux.hpp rename to frontend/dialogs/OBSRemux.hpp diff --git a/frontend/utility/ExtraBrowsersDelegate.cpp b/frontend/utility/ExtraBrowsersDelegate.cpp new file mode 100644 index 000000000..8f4232e1d --- /dev/null +++ b/frontend/utility/ExtraBrowsersDelegate.cpp @@ -0,0 +1,562 @@ +#include "moc_window-extra-browsers.cpp" +#include "window-dock-browser.hpp" +#include "window-basic-main.hpp" + +#include +#include +#include +#include + +#include + +#include "ui_OBSExtraBrowsers.h" + +using namespace json11; + +#define OBJ_NAME_SUFFIX "_extraBrowser" + +enum class Column : int { + Title, + Url, + Delete, + + Count, +}; + +/* ------------------------------------------------------------------------- */ + +void ExtraBrowsersModel::Reset() +{ + items.clear(); + + OBSBasic *main = OBSBasic::Get(); + + for (int i = 0; i < main->extraBrowserDocks.size(); i++) { + Item item; + item.prevIdx = i; + item.title = main->extraBrowserDockNames[i]; + item.url = main->extraBrowserDockTargets[i]; + items.push_back(item); + } +} + +int ExtraBrowsersModel::rowCount(const QModelIndex &) const +{ + int count = items.size() + 1; + return count; +} + +int ExtraBrowsersModel::columnCount(const QModelIndex &) const +{ + return (int)Column::Count; +} + +QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const +{ + int column = index.column(); + int idx = index.row(); + int count = items.size(); + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (!validRole) + return QVariant(); + + if (idx >= 0 && idx < count) { + switch (column) { + case (int)Column::Title: + return items[idx].title; + case (int)Column::Url: + return items[idx].url; + } + } else if (idx == count) { + switch (column) { + case (int)Column::Title: + return newTitle; + case (int)Column::Url: + return newURL; + } + } + + return QVariant(); +} + +QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (validRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case (int)Column::Title: + return QTStr("ExtraBrowsers.DockName"); + case (int)Column::Url: + return QStringLiteral("URL"); + } + } + + return QVariant(); +} + +Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() != (int)Column::Delete) + flags |= Qt::ItemIsEditable; + + return flags; +} + +class DelButton : public QPushButton { +public: + inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {} + + QPersistentModelIndex index; +}; + +class EditWidget : public QLineEdit { +public: + inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {} + + QPersistentModelIndex index; +}; + +void ExtraBrowsersModel::AddDeleteButton(int idx) +{ + QTableView *widget = reinterpret_cast(parent()); + + QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr); + + QPushButton *del = new DelButton(index); + del->setProperty("class", "icon-trash"); + del->setObjectName("extraPanelDelete"); + del->setMinimumSize(QSize(20, 20)); + connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem); + + widget->setIndexWidget(index, del); + widget->setRowHeight(idx, 20); + widget->setColumnWidth(idx, 20); +} + +void ExtraBrowsersModel::CheckToAdd() +{ + if (newTitle.isEmpty() || newURL.isEmpty()) + return; + + int idx = items.size() + 1; + beginInsertRows(QModelIndex(), idx, idx); + + Item item; + item.prevIdx = -1; + item.title = newTitle; + item.url = newURL; + items.push_back(item); + + newTitle = ""; + newURL = ""; + + endInsertRows(); + + AddDeleteButton(idx - 1); +} + +void ExtraBrowsersModel::UpdateItem(Item &item) +{ + int idx = item.prevIdx; + + OBSBasic *main = OBSBasic::Get(); + BrowserDock *dock = reinterpret_cast(main->extraBrowserDocks[idx].get()); + dock->setWindowTitle(item.title); + dock->setObjectName(item.title + OBJ_NAME_SUFFIX); + + if (main->extraBrowserDockNames[idx] != item.title) { + main->extraBrowserDockNames[idx] = item.title; + dock->toggleViewAction()->setText(item.title); + dock->setTitle(item.title); + } + + if (main->extraBrowserDockTargets[idx] != item.url) { + dock->cefWidget->setURL(QT_TO_UTF8(item.url)); + main->extraBrowserDockTargets[idx] = item.url; + } +} + +void ExtraBrowsersModel::DeleteItem() +{ + QTableView *widget = reinterpret_cast(parent()); + + DelButton *del = reinterpret_cast(sender()); + int row = del->index.row(); + + /* there's some sort of internal bug in Qt and deleting certain index + * widgets or "editors" that can cause a crash inside Qt if the widget + * is not manually removed, at least on 5.7 */ + widget->setIndexWidget(del->index, nullptr); + del->deleteLater(); + + /* --------- */ + + beginRemoveRows(QModelIndex(), row, row); + + int prevIdx = items[row].prevIdx; + items.removeAt(row); + + if (prevIdx != -1) { + int i = 0; + for (; i < deleted.size() && deleted[i] < prevIdx; i++) + ; + deleted.insert(i, prevIdx); + } + + endRemoveRows(); +} + +void ExtraBrowsersModel::Apply() +{ + OBSBasic *main = OBSBasic::Get(); + + for (Item &item : items) { + if (item.prevIdx != -1) { + UpdateItem(item); + } else { + QString uuid = QUuid::createUuid().toString(); + uuid.replace(QRegularExpression("[{}-]"), ""); + main->AddExtraBrowserDock(item.title, item.url, uuid, true); + } + } + + for (int i = deleted.size() - 1; i >= 0; i--) { + int idx = deleted[i]; + main->extraBrowserDockTargets.removeAt(idx); + main->extraBrowserDockNames.removeAt(idx); + main->extraBrowserDocks.removeAt(idx); + } + + if (main->extraBrowserDocks.empty()) + main->extraBrowserMenuDocksSeparator.clear(); + + deleted.clear(); + + Reset(); +} + +void ExtraBrowsersModel::TabSelection(bool forward) +{ + QListView *widget = reinterpret_cast(parent()); + QItemSelectionModel *selModel = widget->selectionModel(); + + QModelIndex sel = selModel->currentIndex(); + int row = sel.row(); + int col = sel.column(); + + switch (sel.column()) { + case (int)Column::Title: + if (!forward) { + if (row == 0) { + return; + } + + row -= 1; + } + + col += 1; + break; + + case (int)Column::Url: + if (forward) { + if (row == items.size()) { + return; + } + + row += 1; + } + + col -= 1; + } + + sel = createIndex(row, col, nullptr); + selModel->setCurrentIndex(sel, QItemSelectionModel::Clear); +} + +void ExtraBrowsersModel::Init() +{ + for (int i = 0; i < items.count(); i++) + AddDeleteButton(i); +} + +/* ------------------------------------------------------------------------- */ + +QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &index) const +{ + QLineEdit *text = new EditWidget(parent, index); + text->installEventFilter(const_cast(this)); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + return text; +} + +void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = reinterpret_cast(editor); + text->blockSignals(true); + text->setText(index.data().toString()); + text->blockSignals(false); +} + +bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event) +{ + QLineEdit *edit = qobject_cast(object); + if (!edit) + return false; + + if (LineEditCanceled(event)) { + RevertText(edit); + } + if (LineEditChanged(event)) { + UpdateText(edit); + + if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Tab) { + model->TabSelection(true); + } else if (keyEvent->key() == Qt::Key_Backtab) { + model->TabSelection(false); + } + } + return true; + } + + return false; +} + +bool ExtraBrowsersDelegate::ValidName(const QString &name) const +{ + for (auto &item : model->items) { + if (name.compare(item.title, Qt::CaseInsensitive) == 0) { + return false; + } + } + return true; +} + +void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString oldText; + if (col == (int)Column::Title) { + oldText = newItem ? model->newTitle : model->items[row].title; + } else { + oldText = newItem ? model->newURL : model->items[row].url; + } + + edit->setText(oldText); +} + +bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString text = edit->text().trimmed(); + + if (!newItem && text.isEmpty()) { + return false; + } + + if (col == (int)Column::Title) { + QString oldText = newItem ? model->newTitle : model->items[row].title; + bool same = oldText.compare(text, Qt::CaseInsensitive) == 0; + + if (!same && !ValidName(text)) { + edit->setText(oldText); + return false; + } + } + + if (!newItem) { + /* if edited existing item, update it*/ + switch (col) { + case (int)Column::Title: + model->items[row].title = text; + break; + case (int)Column::Url: + model->items[row].url = text; + break; + } + } else { + /* if both new values filled out, create new one */ + switch (col) { + case (int)Column::Title: + model->newTitle = text; + break; + case (int)Column::Url: + model->newURL = text; + break; + } + + model->CheckToAdd(); + } + + emit commitData(edit); + return true; +} + +/* ------------------------------------------------------------------------- */ + +OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers) +{ + ui->setupUi(this); + + setAttribute(Qt::WA_DeleteOnClose, true); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + model = new ExtraBrowsersModel(ui->table); + + ui->table->setModel(model); + ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model)); + ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model)); + ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed); + ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); +} + +OBSExtraBrowsers::~OBSExtraBrowsers() {} + +void OBSExtraBrowsers::closeEvent(QCloseEvent *event) +{ + QDialog::closeEvent(event); + model->Apply(); +} + +void OBSExtraBrowsers::on_apply_clicked() +{ + model->Apply(); +} + +/* ------------------------------------------------------------------------- */ + +void OBSBasic::ClearExtraBrowserDocks() +{ + extraBrowserDockTargets.clear(); + extraBrowserDockNames.clear(); + extraBrowserDocks.clear(); +} + +void OBSBasic::LoadExtraBrowserDocks() +{ + const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks"); + + std::string err; + Json json = Json::parse(jsonStr, err); + if (!err.empty()) + return; + + Json::array array = json.array_items(); + if (!array.empty()) + extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator(); + + for (Json &item : array) { + std::string title = item["title"].string_value(); + std::string url = item["url"].string_value(); + std::string uuid = item["uuid"].string_value(); + + AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false); + } +} + +void OBSBasic::SaveExtraBrowserDocks() +{ + Json::array array; + for (int i = 0; i < extraBrowserDocks.size(); i++) { + QDockWidget *dock = extraBrowserDocks[i].get(); + QString title = extraBrowserDockNames[i]; + QString url = extraBrowserDockTargets[i]; + QString uuid = dock->property("uuid").toString(); + Json::object obj{ + {"title", QT_TO_UTF8(title)}, + {"url", QT_TO_UTF8(url)}, + {"uuid", QT_TO_UTF8(uuid)}, + }; + array.push_back(obj); + } + + std::string output = Json(array).dump(); + config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str()); +} + +void OBSBasic::ManageExtraBrowserDocks() +{ + if (!extraBrowsers.isNull()) { + extraBrowsers->show(); + extraBrowsers->raise(); + return; + } + + extraBrowsers = new OBSExtraBrowsers(this); + extraBrowsers->show(); +} + +void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate) +{ + static int panel_version = -1; + if (panel_version == -1) { + panel_version = obs_browser_qcef_version(); + } + + BrowserDock *dock = new BrowserDock(title); + QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid); + bId.replace(QRegularExpression("[{}-]"), ""); + dock->setProperty("uuid", bId); + dock->setObjectName(title + OBJ_NAME_SUFFIX); + dock->resize(460, 600); + dock->setMinimumSize(80, 80); + dock->setWindowTitle(title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + + QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr); + if (browser && panel_version >= 1) + browser->allowAllPopups(true); + + dock->SetWidget(browser); + + /* Add support for Twitch Dashboard panels */ + if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) { + QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/"); + QRegularExpressionMatch match = re.match(url); + QString username = match.captured(1); + if (username.length() > 0) { + std::string script; + script = "Object.defineProperty(document, 'referrer', { get: () => '"; + script += "https://twitch.tv/"; + script += QT_TO_UTF8(username); + script += "/dashboard/live"; + script += "'});"; + browser->setStartupScript(script); + } + } + + AddDockWidget(dock, Qt::RightDockWidgetArea, true); + extraBrowserDocks.push_back(std::shared_ptr(dock)); + extraBrowserDockNames.push_back(title); + extraBrowserDockTargets.push_back(url); + + if (firstCreate) { + dock->setFloating(true); + + QPoint curPos = pos(); + QSize wSizeD2 = size() / 2; + QSize dSizeD2 = dock->size() / 2; + + curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width()); + curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height()); + + dock->move(curPos); + dock->setVisible(true); + } +} diff --git a/frontend/utility/ExtraBrowsersDelegate.hpp b/frontend/utility/ExtraBrowsersDelegate.hpp new file mode 100644 index 000000000..690d784dd --- /dev/null +++ b/frontend/utility/ExtraBrowsersDelegate.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +class Ui_OBSExtraBrowsers; +class ExtraBrowsersModel; + +class QCefWidget; + +class OBSExtraBrowsers : public QDialog { + Q_OBJECT + + std::unique_ptr ui; + ExtraBrowsersModel *model; + +public: + OBSExtraBrowsers(QWidget *parent); + ~OBSExtraBrowsers(); + + void closeEvent(QCloseEvent *event) override; + +public slots: + void on_apply_clicked(); +}; + +class ExtraBrowsersModel : public QAbstractTableModel { + Q_OBJECT + +public: + inline ExtraBrowsersModel(QObject *parent = nullptr) : QAbstractTableModel(parent) + { + Reset(); + QMetaObject::invokeMethod(this, "Init", Qt::QueuedConnection); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + + struct Item { + int prevIdx; + QString title; + QString url; + }; + + void TabSelection(bool forward); + + void AddDeleteButton(int idx); + void Reset(); + void CheckToAdd(); + void UpdateItem(Item &item); + void DeleteItem(); + void Apply(); + + QVector items; + QVector deleted; + + QString newTitle; + QString newURL; + +public slots: + void Init(); +}; + +class ExtraBrowsersDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + inline ExtraBrowsersDelegate(ExtraBrowsersModel *model_) : QStyledItemDelegate(nullptr), model(model_) {} + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + + void setEditorData(QWidget *editor, const QModelIndex &index) const override; + + bool eventFilter(QObject *object, QEvent *event) override; + void RevertText(QLineEdit *edit); + bool UpdateText(QLineEdit *edit); + bool ValidName(const QString &text) const; + + ExtraBrowsersModel *model; +}; diff --git a/frontend/utility/ExtraBrowsersModel.cpp b/frontend/utility/ExtraBrowsersModel.cpp new file mode 100644 index 000000000..8f4232e1d --- /dev/null +++ b/frontend/utility/ExtraBrowsersModel.cpp @@ -0,0 +1,562 @@ +#include "moc_window-extra-browsers.cpp" +#include "window-dock-browser.hpp" +#include "window-basic-main.hpp" + +#include +#include +#include +#include + +#include + +#include "ui_OBSExtraBrowsers.h" + +using namespace json11; + +#define OBJ_NAME_SUFFIX "_extraBrowser" + +enum class Column : int { + Title, + Url, + Delete, + + Count, +}; + +/* ------------------------------------------------------------------------- */ + +void ExtraBrowsersModel::Reset() +{ + items.clear(); + + OBSBasic *main = OBSBasic::Get(); + + for (int i = 0; i < main->extraBrowserDocks.size(); i++) { + Item item; + item.prevIdx = i; + item.title = main->extraBrowserDockNames[i]; + item.url = main->extraBrowserDockTargets[i]; + items.push_back(item); + } +} + +int ExtraBrowsersModel::rowCount(const QModelIndex &) const +{ + int count = items.size() + 1; + return count; +} + +int ExtraBrowsersModel::columnCount(const QModelIndex &) const +{ + return (int)Column::Count; +} + +QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const +{ + int column = index.column(); + int idx = index.row(); + int count = items.size(); + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (!validRole) + return QVariant(); + + if (idx >= 0 && idx < count) { + switch (column) { + case (int)Column::Title: + return items[idx].title; + case (int)Column::Url: + return items[idx].url; + } + } else if (idx == count) { + switch (column) { + case (int)Column::Title: + return newTitle; + case (int)Column::Url: + return newURL; + } + } + + return QVariant(); +} + +QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole; + + if (validRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case (int)Column::Title: + return QTStr("ExtraBrowsers.DockName"); + case (int)Column::Url: + return QStringLiteral("URL"); + } + } + + return QVariant(); +} + +Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() != (int)Column::Delete) + flags |= Qt::ItemIsEditable; + + return flags; +} + +class DelButton : public QPushButton { +public: + inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {} + + QPersistentModelIndex index; +}; + +class EditWidget : public QLineEdit { +public: + inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {} + + QPersistentModelIndex index; +}; + +void ExtraBrowsersModel::AddDeleteButton(int idx) +{ + QTableView *widget = reinterpret_cast(parent()); + + QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr); + + QPushButton *del = new DelButton(index); + del->setProperty("class", "icon-trash"); + del->setObjectName("extraPanelDelete"); + del->setMinimumSize(QSize(20, 20)); + connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem); + + widget->setIndexWidget(index, del); + widget->setRowHeight(idx, 20); + widget->setColumnWidth(idx, 20); +} + +void ExtraBrowsersModel::CheckToAdd() +{ + if (newTitle.isEmpty() || newURL.isEmpty()) + return; + + int idx = items.size() + 1; + beginInsertRows(QModelIndex(), idx, idx); + + Item item; + item.prevIdx = -1; + item.title = newTitle; + item.url = newURL; + items.push_back(item); + + newTitle = ""; + newURL = ""; + + endInsertRows(); + + AddDeleteButton(idx - 1); +} + +void ExtraBrowsersModel::UpdateItem(Item &item) +{ + int idx = item.prevIdx; + + OBSBasic *main = OBSBasic::Get(); + BrowserDock *dock = reinterpret_cast(main->extraBrowserDocks[idx].get()); + dock->setWindowTitle(item.title); + dock->setObjectName(item.title + OBJ_NAME_SUFFIX); + + if (main->extraBrowserDockNames[idx] != item.title) { + main->extraBrowserDockNames[idx] = item.title; + dock->toggleViewAction()->setText(item.title); + dock->setTitle(item.title); + } + + if (main->extraBrowserDockTargets[idx] != item.url) { + dock->cefWidget->setURL(QT_TO_UTF8(item.url)); + main->extraBrowserDockTargets[idx] = item.url; + } +} + +void ExtraBrowsersModel::DeleteItem() +{ + QTableView *widget = reinterpret_cast(parent()); + + DelButton *del = reinterpret_cast(sender()); + int row = del->index.row(); + + /* there's some sort of internal bug in Qt and deleting certain index + * widgets or "editors" that can cause a crash inside Qt if the widget + * is not manually removed, at least on 5.7 */ + widget->setIndexWidget(del->index, nullptr); + del->deleteLater(); + + /* --------- */ + + beginRemoveRows(QModelIndex(), row, row); + + int prevIdx = items[row].prevIdx; + items.removeAt(row); + + if (prevIdx != -1) { + int i = 0; + for (; i < deleted.size() && deleted[i] < prevIdx; i++) + ; + deleted.insert(i, prevIdx); + } + + endRemoveRows(); +} + +void ExtraBrowsersModel::Apply() +{ + OBSBasic *main = OBSBasic::Get(); + + for (Item &item : items) { + if (item.prevIdx != -1) { + UpdateItem(item); + } else { + QString uuid = QUuid::createUuid().toString(); + uuid.replace(QRegularExpression("[{}-]"), ""); + main->AddExtraBrowserDock(item.title, item.url, uuid, true); + } + } + + for (int i = deleted.size() - 1; i >= 0; i--) { + int idx = deleted[i]; + main->extraBrowserDockTargets.removeAt(idx); + main->extraBrowserDockNames.removeAt(idx); + main->extraBrowserDocks.removeAt(idx); + } + + if (main->extraBrowserDocks.empty()) + main->extraBrowserMenuDocksSeparator.clear(); + + deleted.clear(); + + Reset(); +} + +void ExtraBrowsersModel::TabSelection(bool forward) +{ + QListView *widget = reinterpret_cast(parent()); + QItemSelectionModel *selModel = widget->selectionModel(); + + QModelIndex sel = selModel->currentIndex(); + int row = sel.row(); + int col = sel.column(); + + switch (sel.column()) { + case (int)Column::Title: + if (!forward) { + if (row == 0) { + return; + } + + row -= 1; + } + + col += 1; + break; + + case (int)Column::Url: + if (forward) { + if (row == items.size()) { + return; + } + + row += 1; + } + + col -= 1; + } + + sel = createIndex(row, col, nullptr); + selModel->setCurrentIndex(sel, QItemSelectionModel::Clear); +} + +void ExtraBrowsersModel::Init() +{ + for (int i = 0; i < items.count(); i++) + AddDeleteButton(i); +} + +/* ------------------------------------------------------------------------- */ + +QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &, + const QModelIndex &index) const +{ + QLineEdit *text = new EditWidget(parent, index); + text->installEventFilter(const_cast(this)); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + return text; +} + +void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = reinterpret_cast(editor); + text->blockSignals(true); + text->setText(index.data().toString()); + text->blockSignals(false); +} + +bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event) +{ + QLineEdit *edit = qobject_cast(object); + if (!edit) + return false; + + if (LineEditCanceled(event)) { + RevertText(edit); + } + if (LineEditChanged(event)) { + UpdateText(edit); + + if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Tab) { + model->TabSelection(true); + } else if (keyEvent->key() == Qt::Key_Backtab) { + model->TabSelection(false); + } + } + return true; + } + + return false; +} + +bool ExtraBrowsersDelegate::ValidName(const QString &name) const +{ + for (auto &item : model->items) { + if (name.compare(item.title, Qt::CaseInsensitive) == 0) { + return false; + } + } + return true; +} + +void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString oldText; + if (col == (int)Column::Title) { + oldText = newItem ? model->newTitle : model->items[row].title; + } else { + oldText = newItem ? model->newURL : model->items[row].url; + } + + edit->setText(oldText); +} + +bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_) +{ + EditWidget *edit = reinterpret_cast(edit_); + int row = edit->index.row(); + int col = edit->index.column(); + bool newItem = (row == model->items.size()); + + QString text = edit->text().trimmed(); + + if (!newItem && text.isEmpty()) { + return false; + } + + if (col == (int)Column::Title) { + QString oldText = newItem ? model->newTitle : model->items[row].title; + bool same = oldText.compare(text, Qt::CaseInsensitive) == 0; + + if (!same && !ValidName(text)) { + edit->setText(oldText); + return false; + } + } + + if (!newItem) { + /* if edited existing item, update it*/ + switch (col) { + case (int)Column::Title: + model->items[row].title = text; + break; + case (int)Column::Url: + model->items[row].url = text; + break; + } + } else { + /* if both new values filled out, create new one */ + switch (col) { + case (int)Column::Title: + model->newTitle = text; + break; + case (int)Column::Url: + model->newURL = text; + break; + } + + model->CheckToAdd(); + } + + emit commitData(edit); + return true; +} + +/* ------------------------------------------------------------------------- */ + +OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers) +{ + ui->setupUi(this); + + setAttribute(Qt::WA_DeleteOnClose, true); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + model = new ExtraBrowsersModel(ui->table); + + ui->table->setModel(model); + ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model)); + ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model)); + ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed); + ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); +} + +OBSExtraBrowsers::~OBSExtraBrowsers() {} + +void OBSExtraBrowsers::closeEvent(QCloseEvent *event) +{ + QDialog::closeEvent(event); + model->Apply(); +} + +void OBSExtraBrowsers::on_apply_clicked() +{ + model->Apply(); +} + +/* ------------------------------------------------------------------------- */ + +void OBSBasic::ClearExtraBrowserDocks() +{ + extraBrowserDockTargets.clear(); + extraBrowserDockNames.clear(); + extraBrowserDocks.clear(); +} + +void OBSBasic::LoadExtraBrowserDocks() +{ + const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks"); + + std::string err; + Json json = Json::parse(jsonStr, err); + if (!err.empty()) + return; + + Json::array array = json.array_items(); + if (!array.empty()) + extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator(); + + for (Json &item : array) { + std::string title = item["title"].string_value(); + std::string url = item["url"].string_value(); + std::string uuid = item["uuid"].string_value(); + + AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false); + } +} + +void OBSBasic::SaveExtraBrowserDocks() +{ + Json::array array; + for (int i = 0; i < extraBrowserDocks.size(); i++) { + QDockWidget *dock = extraBrowserDocks[i].get(); + QString title = extraBrowserDockNames[i]; + QString url = extraBrowserDockTargets[i]; + QString uuid = dock->property("uuid").toString(); + Json::object obj{ + {"title", QT_TO_UTF8(title)}, + {"url", QT_TO_UTF8(url)}, + {"uuid", QT_TO_UTF8(uuid)}, + }; + array.push_back(obj); + } + + std::string output = Json(array).dump(); + config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str()); +} + +void OBSBasic::ManageExtraBrowserDocks() +{ + if (!extraBrowsers.isNull()) { + extraBrowsers->show(); + extraBrowsers->raise(); + return; + } + + extraBrowsers = new OBSExtraBrowsers(this); + extraBrowsers->show(); +} + +void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate) +{ + static int panel_version = -1; + if (panel_version == -1) { + panel_version = obs_browser_qcef_version(); + } + + BrowserDock *dock = new BrowserDock(title); + QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid); + bId.replace(QRegularExpression("[{}-]"), ""); + dock->setProperty("uuid", bId); + dock->setObjectName(title + OBJ_NAME_SUFFIX); + dock->resize(460, 600); + dock->setMinimumSize(80, 80); + dock->setWindowTitle(title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + + QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr); + if (browser && panel_version >= 1) + browser->allowAllPopups(true); + + dock->SetWidget(browser); + + /* Add support for Twitch Dashboard panels */ + if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) { + QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/"); + QRegularExpressionMatch match = re.match(url); + QString username = match.captured(1); + if (username.length() > 0) { + std::string script; + script = "Object.defineProperty(document, 'referrer', { get: () => '"; + script += "https://twitch.tv/"; + script += QT_TO_UTF8(username); + script += "/dashboard/live"; + script += "'});"; + browser->setStartupScript(script); + } + } + + AddDockWidget(dock, Qt::RightDockWidgetArea, true); + extraBrowserDocks.push_back(std::shared_ptr(dock)); + extraBrowserDockNames.push_back(title); + extraBrowserDockTargets.push_back(url); + + if (firstCreate) { + dock->setFloating(true); + + QPoint curPos = pos(); + QSize wSizeD2 = size() / 2; + QSize dSizeD2 = dock->size() / 2; + + curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width()); + curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height()); + + dock->move(curPos); + dock->setVisible(true); + } +} diff --git a/frontend/utility/ExtraBrowsersModel.hpp b/frontend/utility/ExtraBrowsersModel.hpp new file mode 100644 index 000000000..690d784dd --- /dev/null +++ b/frontend/utility/ExtraBrowsersModel.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include + +class Ui_OBSExtraBrowsers; +class ExtraBrowsersModel; + +class QCefWidget; + +class OBSExtraBrowsers : public QDialog { + Q_OBJECT + + std::unique_ptr ui; + ExtraBrowsersModel *model; + +public: + OBSExtraBrowsers(QWidget *parent); + ~OBSExtraBrowsers(); + + void closeEvent(QCloseEvent *event) override; + +public slots: + void on_apply_clicked(); +}; + +class ExtraBrowsersModel : public QAbstractTableModel { + Q_OBJECT + +public: + inline ExtraBrowsersModel(QObject *parent = nullptr) : QAbstractTableModel(parent) + { + Reset(); + QMetaObject::invokeMethod(this, "Init", Qt::QueuedConnection); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + + struct Item { + int prevIdx; + QString title; + QString url; + }; + + void TabSelection(bool forward); + + void AddDeleteButton(int idx); + void Reset(); + void CheckToAdd(); + void UpdateItem(Item &item); + void DeleteItem(); + void Apply(); + + QVector items; + QVector deleted; + + QString newTitle; + QString newURL; + +public slots: + void Init(); +}; + +class ExtraBrowsersDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + inline ExtraBrowsersDelegate(ExtraBrowsersModel *model_) : QStyledItemDelegate(nullptr), model(model_) {} + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + + void setEditorData(QWidget *editor, const QModelIndex &index) const override; + + bool eventFilter(QObject *object, QEvent *event) override; + void RevertText(QLineEdit *edit); + bool UpdateText(QLineEdit *edit); + bool ValidName(const QString &text) const; + + ExtraBrowsersModel *model; +}; diff --git a/frontend/utility/MissingFilesModel.cpp b/frontend/utility/MissingFilesModel.cpp new file mode 100644 index 000000000..57e641a74 --- /dev/null +++ b/frontend/utility/MissingFilesModel.cpp @@ -0,0 +1,542 @@ +/****************************************************************************** + Copyright (C) 2019 by Dillon Pentz + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "moc_window-missing-files.cpp" +#include "window-basic-main.hpp" + +#include "obs-app.hpp" + +#include +#include +#include + +#include + +enum MissingFilesColumn { + Source, + OriginalPath, + NewPath, + State, + + Count +}; + +enum MissingFilesRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole }; + +/********************************************************** + Delegate - Presents cells in the grid. +**********************************************************/ + +MissingFilesPathItemDelegate::MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath) + : QStyledItemDelegate(), + isOutput(isOutput), + defaultPath(defaultPath) +{ +} + +QWidget *MissingFilesPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &) const +{ + QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::PushButton); + + QWidget *container = new QWidget(parent); + + auto browseCallback = [this, container]() { + const_cast(this)->handleBrowse(container); + }; + + auto clearCallback = [this, container]() { + const_cast(this)->handleClear(container); + }; + + QHBoxLayout *layout = new QHBoxLayout(); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QLineEdit *text = new QLineEdit(); + text->setObjectName(QStringLiteral("text")); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + layout->addWidget(text); + + QToolButton *browseButton = new QToolButton(); + browseButton->setText("..."); + browseButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(browseButton); + + container->connect(browseButton, &QToolButton::clicked, browseCallback); + + // The "clear" button is not shown in input cells + if (isOutput) { + QToolButton *clearButton = new QToolButton(); + clearButton->setText("X"); + clearButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(clearButton); + + container->connect(clearButton, &QToolButton::clicked, clearCallback); + } + + container->setLayout(layout); + container->setFocusProxy(text); + + return container; +} + +void MissingFilesPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = editor->findChild(); + text->setText(index.data().toString()); + + editor->setProperty(PATH_LIST_PROP, QVariant()); +} + +void MissingFilesPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const +{ + // We use the PATH_LIST_PROP property to pass a list of + // path strings from the editor widget into the model's + // NewPathsToProcessRole. This is only used when paths + // are selected through the "browse" or "delete" buttons + // in the editor. If the user enters new text in the + // text box, we simply pass that text on to the model + // as normal text data in the default role. + QVariant pathListProp = editor->property(PATH_LIST_PROP); + if (pathListProp.isValid()) { + QStringList list = editor->property(PATH_LIST_PROP).toStringList(); + if (isOutput) { + model->setData(index, list); + } else + model->setData(index, list, MissingFilesRole::NewPathsToProcessRole); + } else { + QLineEdit *lineEdit = editor->findChild(); + model->setData(index, lineEdit->text(), 0); + } +} + +void MissingFilesPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + QStyleOptionViewItem localOption = option; + initStyleOption(&localOption, index); + + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter); +} + +void MissingFilesPathItemDelegate::handleBrowse(QWidget *container) +{ + + QLineEdit *text = container->findChild(); + + QString currentPath = text->text(); + if (currentPath.isEmpty() || currentPath.compare(QTStr("MissingFiles.Clear")) == 0) + currentPath = defaultPath; + + bool isSet = false; + if (isOutput) { + QString newPath = + QFileDialog::getOpenFileName(container, QTStr("MissingFiles.SelectFile"), currentPath, nullptr); + +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + container->window()->raise(); +#endif + + if (!newPath.isEmpty()) { + container->setProperty(PATH_LIST_PROP, QStringList() << newPath); + isSet = true; + } + } + + if (isSet) + emit commitData(container); +} + +void MissingFilesPathItemDelegate::handleClear(QWidget *container) +{ + // An empty string list will indicate that the entry is being + // blanked and should be deleted. + container->setProperty(PATH_LIST_PROP, QStringList() << QTStr("MissingFiles.Clear")); + container->findChild()->clearFocus(); + ((QWidget *)container->parent())->setFocus(); + emit commitData(container); +} + +/** + Model +**/ + +MissingFilesModel::MissingFilesModel(QObject *parent) : QAbstractTableModel(parent) +{ + QStyle *style = QApplication::style(); + + warningIcon = style->standardIcon(QStyle::SP_MessageBoxWarning); +} + +int MissingFilesModel::rowCount(const QModelIndex &) const +{ + return files.length(); +} + +int MissingFilesModel::columnCount(const QModelIndex &) const +{ + return MissingFilesColumn::Count; +} + +int MissingFilesModel::found() const +{ + int res = 0; + + for (int i = 0; i < files.length(); i++) { + if (files[i].state != Missing && files[i].state != Cleared) + res++; + } + + return res; +} + +QVariant MissingFilesModel::data(const QModelIndex &index, int role) const +{ + QVariant result = QVariant(); + + if (index.row() >= files.length()) { + return QVariant(); + } else if (role == Qt::DisplayRole) { + QFileInfo fi(files[index.row()].originalPath); + + switch (index.column()) { + case MissingFilesColumn::Source: + result = files[index.row()].source; + break; + case MissingFilesColumn::OriginalPath: + result = fi.fileName(); + break; + case MissingFilesColumn::NewPath: + result = files[index.row()].newPath; + break; + case MissingFilesColumn::State: + switch (files[index.row()].state) { + case MissingFilesState::Missing: + result = QTStr("MissingFiles.Missing"); + break; + + case MissingFilesState::Replaced: + result = QTStr("MissingFiles.Replaced"); + break; + + case MissingFilesState::Found: + result = QTStr("MissingFiles.Found"); + break; + + case MissingFilesState::Cleared: + result = QTStr("MissingFiles.Cleared"); + break; + } + break; + } + } else if (role == Qt::DecorationRole && index.column() == MissingFilesColumn::Source) { + OBSBasic *main = reinterpret_cast(App()->GetMainWindow()); + OBSSourceAutoRelease source = obs_get_source_by_name(files[index.row()].source.toStdString().c_str()); + + if (source) { + result = main->GetSourceIcon(obs_source_get_id(source)); + } + } else if (role == Qt::FontRole && index.column() == MissingFilesColumn::State) { + QFont font = QFont(); + font.setBold(true); + + result = font; + } else if (role == Qt::ToolTipRole && index.column() == MissingFilesColumn::State) { + switch (files[index.row()].state) { + case MissingFilesState::Missing: + result = QTStr("MissingFiles.Missing"); + break; + + case MissingFilesState::Replaced: + result = QTStr("MissingFiles.Replaced"); + break; + + case MissingFilesState::Found: + result = QTStr("MissingFiles.Found"); + break; + + case MissingFilesState::Cleared: + result = QTStr("MissingFiles.Cleared"); + break; + + default: + break; + } + } else if (role == Qt::ToolTipRole) { + switch (index.column()) { + case MissingFilesColumn::OriginalPath: + result = files[index.row()].originalPath; + break; + case MissingFilesColumn::NewPath: + result = files[index.row()].newPath; + break; + default: + break; + } + } + + return result; +} + +Qt::ItemFlags MissingFilesModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() == MissingFilesColumn::OriginalPath) { + flags &= ~Qt::ItemIsEditable; + } else if (index.column() == MissingFilesColumn::NewPath && index.row() != files.length()) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +void MissingFilesModel::fileCheckLoop(QList files, QString path, bool skipPrompt) +{ + loop = false; + QUrl url = QUrl().fromLocalFile(path); + QString dir = url.toDisplayString(QUrl::RemoveScheme | QUrl::RemoveFilename | QUrl::PreferLocalFile); + + bool prompted = skipPrompt; + + for (int i = 0; i < files.length(); i++) { + if (files[i].state != MissingFilesState::Missing) + continue; + + QUrl origFile = QUrl().fromLocalFile(files[i].originalPath); + QString filename = origFile.fileName(); + QString testFile = dir + filename; + + if (os_file_exists(testFile.toStdString().c_str())) { + if (!prompted) { + QMessageBox::StandardButton button = + QMessageBox::question(nullptr, QTStr("MissingFiles.AutoSearch"), + QTStr("MissingFiles.AutoSearchText")); + + if (button == QMessageBox::No) + break; + + prompted = true; + } + QModelIndex in = index(i, MissingFilesColumn::NewPath); + setData(in, testFile, 0); + } + } + loop = true; +} + +bool MissingFilesModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + bool success = false; + + if (role == MissingFilesRole::NewPathsToProcessRole) { + QStringList list = value.toStringList(); + + int row = index.row() + 1; + beginInsertRows(QModelIndex(), row, row); + + MissingFileEntry entry; + entry.originalPath = list[0].replace("\\", "/"); + entry.source = list[1]; + + files.insert(row, entry); + row++; + + endInsertRows(); + + success = true; + } else { + QString path = value.toString(); + if (index.column() == MissingFilesColumn::NewPath) { + files[index.row()].newPath = value.toString(); + QString fileName = QUrl(path).fileName(); + QString origFileName = QUrl(files[index.row()].originalPath).fileName(); + + if (path.isEmpty()) { + files[index.row()].state = MissingFilesState::Missing; + } else if (path.compare(QTStr("MissingFiles.Clear")) == 0) { + files[index.row()].state = MissingFilesState::Cleared; + } else if (fileName.compare(origFileName) == 0) { + files[index.row()].state = MissingFilesState::Found; + + if (loop) + fileCheckLoop(files, path, false); + } else { + files[index.row()].state = MissingFilesState::Replaced; + + if (loop) + fileCheckLoop(files, path, false); + } + + emit dataChanged(index, index); + success = true; + } + } + + return success; +} + +QVariant MissingFilesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + QVariant result = QVariant(); + + if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case MissingFilesColumn::State: + result = QTStr("MissingFiles.State"); + break; + case MissingFilesColumn::Source: + result = QTStr("Basic.Main.Source"); + break; + case MissingFilesColumn::OriginalPath: + result = QTStr("MissingFiles.MissingFile"); + break; + case MissingFilesColumn::NewPath: + result = QTStr("MissingFiles.NewFile"); + break; + } + } + + return result; +} + +OBSMissingFiles::OBSMissingFiles(obs_missing_files_t *files, QWidget *parent) + : QDialog(parent), + filesModel(new MissingFilesModel), + ui(new Ui::OBSMissingFiles) +{ + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + ui->setupUi(this); + + ui->tableView->setModel(filesModel); + ui->tableView->setItemDelegateForColumn(MissingFilesColumn::OriginalPath, + new MissingFilesPathItemDelegate(false, "")); + ui->tableView->setItemDelegateForColumn(MissingFilesColumn::NewPath, + new MissingFilesPathItemDelegate(true, "")); + ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::Source, + QHeaderView::ResizeMode::ResizeToContents); + ui->tableView->horizontalHeader()->setMaximumSectionSize(width() / 3); + ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::State, + QHeaderView::ResizeMode::ResizeToContents); + ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); + + ui->warningIcon->setPixmap(filesModel->warningIcon.pixmap(QSize(32, 32))); + + for (size_t i = 0; i < obs_missing_files_count(files); i++) { + obs_missing_file_t *f = obs_missing_files_get_file(files, (int)i); + + const char *oldPath = obs_missing_file_get_path(f); + const char *name = obs_missing_file_get_source_name(f); + + addMissingFile(oldPath, name); + } + + QString found = QTStr("MissingFiles.NumFound").arg("0", QString::number(obs_missing_files_count(files))); + + ui->found->setText(found); + + fileStore = files; + + connect(ui->doneButton, &QPushButton::clicked, this, &OBSMissingFiles::saveFiles); + connect(ui->browseButton, &QPushButton::clicked, this, &OBSMissingFiles::browseFolders); + connect(ui->cancelButton, &QPushButton::clicked, this, &OBSMissingFiles::close); + connect(filesModel, &MissingFilesModel::dataChanged, this, &OBSMissingFiles::dataChanged); + + QModelIndex index = filesModel->createIndex(0, 1); + QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection, + Q_ARG(const QModelIndex &, index)); +} + +OBSMissingFiles::~OBSMissingFiles() +{ + obs_missing_files_destroy(fileStore); +} + +void OBSMissingFiles::addMissingFile(const char *originalPath, const char *sourceName) +{ + QStringList list; + + list.append(originalPath); + list.append(sourceName); + + QModelIndex insertIndex = filesModel->index(filesModel->rowCount() - 1, MissingFilesColumn::Source); + + filesModel->setData(insertIndex, list, MissingFilesRole::NewPathsToProcessRole); +} + +void OBSMissingFiles::saveFiles() +{ + for (int i = 0; i < filesModel->files.length(); i++) { + MissingFilesState state = filesModel->files[i].state; + if (state != MissingFilesState::Missing) { + obs_missing_file_t *f = obs_missing_files_get_file(fileStore, i); + + QString path = filesModel->files[i].newPath; + + if (state == MissingFilesState::Cleared) { + obs_missing_file_issue_callback(f, ""); + } else { + char *p = bstrdup(path.toStdString().c_str()); + obs_missing_file_issue_callback(f, p); + bfree(p); + } + } + } + + QDialog::accept(); +} + +void OBSMissingFiles::browseFolders() +{ + QString dir = QFileDialog::getExistingDirectory(this, QTStr("MissingFiles.SelectDir"), "", + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + + if (dir != "") { + dir += "/"; + filesModel->fileCheckLoop(filesModel->files, dir, true); + } +} + +void OBSMissingFiles::dataChanged() +{ + QString found = + QTStr("MissingFiles.NumFound") + .arg(QString::number(filesModel->found()), QString::number(obs_missing_files_count(fileStore))); + + ui->found->setText(found); + + ui->tableView->resizeColumnToContents(MissingFilesColumn::State); + ui->tableView->resizeColumnToContents(MissingFilesColumn::Source); +} + +QIcon OBSMissingFiles::GetWarningIcon() +{ + return filesModel->warningIcon; +} + +void OBSMissingFiles::SetWarningIcon(const QIcon &icon) +{ + ui->warningIcon->setPixmap(icon.pixmap(QSize(32, 32))); + filesModel->warningIcon = icon; +} diff --git a/frontend/utility/MissingFilesModel.hpp b/frontend/utility/MissingFilesModel.hpp new file mode 100644 index 000000000..2b24e0f8e --- /dev/null +++ b/frontend/utility/MissingFilesModel.hpp @@ -0,0 +1,112 @@ +/****************************************************************************** + Copyright (C) 2019 by Dillon Pentz + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include "obs-app.hpp" +#include "ui_OBSMissingFiles.h" + +class MissingFilesModel; + +enum MissingFilesState { Missing, Found, Replaced, Cleared }; +Q_DECLARE_METATYPE(MissingFilesState); + +class OBSMissingFiles : public QDialog { + Q_OBJECT + Q_PROPERTY(QIcon warningIcon READ GetWarningIcon WRITE SetWarningIcon DESIGNABLE true) + + QPointer filesModel; + std::unique_ptr ui; + +public: + explicit OBSMissingFiles(obs_missing_files_t *files, QWidget *parent = nullptr); + virtual ~OBSMissingFiles() override; + + void addMissingFile(const char *originalPath, const char *sourceName); + + QIcon GetWarningIcon(); + void SetWarningIcon(const QIcon &icon); + +private: + void saveFiles(); + void browseFolders(); + + obs_missing_files_t *fileStore; + +public slots: + void dataChanged(); +}; + +class MissingFilesModel : public QAbstractTableModel { + Q_OBJECT + + friend class OBSMissingFiles; + +public: + explicit MissingFilesModel(QObject *parent = 0); + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex &parent = QModelIndex()) const; + int found() const; + QVariant data(const QModelIndex &index, int role) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + Qt::ItemFlags flags(const QModelIndex &index) const; + bool setData(const QModelIndex &index, const QVariant &value, int role); + + bool loop = true; + + QIcon warningIcon; + +private: + struct MissingFileEntry { + MissingFilesState state = MissingFilesState::Missing; + + QString source; + + QString originalPath; + QString newPath; + }; + + QList files; + + void fileCheckLoop(QList files, QString path, bool skipPrompt); +}; + +class MissingFilesPathItemDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath); + + virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const override; + + virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; + virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + virtual void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + bool isOutput; + QString defaultPath; + const char *PATH_LIST_PROP = "pathList"; + + void handleBrowse(QWidget *container); + void handleClear(QWidget *container); +}; diff --git a/frontend/utility/MissingFilesPathItemDelegate.cpp b/frontend/utility/MissingFilesPathItemDelegate.cpp new file mode 100644 index 000000000..57e641a74 --- /dev/null +++ b/frontend/utility/MissingFilesPathItemDelegate.cpp @@ -0,0 +1,542 @@ +/****************************************************************************** + Copyright (C) 2019 by Dillon Pentz + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "moc_window-missing-files.cpp" +#include "window-basic-main.hpp" + +#include "obs-app.hpp" + +#include +#include +#include + +#include + +enum MissingFilesColumn { + Source, + OriginalPath, + NewPath, + State, + + Count +}; + +enum MissingFilesRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole }; + +/********************************************************** + Delegate - Presents cells in the grid. +**********************************************************/ + +MissingFilesPathItemDelegate::MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath) + : QStyledItemDelegate(), + isOutput(isOutput), + defaultPath(defaultPath) +{ +} + +QWidget *MissingFilesPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &) const +{ + QSizePolicy buttonSizePolicy(QSizePolicy::Policy::Minimum, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::PushButton); + + QWidget *container = new QWidget(parent); + + auto browseCallback = [this, container]() { + const_cast(this)->handleBrowse(container); + }; + + auto clearCallback = [this, container]() { + const_cast(this)->handleClear(container); + }; + + QHBoxLayout *layout = new QHBoxLayout(); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QLineEdit *text = new QLineEdit(); + text->setObjectName(QStringLiteral("text")); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + layout->addWidget(text); + + QToolButton *browseButton = new QToolButton(); + browseButton->setText("..."); + browseButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(browseButton); + + container->connect(browseButton, &QToolButton::clicked, browseCallback); + + // The "clear" button is not shown in input cells + if (isOutput) { + QToolButton *clearButton = new QToolButton(); + clearButton->setText("X"); + clearButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(clearButton); + + container->connect(clearButton, &QToolButton::clicked, clearCallback); + } + + container->setLayout(layout); + container->setFocusProxy(text); + + return container; +} + +void MissingFilesPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = editor->findChild(); + text->setText(index.data().toString()); + + editor->setProperty(PATH_LIST_PROP, QVariant()); +} + +void MissingFilesPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const +{ + // We use the PATH_LIST_PROP property to pass a list of + // path strings from the editor widget into the model's + // NewPathsToProcessRole. This is only used when paths + // are selected through the "browse" or "delete" buttons + // in the editor. If the user enters new text in the + // text box, we simply pass that text on to the model + // as normal text data in the default role. + QVariant pathListProp = editor->property(PATH_LIST_PROP); + if (pathListProp.isValid()) { + QStringList list = editor->property(PATH_LIST_PROP).toStringList(); + if (isOutput) { + model->setData(index, list); + } else + model->setData(index, list, MissingFilesRole::NewPathsToProcessRole); + } else { + QLineEdit *lineEdit = editor->findChild(); + model->setData(index, lineEdit->text(), 0); + } +} + +void MissingFilesPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + QStyleOptionViewItem localOption = option; + initStyleOption(&localOption, index); + + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter); +} + +void MissingFilesPathItemDelegate::handleBrowse(QWidget *container) +{ + + QLineEdit *text = container->findChild(); + + QString currentPath = text->text(); + if (currentPath.isEmpty() || currentPath.compare(QTStr("MissingFiles.Clear")) == 0) + currentPath = defaultPath; + + bool isSet = false; + if (isOutput) { + QString newPath = + QFileDialog::getOpenFileName(container, QTStr("MissingFiles.SelectFile"), currentPath, nullptr); + +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + container->window()->raise(); +#endif + + if (!newPath.isEmpty()) { + container->setProperty(PATH_LIST_PROP, QStringList() << newPath); + isSet = true; + } + } + + if (isSet) + emit commitData(container); +} + +void MissingFilesPathItemDelegate::handleClear(QWidget *container) +{ + // An empty string list will indicate that the entry is being + // blanked and should be deleted. + container->setProperty(PATH_LIST_PROP, QStringList() << QTStr("MissingFiles.Clear")); + container->findChild()->clearFocus(); + ((QWidget *)container->parent())->setFocus(); + emit commitData(container); +} + +/** + Model +**/ + +MissingFilesModel::MissingFilesModel(QObject *parent) : QAbstractTableModel(parent) +{ + QStyle *style = QApplication::style(); + + warningIcon = style->standardIcon(QStyle::SP_MessageBoxWarning); +} + +int MissingFilesModel::rowCount(const QModelIndex &) const +{ + return files.length(); +} + +int MissingFilesModel::columnCount(const QModelIndex &) const +{ + return MissingFilesColumn::Count; +} + +int MissingFilesModel::found() const +{ + int res = 0; + + for (int i = 0; i < files.length(); i++) { + if (files[i].state != Missing && files[i].state != Cleared) + res++; + } + + return res; +} + +QVariant MissingFilesModel::data(const QModelIndex &index, int role) const +{ + QVariant result = QVariant(); + + if (index.row() >= files.length()) { + return QVariant(); + } else if (role == Qt::DisplayRole) { + QFileInfo fi(files[index.row()].originalPath); + + switch (index.column()) { + case MissingFilesColumn::Source: + result = files[index.row()].source; + break; + case MissingFilesColumn::OriginalPath: + result = fi.fileName(); + break; + case MissingFilesColumn::NewPath: + result = files[index.row()].newPath; + break; + case MissingFilesColumn::State: + switch (files[index.row()].state) { + case MissingFilesState::Missing: + result = QTStr("MissingFiles.Missing"); + break; + + case MissingFilesState::Replaced: + result = QTStr("MissingFiles.Replaced"); + break; + + case MissingFilesState::Found: + result = QTStr("MissingFiles.Found"); + break; + + case MissingFilesState::Cleared: + result = QTStr("MissingFiles.Cleared"); + break; + } + break; + } + } else if (role == Qt::DecorationRole && index.column() == MissingFilesColumn::Source) { + OBSBasic *main = reinterpret_cast(App()->GetMainWindow()); + OBSSourceAutoRelease source = obs_get_source_by_name(files[index.row()].source.toStdString().c_str()); + + if (source) { + result = main->GetSourceIcon(obs_source_get_id(source)); + } + } else if (role == Qt::FontRole && index.column() == MissingFilesColumn::State) { + QFont font = QFont(); + font.setBold(true); + + result = font; + } else if (role == Qt::ToolTipRole && index.column() == MissingFilesColumn::State) { + switch (files[index.row()].state) { + case MissingFilesState::Missing: + result = QTStr("MissingFiles.Missing"); + break; + + case MissingFilesState::Replaced: + result = QTStr("MissingFiles.Replaced"); + break; + + case MissingFilesState::Found: + result = QTStr("MissingFiles.Found"); + break; + + case MissingFilesState::Cleared: + result = QTStr("MissingFiles.Cleared"); + break; + + default: + break; + } + } else if (role == Qt::ToolTipRole) { + switch (index.column()) { + case MissingFilesColumn::OriginalPath: + result = files[index.row()].originalPath; + break; + case MissingFilesColumn::NewPath: + result = files[index.row()].newPath; + break; + default: + break; + } + } + + return result; +} + +Qt::ItemFlags MissingFilesModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() == MissingFilesColumn::OriginalPath) { + flags &= ~Qt::ItemIsEditable; + } else if (index.column() == MissingFilesColumn::NewPath && index.row() != files.length()) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +void MissingFilesModel::fileCheckLoop(QList files, QString path, bool skipPrompt) +{ + loop = false; + QUrl url = QUrl().fromLocalFile(path); + QString dir = url.toDisplayString(QUrl::RemoveScheme | QUrl::RemoveFilename | QUrl::PreferLocalFile); + + bool prompted = skipPrompt; + + for (int i = 0; i < files.length(); i++) { + if (files[i].state != MissingFilesState::Missing) + continue; + + QUrl origFile = QUrl().fromLocalFile(files[i].originalPath); + QString filename = origFile.fileName(); + QString testFile = dir + filename; + + if (os_file_exists(testFile.toStdString().c_str())) { + if (!prompted) { + QMessageBox::StandardButton button = + QMessageBox::question(nullptr, QTStr("MissingFiles.AutoSearch"), + QTStr("MissingFiles.AutoSearchText")); + + if (button == QMessageBox::No) + break; + + prompted = true; + } + QModelIndex in = index(i, MissingFilesColumn::NewPath); + setData(in, testFile, 0); + } + } + loop = true; +} + +bool MissingFilesModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + bool success = false; + + if (role == MissingFilesRole::NewPathsToProcessRole) { + QStringList list = value.toStringList(); + + int row = index.row() + 1; + beginInsertRows(QModelIndex(), row, row); + + MissingFileEntry entry; + entry.originalPath = list[0].replace("\\", "/"); + entry.source = list[1]; + + files.insert(row, entry); + row++; + + endInsertRows(); + + success = true; + } else { + QString path = value.toString(); + if (index.column() == MissingFilesColumn::NewPath) { + files[index.row()].newPath = value.toString(); + QString fileName = QUrl(path).fileName(); + QString origFileName = QUrl(files[index.row()].originalPath).fileName(); + + if (path.isEmpty()) { + files[index.row()].state = MissingFilesState::Missing; + } else if (path.compare(QTStr("MissingFiles.Clear")) == 0) { + files[index.row()].state = MissingFilesState::Cleared; + } else if (fileName.compare(origFileName) == 0) { + files[index.row()].state = MissingFilesState::Found; + + if (loop) + fileCheckLoop(files, path, false); + } else { + files[index.row()].state = MissingFilesState::Replaced; + + if (loop) + fileCheckLoop(files, path, false); + } + + emit dataChanged(index, index); + success = true; + } + } + + return success; +} + +QVariant MissingFilesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + QVariant result = QVariant(); + + if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case MissingFilesColumn::State: + result = QTStr("MissingFiles.State"); + break; + case MissingFilesColumn::Source: + result = QTStr("Basic.Main.Source"); + break; + case MissingFilesColumn::OriginalPath: + result = QTStr("MissingFiles.MissingFile"); + break; + case MissingFilesColumn::NewPath: + result = QTStr("MissingFiles.NewFile"); + break; + } + } + + return result; +} + +OBSMissingFiles::OBSMissingFiles(obs_missing_files_t *files, QWidget *parent) + : QDialog(parent), + filesModel(new MissingFilesModel), + ui(new Ui::OBSMissingFiles) +{ + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + ui->setupUi(this); + + ui->tableView->setModel(filesModel); + ui->tableView->setItemDelegateForColumn(MissingFilesColumn::OriginalPath, + new MissingFilesPathItemDelegate(false, "")); + ui->tableView->setItemDelegateForColumn(MissingFilesColumn::NewPath, + new MissingFilesPathItemDelegate(true, "")); + ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::Source, + QHeaderView::ResizeMode::ResizeToContents); + ui->tableView->horizontalHeader()->setMaximumSectionSize(width() / 3); + ui->tableView->horizontalHeader()->setSectionResizeMode(MissingFilesColumn::State, + QHeaderView::ResizeMode::ResizeToContents); + ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); + + ui->warningIcon->setPixmap(filesModel->warningIcon.pixmap(QSize(32, 32))); + + for (size_t i = 0; i < obs_missing_files_count(files); i++) { + obs_missing_file_t *f = obs_missing_files_get_file(files, (int)i); + + const char *oldPath = obs_missing_file_get_path(f); + const char *name = obs_missing_file_get_source_name(f); + + addMissingFile(oldPath, name); + } + + QString found = QTStr("MissingFiles.NumFound").arg("0", QString::number(obs_missing_files_count(files))); + + ui->found->setText(found); + + fileStore = files; + + connect(ui->doneButton, &QPushButton::clicked, this, &OBSMissingFiles::saveFiles); + connect(ui->browseButton, &QPushButton::clicked, this, &OBSMissingFiles::browseFolders); + connect(ui->cancelButton, &QPushButton::clicked, this, &OBSMissingFiles::close); + connect(filesModel, &MissingFilesModel::dataChanged, this, &OBSMissingFiles::dataChanged); + + QModelIndex index = filesModel->createIndex(0, 1); + QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection, + Q_ARG(const QModelIndex &, index)); +} + +OBSMissingFiles::~OBSMissingFiles() +{ + obs_missing_files_destroy(fileStore); +} + +void OBSMissingFiles::addMissingFile(const char *originalPath, const char *sourceName) +{ + QStringList list; + + list.append(originalPath); + list.append(sourceName); + + QModelIndex insertIndex = filesModel->index(filesModel->rowCount() - 1, MissingFilesColumn::Source); + + filesModel->setData(insertIndex, list, MissingFilesRole::NewPathsToProcessRole); +} + +void OBSMissingFiles::saveFiles() +{ + for (int i = 0; i < filesModel->files.length(); i++) { + MissingFilesState state = filesModel->files[i].state; + if (state != MissingFilesState::Missing) { + obs_missing_file_t *f = obs_missing_files_get_file(fileStore, i); + + QString path = filesModel->files[i].newPath; + + if (state == MissingFilesState::Cleared) { + obs_missing_file_issue_callback(f, ""); + } else { + char *p = bstrdup(path.toStdString().c_str()); + obs_missing_file_issue_callback(f, p); + bfree(p); + } + } + } + + QDialog::accept(); +} + +void OBSMissingFiles::browseFolders() +{ + QString dir = QFileDialog::getExistingDirectory(this, QTStr("MissingFiles.SelectDir"), "", + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + + if (dir != "") { + dir += "/"; + filesModel->fileCheckLoop(filesModel->files, dir, true); + } +} + +void OBSMissingFiles::dataChanged() +{ + QString found = + QTStr("MissingFiles.NumFound") + .arg(QString::number(filesModel->found()), QString::number(obs_missing_files_count(fileStore))); + + ui->found->setText(found); + + ui->tableView->resizeColumnToContents(MissingFilesColumn::State); + ui->tableView->resizeColumnToContents(MissingFilesColumn::Source); +} + +QIcon OBSMissingFiles::GetWarningIcon() +{ + return filesModel->warningIcon; +} + +void OBSMissingFiles::SetWarningIcon(const QIcon &icon) +{ + ui->warningIcon->setPixmap(icon.pixmap(QSize(32, 32))); + filesModel->warningIcon = icon; +} diff --git a/frontend/utility/MissingFilesPathItemDelegate.hpp b/frontend/utility/MissingFilesPathItemDelegate.hpp new file mode 100644 index 000000000..2b24e0f8e --- /dev/null +++ b/frontend/utility/MissingFilesPathItemDelegate.hpp @@ -0,0 +1,112 @@ +/****************************************************************************** + Copyright (C) 2019 by Dillon Pentz + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include "obs-app.hpp" +#include "ui_OBSMissingFiles.h" + +class MissingFilesModel; + +enum MissingFilesState { Missing, Found, Replaced, Cleared }; +Q_DECLARE_METATYPE(MissingFilesState); + +class OBSMissingFiles : public QDialog { + Q_OBJECT + Q_PROPERTY(QIcon warningIcon READ GetWarningIcon WRITE SetWarningIcon DESIGNABLE true) + + QPointer filesModel; + std::unique_ptr ui; + +public: + explicit OBSMissingFiles(obs_missing_files_t *files, QWidget *parent = nullptr); + virtual ~OBSMissingFiles() override; + + void addMissingFile(const char *originalPath, const char *sourceName); + + QIcon GetWarningIcon(); + void SetWarningIcon(const QIcon &icon); + +private: + void saveFiles(); + void browseFolders(); + + obs_missing_files_t *fileStore; + +public slots: + void dataChanged(); +}; + +class MissingFilesModel : public QAbstractTableModel { + Q_OBJECT + + friend class OBSMissingFiles; + +public: + explicit MissingFilesModel(QObject *parent = 0); + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex &parent = QModelIndex()) const; + int found() const; + QVariant data(const QModelIndex &index, int role) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + Qt::ItemFlags flags(const QModelIndex &index) const; + bool setData(const QModelIndex &index, const QVariant &value, int role); + + bool loop = true; + + QIcon warningIcon; + +private: + struct MissingFileEntry { + MissingFilesState state = MissingFilesState::Missing; + + QString source; + + QString originalPath; + QString newPath; + }; + + QList files; + + void fileCheckLoop(QList files, QString path, bool skipPrompt); +}; + +class MissingFilesPathItemDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + MissingFilesPathItemDelegate(bool isOutput, const QString &defaultPath); + + virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const override; + + virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; + virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + virtual void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + bool isOutput; + QString defaultPath; + const char *PATH_LIST_PROP = "pathList"; + + void handleBrowse(QWidget *container); + void handleClear(QWidget *container); +}; diff --git a/frontend/utility/OBSEventFilter.hpp b/frontend/utility/OBSEventFilter.hpp new file mode 100644 index 000000000..6bfaaab4a --- /dev/null +++ b/frontend/utility/OBSEventFilter.hpp @@ -0,0 +1,82 @@ +/****************************************************************************** + Copyright (C) 2023 by Lain Bailey + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include + +#include +#include + +class OBSBasic; + +#include "ui_OBSBasicInteraction.h" + +class OBSEventFilter; + +class OBSBasicInteraction : public QDialog { + Q_OBJECT + +private: + OBSBasic *main; + + std::unique_ptr ui; + OBSSource source; + OBSSignal removedSignal; + OBSSignal renamedSignal; + std::unique_ptr eventFilter; + + static void SourceRemoved(void *data, calldata_t *params); + static void SourceRenamed(void *data, calldata_t *params); + static void DrawPreview(void *data, uint32_t cx, uint32_t cy); + + bool GetSourceRelativeXY(int mouseX, int mouseY, int &x, int &y); + + bool HandleMouseClickEvent(QMouseEvent *event); + bool HandleMouseMoveEvent(QMouseEvent *event); + bool HandleMouseWheelEvent(QWheelEvent *event); + bool HandleFocusEvent(QFocusEvent *event); + bool HandleKeyEvent(QKeyEvent *event); + + OBSEventFilter *BuildEventFilter(); + +public: + OBSBasicInteraction(QWidget *parent, OBSSource source_); + ~OBSBasicInteraction(); + + void Init(); + +protected: + virtual void closeEvent(QCloseEvent *event) override; + virtual bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override; +}; + +typedef std::function EventFilterFunc; + +class OBSEventFilter : public QObject { + Q_OBJECT +public: + OBSEventFilter(EventFilterFunc filter_) : filter(filter_) {} + +protected: + bool eventFilter(QObject *obj, QEvent *event) { return filter(obj, event); } + +public: + EventFilterFunc filter; +}; diff --git a/frontend/utility/RemuxEntryPathItemDelegate.cpp b/frontend/utility/RemuxEntryPathItemDelegate.cpp new file mode 100644 index 000000000..d72ebbd9c --- /dev/null +++ b/frontend/utility/RemuxEntryPathItemDelegate.cpp @@ -0,0 +1,924 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "moc_window-remux.cpp" + +#include "obs-app.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "window-basic-main.hpp" + +#include +#include + +using namespace std; + +enum RemuxEntryColumn { + State, + InputPath, + OutputPath, + + Count +}; + +enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole }; + +/********************************************************** + Delegate - Presents cells in the grid. +**********************************************************/ + +RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath) + : QStyledItemDelegate(), + isOutput(isOutput), + defaultPath(defaultPath) +{ +} + +QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + 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->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QLineEdit *text = new QLineEdit(); + text->setObjectName(QStringLiteral("text")); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + layout->addWidget(text); + + QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText); + + QToolButton *browseButton = new QToolButton(); + browseButton->setText("..."); + browseButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(browseButton); + + container->connect(browseButton, &QToolButton::clicked, browseCallback); + + // The "clear" button is not shown in output cells + // or the insertion point's input cell. + if (!isOutput && state != RemuxEntryState::Empty) { + QToolButton *clearButton = new QToolButton(); + clearButton->setText("X"); + clearButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(clearButton); + + container->connect(clearButton, &QToolButton::clicked, clearCallback); + } + + container->setLayout(layout); + container->setFocusProxy(text); + return container; + } +} + +void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = editor->findChild(); + text->setText(index.data().toString()); + editor->setProperty(PATH_LIST_PROP, QVariant()); +} + +void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const +{ + // We use the PATH_LIST_PROP property to pass a list of + // path strings from the editor widget into the model's + // NewPathsToProcessRole. This is only used when paths + // are selected through the "browse" or "delete" buttons + // in the editor. If the user enters new text in the + // text box, we simply pass that text on to the model + // as normal text data in the default role. + QVariant pathListProp = editor->property(PATH_LIST_PROP); + if (pathListProp.isValid()) { + QStringList list = editor->property(PATH_LIST_PROP).toStringList(); + if (isOutput) { + if (list.size() > 0) + model->setData(index, list); + } else + model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole); + } else { + QLineEdit *lineEdit = editor->findChild(); + model->setData(index, lineEdit->text()); + } +} + +void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + + QStyleOptionViewItem localOption = option; + initStyleOption(&localOption, index); + + if (isOutput) { + if (state != Ready) { + QColor background = + localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window); + + localOption.backgroundBrush = QBrush(background); + } + } + + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter); +} + +void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container) +{ + QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)"; + + QLineEdit *text = container->findChild(); + + QString currentPath = text->text(); + if (currentPath.isEmpty()) + currentPath = defaultPath; + + bool isSet = false; + if (isOutput) { + QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern); + + if (!newPath.isEmpty()) { + container->setProperty(PATH_LIST_PROP, QStringList() << newPath); + isSet = true; + } + } else { + QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath, + QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern); + + if (!paths.empty()) { + container->setProperty(PATH_LIST_PROP, paths); + isSet = true; + } +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + container->window()->raise(); +#endif + } + + if (isSet) + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::handleClear(QWidget *container) +{ + // An empty string list will indicate that the entry is being + // blanked and should be deleted. + container->setProperty(PATH_LIST_PROP, QStringList()); + + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::updateText() +{ + QLineEdit *lineEdit = dynamic_cast(sender()); + QWidget *editor = lineEdit->parentWidget(); + emit commitData(editor); +} + +/********************************************************** + Model - Manages the queue's data +**********************************************************/ + +int RemuxQueueModel::rowCount(const QModelIndex &) const +{ + return queue.length() + (isProcessing ? 0 : 1); +} + +int RemuxQueueModel::columnCount(const QModelIndex &) const +{ + return RemuxEntryColumn::Count; +} + +QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const +{ + QVariant result = QVariant(); + + if (index.row() >= queue.length()) { + return QVariant(); + } else if (role == Qt::DisplayRole) { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + result = queue[index.row()].sourcePath; + break; + case RemuxEntryColumn::OutputPath: + result = queue[index.row()].targetPath; + break; + } + } else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) { + result = getIcon(queue[index.row()].state); + } else if (role == RemuxEntryRole::EntryStateRole) { + result = queue[index.row()].state; + } + + return result; +} + +QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + QVariant result = QVariant(); + + if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case RemuxEntryColumn::State: + result = QString(); + break; + case RemuxEntryColumn::InputPath: + result = QTStr("Remux.SourceFile"); + break; + case RemuxEntryColumn::OutputPath: + result = QTStr("Remux.TargetFile"); + break; + } + } + + return result; +} + +Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() == RemuxEntryColumn::InputPath) { + flags |= Qt::ItemIsEditable; + } else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + bool success = false; + + if (role == RemuxEntryRole::NewPathsToProcessRole) { + QStringList pathList = value.toStringList(); + + if (pathList.size() == 0) { + if (index.row() < queue.size()) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + if (pathList.size() >= 1 && index.row() < queue.length()) { + queue[index.row()].sourcePath = pathList[0]; + checkInputPath(index.row()); + + pathList.removeAt(0); + + success = true; + } + + if (pathList.size() > 0) { + int row = index.row(); + int lastRow = row + pathList.size() - 1; + beginInsertRows(QModelIndex(), row, lastRow); + + for (QString path : pathList) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + queue.insert(row, entry); + row++; + } + endInsertRows(); + + for (row = index.row(); row <= lastRow; row++) { + checkInputPath(row); + } + + success = true; + } + } + } else if (index.row() == queue.length()) { + QString path = value.toString(); + + if (!path.isEmpty()) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1); + queue.append(entry); + endInsertRows(); + + checkInputPath(index.row()); + success = true; + } + } else { + QString path = value.toString(); + + if (path.isEmpty()) { + if (index.column() == RemuxEntryColumn::InputPath) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + queue[index.row()].sourcePath = value.toString(); + checkInputPath(index.row()); + success = true; + break; + case RemuxEntryColumn::OutputPath: + queue[index.row()].targetPath = value.toString(); + emit dataChanged(index, index); + success = true; + break; + } + } + } + + return success; +} + +QVariant RemuxQueueModel::getIcon(RemuxEntryState state) +{ + QVariant icon; + QStyle *style = QApplication::style(); + + switch (state) { + case RemuxEntryState::Complete: + icon = style->standardIcon(QStyle::SP_DialogApplyButton); + break; + + case RemuxEntryState::InProgress: + icon = style->standardIcon(QStyle::SP_ArrowRight); + break; + + case RemuxEntryState::Error: + icon = style->standardIcon(QStyle::SP_DialogCancelButton); + break; + + case RemuxEntryState::InvalidPath: + icon = style->standardIcon(QStyle::SP_MessageBoxWarning); + break; + + default: + break; + } + + return icon; +} + +void RemuxQueueModel::checkInputPath(int row) +{ + RemuxQueueEntry &entry = queue[row]; + + if (entry.sourcePath.isEmpty()) { + entry.state = RemuxEntryState::Empty; + } else { + entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath); + QFileInfo fileInfo(entry.sourcePath); + if (fileInfo.exists()) + entry.state = RemuxEntryState::Ready; + else + entry.state = RemuxEntryState::InvalidPath; + + QString newExt = ".mp4"; + QString suffix = fileInfo.suffix(); + + if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) { + newExt = ".remuxed." + suffix; + } + + if (entry.state == RemuxEntryState::Ready) + entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() + + fileInfo.completeBaseName() + newExt); + } + + if (entry.state == RemuxEntryState::Ready && isProcessing) + entry.state = RemuxEntryState::Pending; + + emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count)); +} + +QFileInfoList RemuxQueueModel::checkForOverwrites() const +{ + QFileInfoList list; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Ready) { + QFileInfo fileInfo(entry.targetPath); + if (fileInfo.exists()) { + list.append(fileInfo); + } + } + } + + return list; +} + +bool RemuxQueueModel::checkForErrors() const +{ + bool hasErrors = false; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Error) { + hasErrors = true; + break; + } + } + + return hasErrors; +} + +void RemuxQueueModel::clearAll() +{ + beginRemoveRows(QModelIndex(), 0, queue.size() - 1); + queue.clear(); + endRemoveRows(); +} + +void RemuxQueueModel::clearFinished() +{ + int index = 0; + + for (index = 0; index < queue.size(); index++) { + const RemuxQueueEntry &entry = queue[index]; + if (entry.state == RemuxEntryState::Complete) { + beginRemoveRows(QModelIndex(), index, index); + queue.removeAt(index); + endRemoveRows(); + index--; + } + } +} + +bool RemuxQueueModel::canClearFinished() const +{ + bool canClearFinished = false; + for (const RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Complete) { + canClearFinished = true; + break; + } + + return canClearFinished; +} + +void RemuxQueueModel::beginProcessing() +{ + for (RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Ready) + entry.state = RemuxEntryState::Pending; + + // Signal that the insertion point no longer exists. + beginRemoveRows(QModelIndex(), queue.length(), queue.length()); + endRemoveRows(); + + isProcessing = true; + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +void RemuxQueueModel::endProcessing() +{ + for (RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::Ready; + } + } + + // Signal that the insertion point exists again. + isProcessing = false; + if (!autoRemux) { + beginInsertRows(QModelIndex(), queue.length(), queue.length()); + endInsertRows(); + } + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath) +{ + bool anyStarted = false; + + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::InProgress; + + inputPath = entry.sourcePath; + outputPath = entry.targetPath; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + anyStarted = true; + break; + } + } + + return anyStarted; +} + +void RemuxQueueModel::finishEntry(bool success) +{ + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::InProgress) { + if (success) + entry.state = RemuxEntryState::Complete; + else + entry.state = RemuxEntryState::Error; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + break; + } + } +} + +/********************************************************** + The actual remux window implementation +**********************************************************/ + +OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_) + : QDialog(parent), + queueModel(new RemuxQueueModel), + worker(new RemuxWorker()), + ui(new Ui::OBSRemux), + recPath(path), + autoRemux(autoRemux_) +{ + setAcceptDrops(true); + + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + ui->setupUi(this); + + ui->progressBar->setVisible(false); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + + if (autoRemux) { + resize(280, 40); + ui->tableView->hide(); + ui->buttonBox->hide(); + ui->label->hide(); + } + + ui->progressBar->setMinimum(0); + ui->progressBar->setMaximum(1000); + ui->progressBar->setValue(0); + + ui->tableView->setModel(queueModel); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath, + new RemuxEntryPathItemDelegate(false, recPath)); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath, + new RemuxEntryPathItemDelegate(true, recPath)); + ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State, + QHeaderView::ResizeMode::Fixed); + ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); + ui->tableView->setTextElideMode(Qt::ElideMiddle); + ui->tableView->setWordWrap(false); + + installEventFilter(CreateShortcutFilter()); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true); + + connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux); + connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished); + connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this, + &OBSRemux::clearAll); + connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close); + + worker->moveToThread(&remuxer); + remuxer.start(); + + connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress); + connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater); + connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished); + connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux); + + connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged); + connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged); + + QModelIndex index = queueModel->createIndex(0, 1); + QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection, + Q_ARG(const QModelIndex &, index)); +} + +bool OBSRemux::stopRemux() +{ + if (!worker->isWorking) + return true; + + // By locking the worker thread's mutex, we ensure that its + // update poll will be blocked as long as we're in here with + // the popup open. + QMutexLocker lock(&worker->updateMutex); + + bool exit = false; + + if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { + exit = true; + } + + if (exit) { + // Inform the worker it should no longer be + // working. It will interrupt accordingly in + // its next update callback. + worker->isWorking = false; + } + + return exit; +} + +OBSRemux::~OBSRemux() +{ + stopRemux(); + remuxer.quit(); + remuxer.wait(); +} + +void OBSRemux::rowCountChanged(const QModelIndex &, int, int) +{ + // See if there are still any rows ready to remux. Change + // the state of the "go" button accordingly. + // There must be more than one row, since there will always be + // at least one row for the empty insertion point. + if (queueModel->rowCount() > 1) { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + } else { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false); + } +} + +void OBSRemux::dropEvent(QDropEvent *ev) +{ + QStringList urlList; + + for (QUrl url : ev->mimeData()->urls()) { + QFileInfo fileInfo(url.toLocalFile()); + + if (fileInfo.isDir()) { + QStringList directoryFilter; + directoryFilter << "*.flv" + << "*.mp4" + << "*.mov" + << "*.mkv" + << "*.ts" + << "*.m3u8"; + + QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files, + QDirIterator::Subdirectories); + + while (dirIter.hasNext()) { + urlList.append(dirIter.next()); + } + } else { + urlList.append(fileInfo.canonicalFilePath()); + } + } + + if (urlList.empty()) { + QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"), + QMessageBox::Ok); + } else if (!autoRemux) { + QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath); + queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole); + } +} + +void OBSRemux::dragEnterEvent(QDragEnterEvent *ev) +{ + if (ev->mimeData()->hasUrls() && !worker->isWorking) + ev->accept(); +} + +void OBSRemux::beginRemux() +{ + if (worker->isWorking) { + stopRemux(); + return; + } + + bool proceedWithRemux = true; + QFileInfoList overwriteFiles = queueModel->checkForOverwrites(); + + if (!overwriteFiles.empty()) { + QString message = QTStr("Remux.FileExists"); + message += "\n\n"; + + for (QFileInfo fileInfo : overwriteFiles) + message += fileInfo.canonicalFilePath() + "\n"; + + if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes) + proceedWithRemux = false; + } + + if (!proceedWithRemux) + return; + + // Set all jobs to "pending" first. + queueModel->beginProcessing(); + + ui->progressBar->setVisible(true); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop")); + setAcceptDrops(false); + + remuxNextEntry(); +} + +void OBSRemux::AutoRemux(QString inFile, QString outFile) +{ + if (inFile != "" && outFile != "" && autoRemux) { + ui->progressBar->setVisible(true); + emit remux(inFile, outFile); + autoRemuxFile = outFile; + } +} + +void OBSRemux::remuxNextEntry() +{ + worker->lastProgress = 0.f; + + QString inputPath, outputPath; + if (queueModel->beginNextEntry(inputPath, outputPath)) { + emit remux(inputPath, outputPath); + } else { + queueModel->autoRemux = autoRemux; + queueModel->endProcessing(); + + if (!autoRemux) { + OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"), + queueModel->checkForErrors() ? QTStr("Remux.FinishedError") + : QTStr("Remux.Finished")); + } + + ui->progressBar->setVisible(autoRemux); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + setAcceptDrops(true); + } +} + +void OBSRemux::closeEvent(QCloseEvent *event) +{ + if (!stopRemux()) + event->ignore(); + else + QDialog::closeEvent(event); +} + +void OBSRemux::reject() +{ + if (!stopRemux()) + return; + + QDialog::reject(); +} + +void OBSRemux::updateProgress(float percent) +{ + ui->progressBar->setValue(percent * 10); +} + +void OBSRemux::remuxFinished(bool success) +{ + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + + queueModel->finishEntry(success); + + if (autoRemux && autoRemuxFile != "") { + QTimer::singleShot(3000, this, &OBSRemux::close); + + OBSBasic *main = OBSBasic::Get(); + main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile)); + } + + remuxNextEntry(); +} + +void OBSRemux::clearFinished() +{ + queueModel->clearFinished(); +} + +void OBSRemux::clearAll() +{ + queueModel->clearAll(); +} + +/********************************************************** + Worker thread - Executes the libobs remux operation as a + background process. +**********************************************************/ + +void RemuxWorker::UpdateProgress(float percent) +{ + if (abs(lastProgress - percent) < 0.1f) + return; + + emit updateProgress(percent); + lastProgress = percent; +} + +void RemuxWorker::remux(const QString &source, const QString &target) +{ + isWorking = true; + + auto callback = [](void *data, float percent) { + RemuxWorker *rw = static_cast(data); + + QMutexLocker lock(&rw->updateMutex); + + rw->UpdateProgress(percent); + + return rw->isWorking; + }; + + bool stopped = false; + bool success = false; + + media_remux_job_t mr_job = nullptr; + if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) { + + success = media_remux_job_process(mr_job, callback, this); + + media_remux_job_destroy(mr_job); + + stopped = !isWorking; + } + + isWorking = false; + + emit remuxFinished(!stopped && success); +} diff --git a/frontend/utility/RemuxEntryPathItemDelegate.hpp b/frontend/utility/RemuxEntryPathItemDelegate.hpp new file mode 100644 index 000000000..20a13a780 --- /dev/null +++ b/frontend/utility/RemuxEntryPathItemDelegate.hpp @@ -0,0 +1,173 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ui_OBSRemux.h" + +#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; + + std::unique_ptr ui; + + const char *recPath; + + virtual void closeEvent(QCloseEvent *event) override; + virtual void reject() override; + + bool autoRemux; + QString autoRemuxFile; + +public: + explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false); + virtual ~OBSRemux() override; + + using job_t = std::shared_ptr; + + void AutoRemux(QString inFile, QString outFile); + +protected: + virtual void dropEvent(QDropEvent *ev) override; + virtual void dragEnterEvent(QDragEnterEvent *ev) override; + + void remuxNextEntry(); + +private slots: + void rowCountChanged(const QModelIndex &parent, int first, int last); + +public slots: + void updateProgress(float percent); + void remuxFinished(bool success); + void beginRemux(); + bool stopRemux(); + void clearFinished(); + void clearAll(); + +signals: + void remux(const QString &source, const QString &target); +}; + +class RemuxQueueModel : public QAbstractTableModel { + Q_OBJECT + + friend class OBSRemux; + +public: + RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {} + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + Qt::ItemFlags flags(const QModelIndex &index) const; + bool setData(const QModelIndex &index, const QVariant &value, int role); + + QFileInfoList checkForOverwrites() const; + bool checkForErrors() const; + void beginProcessing(); + void endProcessing(); + bool beginNextEntry(QString &inputPath, QString &outputPath); + void finishEntry(bool success); + bool canClearFinished() const; + void clearFinished(); + void clearAll(); + + bool autoRemux = false; + +private: + struct RemuxQueueEntry { + RemuxEntryState state; + + QString sourcePath; + QString targetPath; + }; + + QList queue; + bool isProcessing; + + static QVariant getIcon(RemuxEntryState state); + + void checkInputPath(int row); +}; + +class RemuxWorker : public QObject { + Q_OBJECT + + QMutex updateMutex; + + bool isWorking; + + float lastProgress; + void UpdateProgress(float percent); + + explicit RemuxWorker() : isWorking(false) {} + virtual ~RemuxWorker(){}; + +private slots: + void remux(const QString &source, const QString &target); + +signals: + void updateProgress(float percent); + void remuxFinished(bool success); + + friend class OBSRemux; +}; + +class RemuxEntryPathItemDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath); + + virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const override; + + virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; + virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + virtual void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + bool isOutput; + QString defaultPath; + const char *PATH_LIST_PROP = "pathList"; + + void handleBrowse(QWidget *container); + void handleClear(QWidget *container); + +private slots: + void updateText(); +}; diff --git a/frontend/utility/RemuxQueueModel.cpp b/frontend/utility/RemuxQueueModel.cpp new file mode 100644 index 000000000..d72ebbd9c --- /dev/null +++ b/frontend/utility/RemuxQueueModel.cpp @@ -0,0 +1,924 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "moc_window-remux.cpp" + +#include "obs-app.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "window-basic-main.hpp" + +#include +#include + +using namespace std; + +enum RemuxEntryColumn { + State, + InputPath, + OutputPath, + + Count +}; + +enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole }; + +/********************************************************** + Delegate - Presents cells in the grid. +**********************************************************/ + +RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath) + : QStyledItemDelegate(), + isOutput(isOutput), + defaultPath(defaultPath) +{ +} + +QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + 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->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QLineEdit *text = new QLineEdit(); + text->setObjectName(QStringLiteral("text")); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + layout->addWidget(text); + + QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText); + + QToolButton *browseButton = new QToolButton(); + browseButton->setText("..."); + browseButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(browseButton); + + container->connect(browseButton, &QToolButton::clicked, browseCallback); + + // The "clear" button is not shown in output cells + // or the insertion point's input cell. + if (!isOutput && state != RemuxEntryState::Empty) { + QToolButton *clearButton = new QToolButton(); + clearButton->setText("X"); + clearButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(clearButton); + + container->connect(clearButton, &QToolButton::clicked, clearCallback); + } + + container->setLayout(layout); + container->setFocusProxy(text); + return container; + } +} + +void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = editor->findChild(); + text->setText(index.data().toString()); + editor->setProperty(PATH_LIST_PROP, QVariant()); +} + +void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const +{ + // We use the PATH_LIST_PROP property to pass a list of + // path strings from the editor widget into the model's + // NewPathsToProcessRole. This is only used when paths + // are selected through the "browse" or "delete" buttons + // in the editor. If the user enters new text in the + // text box, we simply pass that text on to the model + // as normal text data in the default role. + QVariant pathListProp = editor->property(PATH_LIST_PROP); + if (pathListProp.isValid()) { + QStringList list = editor->property(PATH_LIST_PROP).toStringList(); + if (isOutput) { + if (list.size() > 0) + model->setData(index, list); + } else + model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole); + } else { + QLineEdit *lineEdit = editor->findChild(); + model->setData(index, lineEdit->text()); + } +} + +void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + + QStyleOptionViewItem localOption = option; + initStyleOption(&localOption, index); + + if (isOutput) { + if (state != Ready) { + QColor background = + localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window); + + localOption.backgroundBrush = QBrush(background); + } + } + + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter); +} + +void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container) +{ + QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)"; + + QLineEdit *text = container->findChild(); + + QString currentPath = text->text(); + if (currentPath.isEmpty()) + currentPath = defaultPath; + + bool isSet = false; + if (isOutput) { + QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern); + + if (!newPath.isEmpty()) { + container->setProperty(PATH_LIST_PROP, QStringList() << newPath); + isSet = true; + } + } else { + QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath, + QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern); + + if (!paths.empty()) { + container->setProperty(PATH_LIST_PROP, paths); + isSet = true; + } +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + container->window()->raise(); +#endif + } + + if (isSet) + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::handleClear(QWidget *container) +{ + // An empty string list will indicate that the entry is being + // blanked and should be deleted. + container->setProperty(PATH_LIST_PROP, QStringList()); + + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::updateText() +{ + QLineEdit *lineEdit = dynamic_cast(sender()); + QWidget *editor = lineEdit->parentWidget(); + emit commitData(editor); +} + +/********************************************************** + Model - Manages the queue's data +**********************************************************/ + +int RemuxQueueModel::rowCount(const QModelIndex &) const +{ + return queue.length() + (isProcessing ? 0 : 1); +} + +int RemuxQueueModel::columnCount(const QModelIndex &) const +{ + return RemuxEntryColumn::Count; +} + +QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const +{ + QVariant result = QVariant(); + + if (index.row() >= queue.length()) { + return QVariant(); + } else if (role == Qt::DisplayRole) { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + result = queue[index.row()].sourcePath; + break; + case RemuxEntryColumn::OutputPath: + result = queue[index.row()].targetPath; + break; + } + } else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) { + result = getIcon(queue[index.row()].state); + } else if (role == RemuxEntryRole::EntryStateRole) { + result = queue[index.row()].state; + } + + return result; +} + +QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + QVariant result = QVariant(); + + if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case RemuxEntryColumn::State: + result = QString(); + break; + case RemuxEntryColumn::InputPath: + result = QTStr("Remux.SourceFile"); + break; + case RemuxEntryColumn::OutputPath: + result = QTStr("Remux.TargetFile"); + break; + } + } + + return result; +} + +Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() == RemuxEntryColumn::InputPath) { + flags |= Qt::ItemIsEditable; + } else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + bool success = false; + + if (role == RemuxEntryRole::NewPathsToProcessRole) { + QStringList pathList = value.toStringList(); + + if (pathList.size() == 0) { + if (index.row() < queue.size()) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + if (pathList.size() >= 1 && index.row() < queue.length()) { + queue[index.row()].sourcePath = pathList[0]; + checkInputPath(index.row()); + + pathList.removeAt(0); + + success = true; + } + + if (pathList.size() > 0) { + int row = index.row(); + int lastRow = row + pathList.size() - 1; + beginInsertRows(QModelIndex(), row, lastRow); + + for (QString path : pathList) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + queue.insert(row, entry); + row++; + } + endInsertRows(); + + for (row = index.row(); row <= lastRow; row++) { + checkInputPath(row); + } + + success = true; + } + } + } else if (index.row() == queue.length()) { + QString path = value.toString(); + + if (!path.isEmpty()) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1); + queue.append(entry); + endInsertRows(); + + checkInputPath(index.row()); + success = true; + } + } else { + QString path = value.toString(); + + if (path.isEmpty()) { + if (index.column() == RemuxEntryColumn::InputPath) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + queue[index.row()].sourcePath = value.toString(); + checkInputPath(index.row()); + success = true; + break; + case RemuxEntryColumn::OutputPath: + queue[index.row()].targetPath = value.toString(); + emit dataChanged(index, index); + success = true; + break; + } + } + } + + return success; +} + +QVariant RemuxQueueModel::getIcon(RemuxEntryState state) +{ + QVariant icon; + QStyle *style = QApplication::style(); + + switch (state) { + case RemuxEntryState::Complete: + icon = style->standardIcon(QStyle::SP_DialogApplyButton); + break; + + case RemuxEntryState::InProgress: + icon = style->standardIcon(QStyle::SP_ArrowRight); + break; + + case RemuxEntryState::Error: + icon = style->standardIcon(QStyle::SP_DialogCancelButton); + break; + + case RemuxEntryState::InvalidPath: + icon = style->standardIcon(QStyle::SP_MessageBoxWarning); + break; + + default: + break; + } + + return icon; +} + +void RemuxQueueModel::checkInputPath(int row) +{ + RemuxQueueEntry &entry = queue[row]; + + if (entry.sourcePath.isEmpty()) { + entry.state = RemuxEntryState::Empty; + } else { + entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath); + QFileInfo fileInfo(entry.sourcePath); + if (fileInfo.exists()) + entry.state = RemuxEntryState::Ready; + else + entry.state = RemuxEntryState::InvalidPath; + + QString newExt = ".mp4"; + QString suffix = fileInfo.suffix(); + + if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) { + newExt = ".remuxed." + suffix; + } + + if (entry.state == RemuxEntryState::Ready) + entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() + + fileInfo.completeBaseName() + newExt); + } + + if (entry.state == RemuxEntryState::Ready && isProcessing) + entry.state = RemuxEntryState::Pending; + + emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count)); +} + +QFileInfoList RemuxQueueModel::checkForOverwrites() const +{ + QFileInfoList list; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Ready) { + QFileInfo fileInfo(entry.targetPath); + if (fileInfo.exists()) { + list.append(fileInfo); + } + } + } + + return list; +} + +bool RemuxQueueModel::checkForErrors() const +{ + bool hasErrors = false; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Error) { + hasErrors = true; + break; + } + } + + return hasErrors; +} + +void RemuxQueueModel::clearAll() +{ + beginRemoveRows(QModelIndex(), 0, queue.size() - 1); + queue.clear(); + endRemoveRows(); +} + +void RemuxQueueModel::clearFinished() +{ + int index = 0; + + for (index = 0; index < queue.size(); index++) { + const RemuxQueueEntry &entry = queue[index]; + if (entry.state == RemuxEntryState::Complete) { + beginRemoveRows(QModelIndex(), index, index); + queue.removeAt(index); + endRemoveRows(); + index--; + } + } +} + +bool RemuxQueueModel::canClearFinished() const +{ + bool canClearFinished = false; + for (const RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Complete) { + canClearFinished = true; + break; + } + + return canClearFinished; +} + +void RemuxQueueModel::beginProcessing() +{ + for (RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Ready) + entry.state = RemuxEntryState::Pending; + + // Signal that the insertion point no longer exists. + beginRemoveRows(QModelIndex(), queue.length(), queue.length()); + endRemoveRows(); + + isProcessing = true; + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +void RemuxQueueModel::endProcessing() +{ + for (RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::Ready; + } + } + + // Signal that the insertion point exists again. + isProcessing = false; + if (!autoRemux) { + beginInsertRows(QModelIndex(), queue.length(), queue.length()); + endInsertRows(); + } + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath) +{ + bool anyStarted = false; + + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::InProgress; + + inputPath = entry.sourcePath; + outputPath = entry.targetPath; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + anyStarted = true; + break; + } + } + + return anyStarted; +} + +void RemuxQueueModel::finishEntry(bool success) +{ + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::InProgress) { + if (success) + entry.state = RemuxEntryState::Complete; + else + entry.state = RemuxEntryState::Error; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + break; + } + } +} + +/********************************************************** + The actual remux window implementation +**********************************************************/ + +OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_) + : QDialog(parent), + queueModel(new RemuxQueueModel), + worker(new RemuxWorker()), + ui(new Ui::OBSRemux), + recPath(path), + autoRemux(autoRemux_) +{ + setAcceptDrops(true); + + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + ui->setupUi(this); + + ui->progressBar->setVisible(false); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + + if (autoRemux) { + resize(280, 40); + ui->tableView->hide(); + ui->buttonBox->hide(); + ui->label->hide(); + } + + ui->progressBar->setMinimum(0); + ui->progressBar->setMaximum(1000); + ui->progressBar->setValue(0); + + ui->tableView->setModel(queueModel); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath, + new RemuxEntryPathItemDelegate(false, recPath)); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath, + new RemuxEntryPathItemDelegate(true, recPath)); + ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State, + QHeaderView::ResizeMode::Fixed); + ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); + ui->tableView->setTextElideMode(Qt::ElideMiddle); + ui->tableView->setWordWrap(false); + + installEventFilter(CreateShortcutFilter()); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true); + + connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux); + connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished); + connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this, + &OBSRemux::clearAll); + connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close); + + worker->moveToThread(&remuxer); + remuxer.start(); + + connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress); + connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater); + connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished); + connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux); + + connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged); + connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged); + + QModelIndex index = queueModel->createIndex(0, 1); + QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection, + Q_ARG(const QModelIndex &, index)); +} + +bool OBSRemux::stopRemux() +{ + if (!worker->isWorking) + return true; + + // By locking the worker thread's mutex, we ensure that its + // update poll will be blocked as long as we're in here with + // the popup open. + QMutexLocker lock(&worker->updateMutex); + + bool exit = false; + + if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { + exit = true; + } + + if (exit) { + // Inform the worker it should no longer be + // working. It will interrupt accordingly in + // its next update callback. + worker->isWorking = false; + } + + return exit; +} + +OBSRemux::~OBSRemux() +{ + stopRemux(); + remuxer.quit(); + remuxer.wait(); +} + +void OBSRemux::rowCountChanged(const QModelIndex &, int, int) +{ + // See if there are still any rows ready to remux. Change + // the state of the "go" button accordingly. + // There must be more than one row, since there will always be + // at least one row for the empty insertion point. + if (queueModel->rowCount() > 1) { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + } else { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false); + } +} + +void OBSRemux::dropEvent(QDropEvent *ev) +{ + QStringList urlList; + + for (QUrl url : ev->mimeData()->urls()) { + QFileInfo fileInfo(url.toLocalFile()); + + if (fileInfo.isDir()) { + QStringList directoryFilter; + directoryFilter << "*.flv" + << "*.mp4" + << "*.mov" + << "*.mkv" + << "*.ts" + << "*.m3u8"; + + QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files, + QDirIterator::Subdirectories); + + while (dirIter.hasNext()) { + urlList.append(dirIter.next()); + } + } else { + urlList.append(fileInfo.canonicalFilePath()); + } + } + + if (urlList.empty()) { + QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"), + QMessageBox::Ok); + } else if (!autoRemux) { + QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath); + queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole); + } +} + +void OBSRemux::dragEnterEvent(QDragEnterEvent *ev) +{ + if (ev->mimeData()->hasUrls() && !worker->isWorking) + ev->accept(); +} + +void OBSRemux::beginRemux() +{ + if (worker->isWorking) { + stopRemux(); + return; + } + + bool proceedWithRemux = true; + QFileInfoList overwriteFiles = queueModel->checkForOverwrites(); + + if (!overwriteFiles.empty()) { + QString message = QTStr("Remux.FileExists"); + message += "\n\n"; + + for (QFileInfo fileInfo : overwriteFiles) + message += fileInfo.canonicalFilePath() + "\n"; + + if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes) + proceedWithRemux = false; + } + + if (!proceedWithRemux) + return; + + // Set all jobs to "pending" first. + queueModel->beginProcessing(); + + ui->progressBar->setVisible(true); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop")); + setAcceptDrops(false); + + remuxNextEntry(); +} + +void OBSRemux::AutoRemux(QString inFile, QString outFile) +{ + if (inFile != "" && outFile != "" && autoRemux) { + ui->progressBar->setVisible(true); + emit remux(inFile, outFile); + autoRemuxFile = outFile; + } +} + +void OBSRemux::remuxNextEntry() +{ + worker->lastProgress = 0.f; + + QString inputPath, outputPath; + if (queueModel->beginNextEntry(inputPath, outputPath)) { + emit remux(inputPath, outputPath); + } else { + queueModel->autoRemux = autoRemux; + queueModel->endProcessing(); + + if (!autoRemux) { + OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"), + queueModel->checkForErrors() ? QTStr("Remux.FinishedError") + : QTStr("Remux.Finished")); + } + + ui->progressBar->setVisible(autoRemux); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + setAcceptDrops(true); + } +} + +void OBSRemux::closeEvent(QCloseEvent *event) +{ + if (!stopRemux()) + event->ignore(); + else + QDialog::closeEvent(event); +} + +void OBSRemux::reject() +{ + if (!stopRemux()) + return; + + QDialog::reject(); +} + +void OBSRemux::updateProgress(float percent) +{ + ui->progressBar->setValue(percent * 10); +} + +void OBSRemux::remuxFinished(bool success) +{ + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + + queueModel->finishEntry(success); + + if (autoRemux && autoRemuxFile != "") { + QTimer::singleShot(3000, this, &OBSRemux::close); + + OBSBasic *main = OBSBasic::Get(); + main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile)); + } + + remuxNextEntry(); +} + +void OBSRemux::clearFinished() +{ + queueModel->clearFinished(); +} + +void OBSRemux::clearAll() +{ + queueModel->clearAll(); +} + +/********************************************************** + Worker thread - Executes the libobs remux operation as a + background process. +**********************************************************/ + +void RemuxWorker::UpdateProgress(float percent) +{ + if (abs(lastProgress - percent) < 0.1f) + return; + + emit updateProgress(percent); + lastProgress = percent; +} + +void RemuxWorker::remux(const QString &source, const QString &target) +{ + isWorking = true; + + auto callback = [](void *data, float percent) { + RemuxWorker *rw = static_cast(data); + + QMutexLocker lock(&rw->updateMutex); + + rw->UpdateProgress(percent); + + return rw->isWorking; + }; + + bool stopped = false; + bool success = false; + + media_remux_job_t mr_job = nullptr; + if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) { + + success = media_remux_job_process(mr_job, callback, this); + + media_remux_job_destroy(mr_job); + + stopped = !isWorking; + } + + isWorking = false; + + emit remuxFinished(!stopped && success); +} diff --git a/frontend/utility/RemuxQueueModel.hpp b/frontend/utility/RemuxQueueModel.hpp new file mode 100644 index 000000000..20a13a780 --- /dev/null +++ b/frontend/utility/RemuxQueueModel.hpp @@ -0,0 +1,173 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ui_OBSRemux.h" + +#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; + + std::unique_ptr ui; + + const char *recPath; + + virtual void closeEvent(QCloseEvent *event) override; + virtual void reject() override; + + bool autoRemux; + QString autoRemuxFile; + +public: + explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false); + virtual ~OBSRemux() override; + + using job_t = std::shared_ptr; + + void AutoRemux(QString inFile, QString outFile); + +protected: + virtual void dropEvent(QDropEvent *ev) override; + virtual void dragEnterEvent(QDragEnterEvent *ev) override; + + void remuxNextEntry(); + +private slots: + void rowCountChanged(const QModelIndex &parent, int first, int last); + +public slots: + void updateProgress(float percent); + void remuxFinished(bool success); + void beginRemux(); + bool stopRemux(); + void clearFinished(); + void clearAll(); + +signals: + void remux(const QString &source, const QString &target); +}; + +class RemuxQueueModel : public QAbstractTableModel { + Q_OBJECT + + friend class OBSRemux; + +public: + RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {} + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + Qt::ItemFlags flags(const QModelIndex &index) const; + bool setData(const QModelIndex &index, const QVariant &value, int role); + + QFileInfoList checkForOverwrites() const; + bool checkForErrors() const; + void beginProcessing(); + void endProcessing(); + bool beginNextEntry(QString &inputPath, QString &outputPath); + void finishEntry(bool success); + bool canClearFinished() const; + void clearFinished(); + void clearAll(); + + bool autoRemux = false; + +private: + struct RemuxQueueEntry { + RemuxEntryState state; + + QString sourcePath; + QString targetPath; + }; + + QList queue; + bool isProcessing; + + static QVariant getIcon(RemuxEntryState state); + + void checkInputPath(int row); +}; + +class RemuxWorker : public QObject { + Q_OBJECT + + QMutex updateMutex; + + bool isWorking; + + float lastProgress; + void UpdateProgress(float percent); + + explicit RemuxWorker() : isWorking(false) {} + virtual ~RemuxWorker(){}; + +private slots: + void remux(const QString &source, const QString &target); + +signals: + void updateProgress(float percent); + void remuxFinished(bool success); + + friend class OBSRemux; +}; + +class RemuxEntryPathItemDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath); + + virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const override; + + virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; + virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + virtual void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + bool isOutput; + QString defaultPath; + const char *PATH_LIST_PROP = "pathList"; + + void handleBrowse(QWidget *container); + void handleClear(QWidget *container); + +private slots: + void updateText(); +}; diff --git a/frontend/utility/RemuxWorker.cpp b/frontend/utility/RemuxWorker.cpp new file mode 100644 index 000000000..d72ebbd9c --- /dev/null +++ b/frontend/utility/RemuxWorker.cpp @@ -0,0 +1,924 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "moc_window-remux.cpp" + +#include "obs-app.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "window-basic-main.hpp" + +#include +#include + +using namespace std; + +enum RemuxEntryColumn { + State, + InputPath, + OutputPath, + + Count +}; + +enum RemuxEntryRole { EntryStateRole = Qt::UserRole, NewPathsToProcessRole }; + +/********************************************************** + Delegate - Presents cells in the grid. +**********************************************************/ + +RemuxEntryPathItemDelegate::RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath) + : QStyledItemDelegate(), + isOutput(isOutput), + defaultPath(defaultPath) +{ +} + +QWidget *RemuxEntryPathItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + 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->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QLineEdit *text = new QLineEdit(); + text->setObjectName(QStringLiteral("text")); + text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding, + QSizePolicy::ControlType::LineEdit)); + layout->addWidget(text); + + QObject::connect(text, &QLineEdit::editingFinished, this, &RemuxEntryPathItemDelegate::updateText); + + QToolButton *browseButton = new QToolButton(); + browseButton->setText("..."); + browseButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(browseButton); + + container->connect(browseButton, &QToolButton::clicked, browseCallback); + + // The "clear" button is not shown in output cells + // or the insertion point's input cell. + if (!isOutput && state != RemuxEntryState::Empty) { + QToolButton *clearButton = new QToolButton(); + clearButton->setText("X"); + clearButton->setSizePolicy(buttonSizePolicy); + layout->addWidget(clearButton); + + container->connect(clearButton, &QToolButton::clicked, clearCallback); + } + + container->setLayout(layout); + container->setFocusProxy(text); + return container; + } +} + +void RemuxEntryPathItemDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const +{ + QLineEdit *text = editor->findChild(); + text->setText(index.data().toString()); + editor->setProperty(PATH_LIST_PROP, QVariant()); +} + +void RemuxEntryPathItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, + const QModelIndex &index) const +{ + // We use the PATH_LIST_PROP property to pass a list of + // path strings from the editor widget into the model's + // NewPathsToProcessRole. This is only used when paths + // are selected through the "browse" or "delete" buttons + // in the editor. If the user enters new text in the + // text box, we simply pass that text on to the model + // as normal text data in the default role. + QVariant pathListProp = editor->property(PATH_LIST_PROP); + if (pathListProp.isValid()) { + QStringList list = editor->property(PATH_LIST_PROP).toStringList(); + if (isOutput) { + if (list.size() > 0) + model->setData(index, list); + } else + model->setData(index, list, RemuxEntryRole::NewPathsToProcessRole); + } else { + QLineEdit *lineEdit = editor->findChild(); + model->setData(index, lineEdit->text()); + } +} + +void RemuxEntryPathItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + RemuxEntryState state = index.model() + ->index(index.row(), RemuxEntryColumn::State) + .data(RemuxEntryRole::EntryStateRole) + .value(); + + QStyleOptionViewItem localOption = option; + initStyleOption(&localOption, index); + + if (isOutput) { + if (state != Ready) { + QColor background = + localOption.palette.color(QPalette::ColorGroup::Disabled, QPalette::ColorRole::Window); + + localOption.backgroundBrush = QBrush(background); + } + } + + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &localOption, painter); +} + +void RemuxEntryPathItemDelegate::handleBrowse(QWidget *container) +{ + QString ExtensionPattern = "(*.mp4 *.flv *.mov *.mkv *.ts *.m3u8)"; + + QLineEdit *text = container->findChild(); + + QString currentPath = text->text(); + if (currentPath.isEmpty()) + currentPath = defaultPath; + + bool isSet = false; + if (isOutput) { + QString newPath = SaveFile(container, QTStr("Remux.SelectTarget"), currentPath, ExtensionPattern); + + if (!newPath.isEmpty()) { + container->setProperty(PATH_LIST_PROP, QStringList() << newPath); + isSet = true; + } + } else { + QStringList paths = OpenFiles(container, QTStr("Remux.SelectRecording"), currentPath, + QTStr("Remux.OBSRecording") + QString(" ") + ExtensionPattern); + + if (!paths.empty()) { + container->setProperty(PATH_LIST_PROP, paths); + isSet = true; + } +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + container->window()->raise(); +#endif + } + + if (isSet) + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::handleClear(QWidget *container) +{ + // An empty string list will indicate that the entry is being + // blanked and should be deleted. + container->setProperty(PATH_LIST_PROP, QStringList()); + + emit commitData(container); +} + +void RemuxEntryPathItemDelegate::updateText() +{ + QLineEdit *lineEdit = dynamic_cast(sender()); + QWidget *editor = lineEdit->parentWidget(); + emit commitData(editor); +} + +/********************************************************** + Model - Manages the queue's data +**********************************************************/ + +int RemuxQueueModel::rowCount(const QModelIndex &) const +{ + return queue.length() + (isProcessing ? 0 : 1); +} + +int RemuxQueueModel::columnCount(const QModelIndex &) const +{ + return RemuxEntryColumn::Count; +} + +QVariant RemuxQueueModel::data(const QModelIndex &index, int role) const +{ + QVariant result = QVariant(); + + if (index.row() >= queue.length()) { + return QVariant(); + } else if (role == Qt::DisplayRole) { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + result = queue[index.row()].sourcePath; + break; + case RemuxEntryColumn::OutputPath: + result = queue[index.row()].targetPath; + break; + } + } else if (role == Qt::DecorationRole && index.column() == RemuxEntryColumn::State) { + result = getIcon(queue[index.row()].state); + } else if (role == RemuxEntryRole::EntryStateRole) { + result = queue[index.row()].state; + } + + return result; +} + +QVariant RemuxQueueModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + QVariant result = QVariant(); + + if (role == Qt::DisplayRole && orientation == Qt::Orientation::Horizontal) { + switch (section) { + case RemuxEntryColumn::State: + result = QString(); + break; + case RemuxEntryColumn::InputPath: + result = QTStr("Remux.SourceFile"); + break; + case RemuxEntryColumn::OutputPath: + result = QTStr("Remux.TargetFile"); + break; + } + } + + return result; +} + +Qt::ItemFlags RemuxQueueModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags flags = QAbstractTableModel::flags(index); + + if (index.column() == RemuxEntryColumn::InputPath) { + flags |= Qt::ItemIsEditable; + } else if (index.column() == RemuxEntryColumn::OutputPath && index.row() != queue.length()) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +bool RemuxQueueModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + bool success = false; + + if (role == RemuxEntryRole::NewPathsToProcessRole) { + QStringList pathList = value.toStringList(); + + if (pathList.size() == 0) { + if (index.row() < queue.size()) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + if (pathList.size() >= 1 && index.row() < queue.length()) { + queue[index.row()].sourcePath = pathList[0]; + checkInputPath(index.row()); + + pathList.removeAt(0); + + success = true; + } + + if (pathList.size() > 0) { + int row = index.row(); + int lastRow = row + pathList.size() - 1; + beginInsertRows(QModelIndex(), row, lastRow); + + for (QString path : pathList) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + queue.insert(row, entry); + row++; + } + endInsertRows(); + + for (row = index.row(); row <= lastRow; row++) { + checkInputPath(row); + } + + success = true; + } + } + } else if (index.row() == queue.length()) { + QString path = value.toString(); + + if (!path.isEmpty()) { + RemuxQueueEntry entry; + entry.sourcePath = path; + entry.state = RemuxEntryState::Empty; + + beginInsertRows(QModelIndex(), queue.length() + 1, queue.length() + 1); + queue.append(entry); + endInsertRows(); + + checkInputPath(index.row()); + success = true; + } + } else { + QString path = value.toString(); + + if (path.isEmpty()) { + if (index.column() == RemuxEntryColumn::InputPath) { + beginRemoveRows(QModelIndex(), index.row(), index.row()); + queue.removeAt(index.row()); + endRemoveRows(); + } + } else { + switch (index.column()) { + case RemuxEntryColumn::InputPath: + queue[index.row()].sourcePath = value.toString(); + checkInputPath(index.row()); + success = true; + break; + case RemuxEntryColumn::OutputPath: + queue[index.row()].targetPath = value.toString(); + emit dataChanged(index, index); + success = true; + break; + } + } + } + + return success; +} + +QVariant RemuxQueueModel::getIcon(RemuxEntryState state) +{ + QVariant icon; + QStyle *style = QApplication::style(); + + switch (state) { + case RemuxEntryState::Complete: + icon = style->standardIcon(QStyle::SP_DialogApplyButton); + break; + + case RemuxEntryState::InProgress: + icon = style->standardIcon(QStyle::SP_ArrowRight); + break; + + case RemuxEntryState::Error: + icon = style->standardIcon(QStyle::SP_DialogCancelButton); + break; + + case RemuxEntryState::InvalidPath: + icon = style->standardIcon(QStyle::SP_MessageBoxWarning); + break; + + default: + break; + } + + return icon; +} + +void RemuxQueueModel::checkInputPath(int row) +{ + RemuxQueueEntry &entry = queue[row]; + + if (entry.sourcePath.isEmpty()) { + entry.state = RemuxEntryState::Empty; + } else { + entry.sourcePath = QDir::toNativeSeparators(entry.sourcePath); + QFileInfo fileInfo(entry.sourcePath); + if (fileInfo.exists()) + entry.state = RemuxEntryState::Ready; + else + entry.state = RemuxEntryState::InvalidPath; + + QString newExt = ".mp4"; + QString suffix = fileInfo.suffix(); + + if (suffix.contains("mov", Qt::CaseInsensitive) || suffix.contains("mp4", Qt::CaseInsensitive)) { + newExt = ".remuxed." + suffix; + } + + if (entry.state == RemuxEntryState::Ready) + entry.targetPath = QDir::toNativeSeparators(fileInfo.path() + QDir::separator() + + fileInfo.completeBaseName() + newExt); + } + + if (entry.state == RemuxEntryState::Ready && isProcessing) + entry.state = RemuxEntryState::Pending; + + emit dataChanged(index(row, 0), index(row, RemuxEntryColumn::Count)); +} + +QFileInfoList RemuxQueueModel::checkForOverwrites() const +{ + QFileInfoList list; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Ready) { + QFileInfo fileInfo(entry.targetPath); + if (fileInfo.exists()) { + list.append(fileInfo); + } + } + } + + return list; +} + +bool RemuxQueueModel::checkForErrors() const +{ + bool hasErrors = false; + + for (const RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Error) { + hasErrors = true; + break; + } + } + + return hasErrors; +} + +void RemuxQueueModel::clearAll() +{ + beginRemoveRows(QModelIndex(), 0, queue.size() - 1); + queue.clear(); + endRemoveRows(); +} + +void RemuxQueueModel::clearFinished() +{ + int index = 0; + + for (index = 0; index < queue.size(); index++) { + const RemuxQueueEntry &entry = queue[index]; + if (entry.state == RemuxEntryState::Complete) { + beginRemoveRows(QModelIndex(), index, index); + queue.removeAt(index); + endRemoveRows(); + index--; + } + } +} + +bool RemuxQueueModel::canClearFinished() const +{ + bool canClearFinished = false; + for (const RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Complete) { + canClearFinished = true; + break; + } + + return canClearFinished; +} + +void RemuxQueueModel::beginProcessing() +{ + for (RemuxQueueEntry &entry : queue) + if (entry.state == RemuxEntryState::Ready) + entry.state = RemuxEntryState::Pending; + + // Signal that the insertion point no longer exists. + beginRemoveRows(QModelIndex(), queue.length(), queue.length()); + endRemoveRows(); + + isProcessing = true; + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +void RemuxQueueModel::endProcessing() +{ + for (RemuxQueueEntry &entry : queue) { + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::Ready; + } + } + + // Signal that the insertion point exists again. + isProcessing = false; + if (!autoRemux) { + beginInsertRows(QModelIndex(), queue.length(), queue.length()); + endInsertRows(); + } + + emit dataChanged(index(0, RemuxEntryColumn::State), index(queue.length(), RemuxEntryColumn::State)); +} + +bool RemuxQueueModel::beginNextEntry(QString &inputPath, QString &outputPath) +{ + bool anyStarted = false; + + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::Pending) { + entry.state = RemuxEntryState::InProgress; + + inputPath = entry.sourcePath; + outputPath = entry.targetPath; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + anyStarted = true; + break; + } + } + + return anyStarted; +} + +void RemuxQueueModel::finishEntry(bool success) +{ + for (int row = 0; row < queue.length(); row++) { + RemuxQueueEntry &entry = queue[row]; + if (entry.state == RemuxEntryState::InProgress) { + if (success) + entry.state = RemuxEntryState::Complete; + else + entry.state = RemuxEntryState::Error; + + QModelIndex index = this->index(row, RemuxEntryColumn::State); + emit dataChanged(index, index); + + break; + } + } +} + +/********************************************************** + The actual remux window implementation +**********************************************************/ + +OBSRemux::OBSRemux(const char *path, QWidget *parent, bool autoRemux_) + : QDialog(parent), + queueModel(new RemuxQueueModel), + worker(new RemuxWorker()), + ui(new Ui::OBSRemux), + recPath(path), + autoRemux(autoRemux_) +{ + setAcceptDrops(true); + + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + ui->setupUi(this); + + ui->progressBar->setVisible(false); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + + if (autoRemux) { + resize(280, 40); + ui->tableView->hide(); + ui->buttonBox->hide(); + ui->label->hide(); + } + + ui->progressBar->setMinimum(0); + ui->progressBar->setMaximum(1000); + ui->progressBar->setValue(0); + + ui->tableView->setModel(queueModel); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::InputPath, + new RemuxEntryPathItemDelegate(false, recPath)); + ui->tableView->setItemDelegateForColumn(RemuxEntryColumn::OutputPath, + new RemuxEntryPathItemDelegate(true, recPath)); + ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch); + ui->tableView->horizontalHeader()->setSectionResizeMode(RemuxEntryColumn::State, + QHeaderView::ResizeMode::Fixed); + ui->tableView->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged); + ui->tableView->setTextElideMode(Qt::ElideMiddle); + ui->tableView->setWordWrap(false); + + installEventFilter(CreateShortcutFilter()); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setText(QTStr("Remux.ClearFinished")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setText(QTStr("Remux.ClearAll")); + ui->buttonBox->button(QDialogButtonBox::Reset)->setDisabled(true); + + connect(ui->buttonBox->button(QDialogButtonBox::Ok), &QPushButton::clicked, this, &OBSRemux::beginRemux); + connect(ui->buttonBox->button(QDialogButtonBox::Reset), &QPushButton::clicked, this, &OBSRemux::clearFinished); + connect(ui->buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this, + &OBSRemux::clearAll); + connect(ui->buttonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, &OBSRemux::close); + + worker->moveToThread(&remuxer); + remuxer.start(); + + connect(worker.data(), &RemuxWorker::updateProgress, this, &OBSRemux::updateProgress); + connect(&remuxer, &QThread::finished, worker.data(), &QObject::deleteLater); + connect(worker.data(), &RemuxWorker::remuxFinished, this, &OBSRemux::remuxFinished); + connect(this, &OBSRemux::remux, worker.data(), &RemuxWorker::remux); + + connect(queueModel.data(), &RemuxQueueModel::rowsInserted, this, &OBSRemux::rowCountChanged); + connect(queueModel.data(), &RemuxQueueModel::rowsRemoved, this, &OBSRemux::rowCountChanged); + + QModelIndex index = queueModel->createIndex(0, 1); + QMetaObject::invokeMethod(ui->tableView, "setCurrentIndex", Qt::QueuedConnection, + Q_ARG(const QModelIndex &, index)); +} + +bool OBSRemux::stopRemux() +{ + if (!worker->isWorking) + return true; + + // By locking the worker thread's mutex, we ensure that its + // update poll will be blocked as long as we're in here with + // the popup open. + QMutexLocker lock(&worker->updateMutex); + + bool exit = false; + + if (QMessageBox::critical(nullptr, QTStr("Remux.ExitUnfinishedTitle"), QTStr("Remux.ExitUnfinished"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { + exit = true; + } + + if (exit) { + // Inform the worker it should no longer be + // working. It will interrupt accordingly in + // its next update callback. + worker->isWorking = false; + } + + return exit; +} + +OBSRemux::~OBSRemux() +{ + stopRemux(); + remuxer.quit(); + remuxer.wait(); +} + +void OBSRemux::rowCountChanged(const QModelIndex &, int, int) +{ + // See if there are still any rows ready to remux. Change + // the state of the "go" button accordingly. + // There must be more than one row, since there will always be + // at least one row for the empty insertion point. + if (queueModel->rowCount() > 1) { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + } else { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(false); + } +} + +void OBSRemux::dropEvent(QDropEvent *ev) +{ + QStringList urlList; + + for (QUrl url : ev->mimeData()->urls()) { + QFileInfo fileInfo(url.toLocalFile()); + + if (fileInfo.isDir()) { + QStringList directoryFilter; + directoryFilter << "*.flv" + << "*.mp4" + << "*.mov" + << "*.mkv" + << "*.ts" + << "*.m3u8"; + + QDirIterator dirIter(fileInfo.absoluteFilePath(), directoryFilter, QDir::Files, + QDirIterator::Subdirectories); + + while (dirIter.hasNext()) { + urlList.append(dirIter.next()); + } + } else { + urlList.append(fileInfo.canonicalFilePath()); + } + } + + if (urlList.empty()) { + QMessageBox::information(nullptr, QTStr("Remux.NoFilesAddedTitle"), QTStr("Remux.NoFilesAdded"), + QMessageBox::Ok); + } else if (!autoRemux) { + QModelIndex insertIndex = queueModel->index(queueModel->rowCount() - 1, RemuxEntryColumn::InputPath); + queueModel->setData(insertIndex, urlList, RemuxEntryRole::NewPathsToProcessRole); + } +} + +void OBSRemux::dragEnterEvent(QDragEnterEvent *ev) +{ + if (ev->mimeData()->hasUrls() && !worker->isWorking) + ev->accept(); +} + +void OBSRemux::beginRemux() +{ + if (worker->isWorking) { + stopRemux(); + return; + } + + bool proceedWithRemux = true; + QFileInfoList overwriteFiles = queueModel->checkForOverwrites(); + + if (!overwriteFiles.empty()) { + QString message = QTStr("Remux.FileExists"); + message += "\n\n"; + + for (QFileInfo fileInfo : overwriteFiles) + message += fileInfo.canonicalFilePath() + "\n"; + + if (OBSMessageBox::question(this, QTStr("Remux.FileExistsTitle"), message) != QMessageBox::Yes) + proceedWithRemux = false; + } + + if (!proceedWithRemux) + return; + + // Set all jobs to "pending" first. + queueModel->beginProcessing(); + + ui->progressBar->setVisible(true); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Stop")); + setAcceptDrops(false); + + remuxNextEntry(); +} + +void OBSRemux::AutoRemux(QString inFile, QString outFile) +{ + if (inFile != "" && outFile != "" && autoRemux) { + ui->progressBar->setVisible(true); + emit remux(inFile, outFile); + autoRemuxFile = outFile; + } +} + +void OBSRemux::remuxNextEntry() +{ + worker->lastProgress = 0.f; + + QString inputPath, outputPath; + if (queueModel->beginNextEntry(inputPath, outputPath)) { + emit remux(inputPath, outputPath); + } else { + queueModel->autoRemux = autoRemux; + queueModel->endProcessing(); + + if (!autoRemux) { + OBSMessageBox::information(this, QTStr("Remux.FinishedTitle"), + queueModel->checkForErrors() ? QTStr("Remux.FinishedError") + : QTStr("Remux.Finished")); + } + + ui->progressBar->setVisible(autoRemux); + ui->buttonBox->button(QDialogButtonBox::Ok)->setText(QTStr("Remux.Remux")); + ui->buttonBox->button(QDialogButtonBox::RestoreDefaults)->setEnabled(true); + ui->buttonBox->button(QDialogButtonBox::Reset)->setEnabled(queueModel->canClearFinished()); + setAcceptDrops(true); + } +} + +void OBSRemux::closeEvent(QCloseEvent *event) +{ + if (!stopRemux()) + event->ignore(); + else + QDialog::closeEvent(event); +} + +void OBSRemux::reject() +{ + if (!stopRemux()) + return; + + QDialog::reject(); +} + +void OBSRemux::updateProgress(float percent) +{ + ui->progressBar->setValue(percent * 10); +} + +void OBSRemux::remuxFinished(bool success) +{ + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + + queueModel->finishEntry(success); + + if (autoRemux && autoRemuxFile != "") { + QTimer::singleShot(3000, this, &OBSRemux::close); + + OBSBasic *main = OBSBasic::Get(); + main->ShowStatusBarMessage(QTStr("Basic.StatusBar.AutoRemuxedTo").arg(autoRemuxFile)); + } + + remuxNextEntry(); +} + +void OBSRemux::clearFinished() +{ + queueModel->clearFinished(); +} + +void OBSRemux::clearAll() +{ + queueModel->clearAll(); +} + +/********************************************************** + Worker thread - Executes the libobs remux operation as a + background process. +**********************************************************/ + +void RemuxWorker::UpdateProgress(float percent) +{ + if (abs(lastProgress - percent) < 0.1f) + return; + + emit updateProgress(percent); + lastProgress = percent; +} + +void RemuxWorker::remux(const QString &source, const QString &target) +{ + isWorking = true; + + auto callback = [](void *data, float percent) { + RemuxWorker *rw = static_cast(data); + + QMutexLocker lock(&rw->updateMutex); + + rw->UpdateProgress(percent); + + return rw->isWorking; + }; + + bool stopped = false; + bool success = false; + + media_remux_job_t mr_job = nullptr; + if (media_remux_job_create(&mr_job, QT_TO_UTF8(source), QT_TO_UTF8(target))) { + + success = media_remux_job_process(mr_job, callback, this); + + media_remux_job_destroy(mr_job); + + stopped = !isWorking; + } + + isWorking = false; + + emit remuxFinished(!stopped && success); +} diff --git a/frontend/utility/RemuxWorker.hpp b/frontend/utility/RemuxWorker.hpp new file mode 100644 index 000000000..20a13a780 --- /dev/null +++ b/frontend/utility/RemuxWorker.hpp @@ -0,0 +1,173 @@ +/****************************************************************************** + Copyright (C) 2014 by Ruwen Hahn + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ui_OBSRemux.h" + +#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; + + std::unique_ptr ui; + + const char *recPath; + + virtual void closeEvent(QCloseEvent *event) override; + virtual void reject() override; + + bool autoRemux; + QString autoRemuxFile; + +public: + explicit OBSRemux(const char *recPath, QWidget *parent = nullptr, bool autoRemux = false); + virtual ~OBSRemux() override; + + using job_t = std::shared_ptr; + + void AutoRemux(QString inFile, QString outFile); + +protected: + virtual void dropEvent(QDropEvent *ev) override; + virtual void dragEnterEvent(QDragEnterEvent *ev) override; + + void remuxNextEntry(); + +private slots: + void rowCountChanged(const QModelIndex &parent, int first, int last); + +public slots: + void updateProgress(float percent); + void remuxFinished(bool success); + void beginRemux(); + bool stopRemux(); + void clearFinished(); + void clearAll(); + +signals: + void remux(const QString &source, const QString &target); +}; + +class RemuxQueueModel : public QAbstractTableModel { + Q_OBJECT + + friend class OBSRemux; + +public: + RemuxQueueModel(QObject *parent = 0) : QAbstractTableModel(parent), isProcessing(false) {} + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + int columnCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const; + Qt::ItemFlags flags(const QModelIndex &index) const; + bool setData(const QModelIndex &index, const QVariant &value, int role); + + QFileInfoList checkForOverwrites() const; + bool checkForErrors() const; + void beginProcessing(); + void endProcessing(); + bool beginNextEntry(QString &inputPath, QString &outputPath); + void finishEntry(bool success); + bool canClearFinished() const; + void clearFinished(); + void clearAll(); + + bool autoRemux = false; + +private: + struct RemuxQueueEntry { + RemuxEntryState state; + + QString sourcePath; + QString targetPath; + }; + + QList queue; + bool isProcessing; + + static QVariant getIcon(RemuxEntryState state); + + void checkInputPath(int row); +}; + +class RemuxWorker : public QObject { + Q_OBJECT + + QMutex updateMutex; + + bool isWorking; + + float lastProgress; + void UpdateProgress(float percent); + + explicit RemuxWorker() : isWorking(false) {} + virtual ~RemuxWorker(){}; + +private slots: + void remux(const QString &source, const QString &target); + +signals: + void updateProgress(float percent); + void remuxFinished(bool success); + + friend class OBSRemux; +}; + +class RemuxEntryPathItemDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + RemuxEntryPathItemDelegate(bool isOutput, const QString &defaultPath); + + virtual QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem & /* option */, + const QModelIndex &index) const override; + + virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; + virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + virtual void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + bool isOutput; + QString defaultPath; + const char *PATH_LIST_PROP = "pathList"; + + void handleBrowse(QWidget *container); + void handleClear(QWidget *container); + +private slots: + void updateText(); +};