diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index 1ed137860..62a8a9503 100644 --- a/UI/CMakeLists.txt +++ b/UI/CMakeLists.txt @@ -146,10 +146,12 @@ if(BROWSER_AVAILABLE_INTERNAL) list(APPEND obs_PLATFORM_SOURCES obf.c auth-oauth.cpp + window-extra-browsers.cpp ) list(APPEND obs_PLATFORM_HEADERS obf.h auth-oauth.hpp + window-extra-browsers.hpp ) if(TWITCH_ENABLED) @@ -324,6 +326,7 @@ set(obs_UI forms/OBSBasicSettings.ui forms/OBSBasicSourceSelect.ui forms/OBSBasicInteraction.ui + forms/OBSExtraBrowsers.ui forms/OBSUpdate.ui forms/OBSRemux.ui forms/OBSAbout.ui) diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini index f029ce3bb..b526efedc 100644 --- a/UI/data/locale/en-US.ini +++ b/UI/data/locale/en-US.ini @@ -98,6 +98,11 @@ AlreadyRunning.LaunchAnyway="Launch Anyway" DockCloseWarning.Title="Closing Dockable Window" DockCloseWarning.Text="You just closed a dockable window. If you'd like to show it again, use the View → Docks menu on the menu bar." +# extra browser panels dialog +ExtraBrowsers="Custom Browser Docks" +ExtraBrowsers.Info="Add a dock by giving it a name and URL, then click Apply or Close to configure where it is on your screen. You can add or remove docks at any time." +ExtraBrowsers.DockName="Dock Name" + # Auth Auth.Authing.Title="Authenticating..." Auth.Authing.Text="Authenticating with %1, please wait..." @@ -572,6 +577,7 @@ Basic.MainMenu.View.Toolbars="&Toolbars" Basic.MainMenu.View.Docks="Docks" Basic.MainMenu.View.Docks.ResetUI="Reset UI" Basic.MainMenu.View.Docks.LockUI="Lock UI" +Basic.MainMenu.View.Docks.CustomBrowserDocks="Custom Browser Docks..." Basic.MainMenu.View.Toolbars.Listboxes="&Listboxes" Basic.MainMenu.View.SceneTransitions="S&cene Transitions" Basic.MainMenu.View.StatusBar="&Status Bar" diff --git a/UI/forms/OBSExtraBrowsers.ui b/UI/forms/OBSExtraBrowsers.ui new file mode 100644 index 000000000..a7d3a27a5 --- /dev/null +++ b/UI/forms/OBSExtraBrowsers.ui @@ -0,0 +1,98 @@ + + + OBSExtraBrowsers + + + + 0 + 0 + 785 + 353 + + + + ExtraBrowsers + + + + + + ExtraBrowsers.Info + + + true + + + + + + + QAbstractItemView::NoSelection + + + 23 + + + 23 + + + false + + + 23 + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Apply + + + + + + + Close + + + + + + + + + + + close + clicked() + OBSExtraBrowsers + close() + + + 520 + 286 + + + 435 + -19 + + + + + diff --git a/UI/window-basic-main.cpp b/UI/window-basic-main.cpp index 10817c132..8437bdc3b 100644 --- a/UI/window-basic-main.cpp +++ b/UI/window-basic-main.cpp @@ -1689,6 +1689,23 @@ void OBSBasic::OBSInit() OBSBasicStats *statsDlg = new OBSBasicStats(statsDock, false); statsDock->setWidget(statsDlg); + /* ----------------------------- */ + /* add custom browser docks */ + +#ifdef BROWSER_AVAILABLE + if (cef) { + QAction *action = new QAction(QTStr("Basic.MainMenu." + "View.Docks." + "CustomBrowserDocks")); + ui->viewMenuDocks->insertAction(ui->toggleScenes, action); + connect(action, &QAction::triggered, this, + &OBSBasic::ManageExtraBrowserDocks); + ui->viewMenuDocks->insertSeparator(ui->toggleScenes); + + LoadExtraBrowserDocks(); + } +#endif + const char *dockStateStr = config_get_string( App()->GlobalConfig(), "BasicWindow", "DockState"); if (!dockStateStr) { @@ -3794,9 +3811,16 @@ void OBSBasic::closeEvent(QCloseEvent *event) SaveProjectNow(); auth.reset(); + delete extraBrowsers; + config_set_string(App()->GlobalConfig(), "BasicWindow", "DockState", saveState().toBase64().constData()); +#ifdef BROWSER_AVAILABLE + SaveExtraBrowserDocks(); + ClearExtraBrowserDocks(); +#endif + if (api) api->on_event(OBS_FRONTEND_EVENT_EXIT); diff --git a/UI/window-basic-main.hpp b/UI/window-basic-main.hpp index ff4ddb1ce..419c11eac 100644 --- a/UI/window-basic-main.hpp +++ b/UI/window-basic-main.hpp @@ -124,6 +124,8 @@ class OBSBasic : public OBSMainWindow { friend class AutoConfig; friend class AutoConfigStreamPage; friend class RecordButton; + friend class ExtraBrowsersModel; + friend class ExtraBrowsersDelegate; friend struct OBSStudioAPI; enum class MoveDir { Up, Down, Left, Right }; @@ -200,6 +202,7 @@ private: QPointer stats; QPointer remux; + QPointer extraBrowsers; QPointer startStreamMenu; @@ -420,6 +423,19 @@ private: bool NoSourcesConfirmation(); +#ifdef BROWSER_AVAILABLE + QList> extraBrowserDocks; + QList> extraBrowserDockActions; + QStringList extraBrowserDockTargets; + + void ClearExtraBrowserDocks(); + void LoadExtraBrowserDocks(); + void SaveExtraBrowserDocks(); + void ManageExtraBrowserDocks(); + void AddExtraBrowserDock(const QString &title, const QString &url, + bool firstCreate); +#endif + public slots: void DeferSaveBegin(); void DeferSaveEnd(); diff --git a/UI/window-extra-browsers.cpp b/UI/window-extra-browsers.cpp new file mode 100644 index 000000000..c1e6f8e4a --- /dev/null +++ b/UI/window-extra-browsers.cpp @@ -0,0 +1,575 @@ +#include "window-extra-browsers.hpp" +#include "window-basic-main.hpp" +#include "qt-wrappers.hpp" +#include "window-dock.hpp" + +#include +#include + +#include + +#include "ui_OBSExtraBrowsers.h" + +#include +extern QCef *cef; +extern QCefCookieManager *panel_cookies; + +using namespace json11; + +#define OBJ_NAME_SUFFIX "_extraBrowser" + +enum class Column : int { + Title, + Url, + Delete, + + Count, +}; + +class ExtraBrowser : public OBSDock { +public: + inline ExtraBrowser() : OBSDock() {} + + QScopedPointer cefWidget; + + inline void SetWidget(QCefWidget *widget_) + { + setWidget(widget_); + cefWidget.reset(widget_); + } +}; + +/* ------------------------------------------------------------------------- */ + +void ExtraBrowsersModel::Reset() +{ + items.clear(); + + OBSBasic *main = OBSBasic::Get(); + + for (int i = 0; i < main->extraBrowserDocks.size(); i++) { + ExtraBrowser *dock = reinterpret_cast( + main->extraBrowserDocks[i].data()); + + Item item; + item.prevIdx = i; + item.title = dock->windowTitle(); + 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()); + + QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Expanding, + QSizePolicy::PushButton); + policy.setWidthForHeight(true); + + QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr); + + QPushButton *del = new DelButton(index); + del->setProperty("themeID", "trashIcon"); + del->setSizePolicy(policy); + del->setFlat(true); + connect(del, &QPushButton::clicked, this, + &ExtraBrowsersModel::DeleteItem); + + widget->setIndexWidget(index, del); +} + +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(); + ExtraBrowser *dock = reinterpret_cast( + main->extraBrowserDocks[idx].data()); + dock->setWindowTitle(item.title); + dock->setObjectName(item.title + OBJ_NAME_SUFFIX); + main->extraBrowserDockActions[idx]->setText(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 { + main->AddExtraBrowserDock(item.title, item.url, true); + } + } + + for (int i = deleted.size() - 1; i >= 0; i--) { + int idx = deleted[i]; + main->extraBrowserDockActions.removeAt(idx); + main->extraBrowserDockTargets.removeAt(idx); + main->extraBrowserDocks.removeAt(idx); + } + + 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() +{ + delete ui; +} + +void OBSExtraBrowsers::closeEvent(QCloseEvent *event) +{ + QDialog::closeEvent(event); + model->Apply(); +} + +void OBSExtraBrowsers::on_apply_clicked() +{ + model->Apply(); +} + +/* ------------------------------------------------------------------------- */ + +void OBSBasic::ClearExtraBrowserDocks() +{ + extraBrowserDockTargets.clear(); + extraBrowserDockActions.clear(); + extraBrowserDocks.clear(); +} + +void OBSBasic::LoadExtraBrowserDocks() +{ + const char *jsonStr = config_get_string( + App()->GlobalConfig(), "BasicWindow", "ExtraBrowserDocks"); + + std::string err; + Json json = Json::parse(jsonStr, err); + if (!err.empty()) + return; + + Json::array array = json.array_items(); + for (Json &item : array) { + std::string title = item["title"].string_value(); + std::string url = item["url"].string_value(); + + AddExtraBrowserDock(title.c_str(), url.c_str(), false); + } +} + +void OBSBasic::SaveExtraBrowserDocks() +{ + Json::array array; + for (int i = 0; i < extraBrowserDocks.size(); i++) { + QAction *action = extraBrowserDockActions[i].data(); + QString url = extraBrowserDockTargets[i]; + Json::object obj{ + {"title", QT_TO_UTF8(action->text())}, + {"url", QT_TO_UTF8(url)}, + }; + array.push_back(obj); + } + + std::string output = Json(array).dump(); + config_set_string(App()->GlobalConfig(), "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, + bool firstCreate) +{ + static int panel_version = -1; + if (panel_version == -1) { + panel_version = obs_browser_qcef_version(); + } + + ExtraBrowser *dock = new ExtraBrowser(); + dock->setObjectName(title + OBJ_NAME_SUFFIX); + dock->resize(460, 600); + dock->setMinimumSize(150, 150); + dock->setWindowTitle(title); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + + QCefWidget *browser = + cef->create_widget(nullptr, QT_TO_UTF8(url), nullptr); + if (browser && panel_version >= 1) + browser->allowAllPopups(true); + + dock->SetWidget(browser); + + addDockWidget(Qt::RightDockWidgetArea, dock); + + 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); + } + + extraBrowserDocks.push_back(QSharedPointer(dock)); + extraBrowserDockActions.push_back( + QSharedPointer(AddDockWidget(dock))); + extraBrowserDockTargets.push_back(url); +} diff --git a/UI/window-extra-browsers.hpp b/UI/window-extra-browsers.hpp new file mode 100644 index 000000000..cdfb14b05 --- /dev/null +++ b/UI/window-extra-browsers.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include + +class Ui_OBSExtraBrowsers; +class ExtraBrowsersModel; + +class QCefWidget; + +class OBSExtraBrowsers : public QDialog { + Q_OBJECT + + Ui_OBSExtraBrowsers *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; +};