diff --git a/frontend/OBSApp.cpp b/frontend/OBSApp.cpp index 6ade93c5d..45d307ff6 100644 --- a/frontend/OBSApp.cpp +++ b/frontend/OBSApp.cpp @@ -929,6 +929,7 @@ OBSApp::OBSApp(int &argc, char **argv, profiler_name_store_t *store) #endif setDesktopFileName("com.obsproject.Studio"); + pluginManager_ = std::make_unique(); } @@ -1262,6 +1263,8 @@ bool OBSApp::OBSInit() setQuitOnLastWindowClosed(false); + thumbnailManager = new ThumbnailManager(this); + mainWindow = new OBSBasic(); mainWindow->setAttribute(Qt::WA_DeleteOnClose, true); diff --git a/frontend/OBSApp.hpp b/frontend/OBSApp.hpp index 862073084..dabca7876 100644 --- a/frontend/OBSApp.hpp +++ b/frontend/OBSApp.hpp @@ -17,8 +17,9 @@ #pragma once -#include #include +#include +#include #include #include @@ -90,6 +91,8 @@ private: std::deque translatorHooks; + ThumbnailManager *thumbnailManager = nullptr; + std::unique_ptr pluginManager_; bool UpdatePre22MultiviewLayout(const char *layout); @@ -229,6 +232,8 @@ public: void loadAppModules(struct obs_module_failure_info &mfi); + ThumbnailManager *thumbnails() const { return thumbnailManager; } + // Plugin Manager Accessors void pluginManagerOpenDialog(); diff --git a/frontend/cmake/ui-components.cmake b/frontend/cmake/ui-components.cmake index 0ddfe9d1f..34f4c0e7f 100644 --- a/frontend/cmake/ui-components.cmake +++ b/frontend/cmake/ui-components.cmake @@ -36,6 +36,10 @@ target_sources( components/DisplayCaptureToolbar.cpp components/DisplayCaptureToolbar.hpp components/EditWidget.hpp + components/FlowFrame.cpp + components/FlowFrame.hpp + components/FlowLayout.cpp + components/FlowLayout.hpp components/FocusList.cpp components/FocusList.hpp components/GameCaptureToolbar.cpp @@ -63,6 +67,8 @@ target_sources( components/SceneTree.hpp components/SilentUpdateCheckBox.hpp components/SilentUpdateSpinBox.hpp + components/SourceSelectButton.cpp + components/SourceSelectButton.hpp components/SourceToolbar.cpp components/SourceToolbar.hpp components/SourceTree.cpp diff --git a/frontend/cmake/ui-utility.cmake b/frontend/cmake/ui-utility.cmake index 8b02d5ec7..387385da8 100644 --- a/frontend/cmake/ui-utility.cmake +++ b/frontend/cmake/ui-utility.cmake @@ -49,6 +49,7 @@ target_sources( utility/RemuxQueueModel.hpp utility/RemuxWorker.cpp utility/RemuxWorker.hpp + utility/ResizeSignaler.hpp utility/SceneRenameDelegate.cpp utility/SceneRenameDelegate.hpp utility/ScreenshotObj.cpp @@ -58,6 +59,12 @@ target_sources( utility/SimpleOutput.hpp utility/StartMultiTrackVideoStreamingGuard.hpp utility/SurfaceEventFilter.hpp + utility/ThumbnailItem.cpp + utility/ThumbnailItem.hpp + utility/ThumbnailManager.cpp + utility/ThumbnailManager.hpp + utility/ThumbnailView.cpp + utility/ThumbnailView.hpp utility/VCamConfig.hpp utility/audio-encoders.cpp utility/audio-encoders.hpp diff --git a/frontend/components/FlowFrame.cpp b/frontend/components/FlowFrame.cpp new file mode 100644 index 000000000..3974f1d85 --- /dev/null +++ b/frontend/components/FlowFrame.cpp @@ -0,0 +1,134 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + + 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 + +#include +#include + +FlowFrame::FlowFrame(QWidget *parent) : QFrame(parent) +{ + layout = new FlowLayout(this); + setLayout(layout); +} + +void FlowFrame::keyPressEvent(QKeyEvent *event) +{ + QWidget *focused = focusWidget(); + if (!focused) { + return; + } + + int index = -1; + for (int i = 0; i < layout->count(); ++i) { + if (!layout->itemAt(i)->widget()) { + continue; + } + + auto focusProxy = layout->itemAt(i)->widget()->focusProxy(); + if (layout->itemAt(i)->widget() == focused || focusProxy == focused) { + if (focusProxy == focused) { + focused = layout->itemAt(i)->widget(); + } + + index = i; + break; + } + } + + if (index == -1) { + return; + } + + const QRect focusedRect = focused->geometry(); + QWidget *nextFocus = nullptr; + + switch (event->key()) { + case Qt::Key_Right: + case Qt::Key_Down: + case Qt::Key_Left: + case Qt::Key_Up: { + // Find next widget in the given direction + int bestDistance = INT_MAX; + for (int i = 0; i < layout->count(); ++i) { + if (i == index) { + continue; + } + + QWidget *widget = layout->itemAt(i)->widget(); + const QRect rect = widget->geometry(); + + bool isCandidate = false; + int distance = INT_MAX; + + switch (event->key()) { + case Qt::Key_Right: + if (rect.left() > focusedRect.right()) { + distance = (rect.left() - focusedRect.right()) + + qAbs(rect.center().y() - focusedRect.center().y()); + isCandidate = true; + } + break; + case Qt::Key_Left: + if (rect.right() < focusedRect.left()) { + distance = (focusedRect.left() - rect.right()) + + qAbs(rect.center().y() - focusedRect.center().y()); + isCandidate = true; + } + break; + case Qt::Key_Down: + if (rect.top() > focusedRect.bottom()) { + distance = (rect.top() - focusedRect.bottom()) + + qAbs(rect.center().x() - focusedRect.center().x()); + isCandidate = true; + } + break; + case Qt::Key_Up: + if (rect.bottom() < focusedRect.top()) { + distance = (focusedRect.top() - rect.bottom()) + + qAbs(rect.center().x() - focusedRect.center().x()); + isCandidate = true; + } + break; + } + + if (isCandidate && distance < bestDistance) { + bestDistance = distance; + nextFocus = widget; + } + } + break; + } + default: + QWidget::keyPressEvent(event); + return; + } + + if (nextFocus) { + nextFocus->setFocus(); + + QWidget *scrollParent = nextFocus->parentWidget(); + while (scrollParent) { + QScrollArea *scrollArea = qobject_cast(scrollParent); + if (scrollArea) { + scrollArea->ensureWidgetVisible(nextFocus, 20, 20); + break; + } + scrollParent = scrollParent->parentWidget(); + } + } +} diff --git a/frontend/components/FlowFrame.hpp b/frontend/components/FlowFrame.hpp new file mode 100644 index 000000000..0ad1164ce --- /dev/null +++ b/frontend/components/FlowFrame.hpp @@ -0,0 +1,37 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + + 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 "FlowLayout.hpp" + +#include + +class FlowFrame : public QFrame { + Q_OBJECT + +public: + explicit FlowFrame(QWidget *parent = nullptr); + + FlowLayout *flowLayout() const { return layout; } + +protected: + void keyPressEvent(QKeyEvent *event) override; + +private: + FlowLayout *layout; +}; diff --git a/frontend/components/FlowLayout.cpp b/frontend/components/FlowLayout.cpp new file mode 100644 index 000000000..448e168f9 --- /dev/null +++ b/frontend/components/FlowLayout.cpp @@ -0,0 +1,170 @@ +/****************************************************************************** + Example provided by Qt + + + Copyright (C) 2016 The Qt Company Ltd. + SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +******************************************************************************/ + +#include "FlowLayout.hpp" + +#include + +FlowLayout::FlowLayout(QWidget *parent, int margin, int hSpacing, int vSpacing) + : QLayout(parent), + m_hSpace(hSpacing), + m_vSpace(vSpacing) +{ + setContentsMargins(margin, margin, margin, margin); +} + +FlowLayout::FlowLayout(int margin, int hSpacing, int vSpacing) : m_hSpace(hSpacing), m_vSpace(vSpacing) +{ + setContentsMargins(margin, margin, margin, margin); +} + +FlowLayout::~FlowLayout() +{ + QLayoutItem *item; + while ((item = takeAt(0))) { + delete item; + } +} + +void FlowLayout::addItem(QLayoutItem *item) +{ + itemList.append(item); +} + +int FlowLayout::horizontalSpacing() const +{ + if (m_hSpace >= 0) { + return m_hSpace; + } else { + return smartSpacing(QStyle::PM_LayoutHorizontalSpacing); + } +} + +int FlowLayout::verticalSpacing() const +{ + if (m_vSpace >= 0) { + return m_vSpace; + } else { + return smartSpacing(QStyle::PM_LayoutVerticalSpacing); + } +} + +int FlowLayout::count() const +{ + return itemList.size(); +} + +QLayoutItem *FlowLayout::itemAt(int index) const +{ + return itemList.value(index); +} + +QLayoutItem *FlowLayout::takeAt(int index) +{ + if (index >= 0 && index < itemList.size()) { + return itemList.takeAt(index); + } + return nullptr; +} + +Qt::Orientations FlowLayout::expandingDirections() const +{ + return {}; +} + +bool FlowLayout::hasHeightForWidth() const +{ + return true; +} + +int FlowLayout::heightForWidth(int width) const +{ + int height = doLayout(QRect(0, 0, width, 0), true); + return height; +} + +void FlowLayout::setGeometry(const QRect &rect) +{ + QLayout::setGeometry(rect); + doLayout(rect, false); +} + +QSize FlowLayout::sizeHint() const +{ + return minimumSize(); +} + +QSize FlowLayout::minimumSize() const +{ + QSize size; + for (const QLayoutItem *item : std::as_const(itemList)) { + size = size.expandedTo(item->minimumSize()); + } + + const QMargins margins = contentsMargins(); + size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom()); + return size; +} + +int FlowLayout::doLayout(const QRect &rect, bool testOnly) const +{ + int left; + int top; + int right; + int bottom; + + getContentsMargins(&left, &top, &right, &bottom); + QRect effectiveRect = rect.adjusted(+left, +top, -right, -bottom); + int x = effectiveRect.x(); + int y = effectiveRect.y(); + int lineHeight = 0; + + for (QLayoutItem *item : std::as_const(itemList)) { + const QWidget *wid = item->widget(); + int spaceX = horizontalSpacing(); + if (spaceX == -1) { + spaceX = wid->style()->layoutSpacing(QSizePolicy::PushButton, QSizePolicy::PushButton, + Qt::Horizontal); + } + + int spaceY = verticalSpacing(); + if (spaceY == -1) { + spaceY = wid->style()->layoutSpacing(QSizePolicy::PushButton, QSizePolicy::PushButton, + Qt::Vertical); + } + + int nextX = x + item->sizeHint().width() + spaceX; + if (nextX - spaceX > effectiveRect.right() && lineHeight > 0) { + x = effectiveRect.x(); + y = y + lineHeight + spaceY; + nextX = x + item->sizeHint().width() + spaceX; + lineHeight = 0; + } + + if (!testOnly) { + item->setGeometry(QRect(QPoint(x, y), item->sizeHint())); + } + + x = nextX; + lineHeight = qMax(lineHeight, item->sizeHint().height()); + } + return y + lineHeight - rect.y() + bottom; +} + +int FlowLayout::smartSpacing(QStyle::PixelMetric pm) const +{ + QObject *parent = this->parent(); + if (!parent) { + return -1; + } else if (parent->isWidgetType()) { + QWidget *pw = static_cast(parent); + return pw->style()->pixelMetric(pm, nullptr, pw); + } else { + return static_cast(parent)->spacing(); + } +} diff --git a/frontend/components/FlowLayout.hpp b/frontend/components/FlowLayout.hpp new file mode 100644 index 000000000..9d3cb32be --- /dev/null +++ b/frontend/components/FlowLayout.hpp @@ -0,0 +1,40 @@ +/****************************************************************************** + Example provided by Qt + + + Copyright (C) 2016 The Qt Company Ltd. + SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause +******************************************************************************/ + +#pragma once + +#include +#include + +class FlowLayout : public QLayout { +public: + explicit FlowLayout(QWidget *parent, int margin = -1, int hSpacing = -1, int vSpacing = -1); + explicit FlowLayout(int margin = -1, int hSpacing = -1, int vSpacing = -1); + ~FlowLayout(); + + void addItem(QLayoutItem *item) override; + int horizontalSpacing() const; + int verticalSpacing() const; + Qt::Orientations expandingDirections() const override; + bool hasHeightForWidth() const override; + int heightForWidth(int) const override; + int count() const override; + QLayoutItem *itemAt(int index) const override; + QSize minimumSize() const override; + void setGeometry(const QRect &rect) override; + QSize sizeHint() const override; + QLayoutItem *takeAt(int index) override; + +private: + int doLayout(const QRect &rect, bool testOnly) const; + int smartSpacing(QStyle::PixelMetric pm) const; + + QList itemList; + int m_hSpace; + int m_vSpace; +}; diff --git a/frontend/components/SourceSelectButton.cpp b/frontend/components/SourceSelectButton.cpp new file mode 100644 index 000000000..527d0d91f --- /dev/null +++ b/frontend/components/SourceSelectButton.cpp @@ -0,0 +1,222 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 . +******************************************************************************/ + +#include "SourceSelectButton.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +SourceSelectButton::SourceSelectButton(OBSWeakSource weak, QWidget *parent) : QAbstractButton(parent), weakSource(weak) +{ + OBSSource source = OBSGetStrongRef(weak); + if (!source) { + return; + } + + sourceUuid = obs_source_get_uuid(source); + const char *sourceName = obs_source_get_name(source); + + setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + + setCheckable(true); + setAccessibleName(sourceName); + + QVBoxLayout *layout = new QVBoxLayout(); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + setLayout(layout); + + label = new QLabel(sourceName); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setAttribute(Qt::WA_TransparentForMouseEvents); + label->setObjectName("name"); + + image = new QLabel(this); + image->setObjectName("thumbnail"); + image->setAttribute(Qt::WA_TransparentForMouseEvents); + image->setMinimumSize(160, 90); + image->setMaximumSize(160, 90); + image->setAlignment(Qt::AlignCenter); + + thumbnail = App()->thumbnails()->createView(this, source); + connect(thumbnail, &ThumbnailView::updated, this, &SourceSelectButton::updatePixmap); + updatePixmap(thumbnail->getPixmap()); + + layout->addWidget(image); + layout->addWidget(label); + + setFocusPolicy(Qt::StrongFocus); + + signalHandlers.reserve(2); + signalHandlers.emplace_back(obs_source_get_signal_handler(source), "destroy", + &SourceSelectButton::obsSourceRemoved, this); + signalHandlers.emplace_back(obs_source_get_signal_handler(source), "rename", + &SourceSelectButton::obsSourceRenamed, this); + + connect(this, &QAbstractButton::pressed, this, &SourceSelectButton::buttonPressed); +} + +SourceSelectButton::~SourceSelectButton() {} + +void SourceSelectButton::paintEvent(QPaintEvent *) +{ + QPainter painter{this}; + + QStyleOptionButton option; + option.initFrom(this); + + if (isChecked()) { + option.state |= QStyle::State_On; + } + if (isDown()) { + option.state |= QStyle::State_Sunken; + } + if (hasFocus()) { + option.state |= QStyle::State_HasFocus; + } + if (underMouse()) { + option.state |= QStyle::State_MouseOver; + } + + style()->drawControl(QStyle::CE_PushButton, &option, &painter, this); +} + +void SourceSelectButton::resizeEvent(QResizeEvent *) +{ + QStyleOptionButton option; + option.initFrom(this); + + QRect contentRect = style()->subElementRect(QStyle::SE_PushButtonContents, &option, this); + if (!contentRect.isValid()) { + contentRect = rect(); + } + + int left = contentRect.left(); + int top = contentRect.top(); + int right = rect().width() - contentRect.right() - 1; + int bottom = rect().height() - contentRect.bottom() - 1; + + left = std::max(0, left); + top = std::max(0, top); + right = std::max(0, right); + bottom = std::max(0, bottom); + + if (QLayout *layout = this->layout()) { + layout->setContentsMargins(left, top, right, bottom); + } +} + +void SourceSelectButton::enterEvent(QEnterEvent *) +{ + update(); + thumbnail->requestUpdate(); +} + +void SourceSelectButton::leaveEvent(QEvent *) +{ + update(); +} + +void SourceSelectButton::buttonPressed() +{ + dragStartPosition = mapFromGlobal(QCursor::pos()); +} + +void SourceSelectButton::obsSourceRemoved(void *data, calldata_t *) +{ + QMetaObject::invokeMethod(static_cast(data), &SourceSelectButton::handleSourceRemoved); +} + +void SourceSelectButton::obsSourceRenamed(void *data, calldata_t *params) +{ + const char *newNamePtr = static_cast(calldata_ptr(params, "new_name")); + if (!newNamePtr) { + return; + } + + QMetaObject::invokeMethod(static_cast(data), "handleSourceRenamed", Qt::QueuedConnection, + Q_ARG(QString, QString::fromUtf8(newNamePtr))); +} + +void SourceSelectButton::handleSourceRemoved() +{ + emit sourceRemoved(); +} + +void SourceSelectButton::handleSourceRenamed(QString name) +{ + label->setText(name); +} + +void SourceSelectButton::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton)) { + return; + } + + if ((event->pos() - dragStartPosition).manhattanLength() < QApplication::startDragDistance() * 3) { + return; + } + + QMimeData *mimeData = new QMimeData; + + mimeData->setData("application/x-obs-source-uuid", sourceUuid.data()); + + QDrag *drag = new QDrag(this); + drag->setMimeData(mimeData); + drag->setPixmap(this->grab()); + drag->exec(Qt::CopyAction); + + if (isDown()) { + setDown(false); + } +} + +void SourceSelectButton::setThumbnailEnabled(bool enabled) +{ + OBSSource source = OBSGetStrongRef(weakSource); + if (!source) { + return; + } + + if (thumbnailEnabled != enabled) { + thumbnailEnabled = enabled; + + thumbnail->setEnabled(thumbnailEnabled); + } +} + +void SourceSelectButton::updateThumbnail() +{ + thumbnail->requestUpdate(); +} + +void SourceSelectButton::updatePixmap(QPixmap pixmap) +{ + if (!pixmap.isNull()) { + image->setPixmap( + pixmap.scaled(image->width(), image->height(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +} diff --git a/frontend/components/SourceSelectButton.hpp b/frontend/components/SourceSelectButton.hpp new file mode 100644 index 000000000..44a8ae5ad --- /dev/null +++ b/frontend/components/SourceSelectButton.hpp @@ -0,0 +1,75 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 +#include + +class QLabel; +class Thumbnail; +class ThumbnailView; + +class SourceSelectButton : public QAbstractButton { + Q_OBJECT + +public: + SourceSelectButton(OBSWeakSource weak, QWidget *parent = nullptr); + ~SourceSelectButton(); + + std::string_view uuid() const { return sourceUuid; }; + + void setThumbnailEnabled(bool enabled); + void updateThumbnail(); + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void buttonPressed(); + +private: + OBSWeakSource weakSource; + QPointer thumbnail; + QPointer image; + std::string sourceUuid; + + std::vector signalHandlers; + static void obsSourceRemoved(void *param, calldata_t *calldata); + static void obsSourceRenamed(void *param, calldata_t *calldata); + + QLabel *label = nullptr; + bool thumbnailEnabled = true; + + QPoint dragStartPosition; + +private slots: + void updatePixmap(QPixmap pixmap); + void handleSourceRemoved(); + void handleSourceRenamed(QString name); + +signals: + void sourceRemoved(); +}; diff --git a/frontend/data/locale/en-US.ini b/frontend/data/locale/en-US.ini index 3775d9224..a86d2a729 100644 --- a/frontend/data/locale/en-US.ini +++ b/frontend/data/locale/en-US.ini @@ -637,15 +637,21 @@ RenameProfile.Title="Rename Profile" Basic.Main.MixerRename.Title="Rename Audio Source" Basic.Main.MixerRename.Text="Please enter the name of the audio source" - # preview window disabled Basic.Main.PreviewDisabled="Preview is currently disabled" # add source dialog -Basic.SourceSelect="Create/Select Source" -Basic.SourceSelect.CreateNew="Create new" -Basic.SourceSelect.AddExisting="Add Existing" +Basic.SourceSelect="Add Source" +Basic.SourceSelect.SelectType="Source Type" +Basic.SourceSelect.Recent="Recently Added" +Basic.SourceSelect.NewSource="Create a New Source" +Basic.SourceSelect.Existing="Add an Existing Source" +Basic.SourceSelect.CreateButton="Create New" Basic.SourceSelect.AddVisible="Make source visible" +Basic.SourceSelect.NoExisting="No existing %1 sources" +Basic.SourceSelect.Accessible.SourceName="Source Name" +Basic.SourceSelect.Accessible.Existing="Add an Existing Source" +Basic.SourceSelect.Deprecated.Create="This source type is marked as deprecated and may be removed in the future." # source box Basic.Main.Sources.Visibility="Visibility" @@ -783,6 +789,7 @@ Basic.Main.ShowContextBar="Show Source Toolbar" Basic.Main.HideContextBar="Hide Source Toolbar" Basic.Main.StopVirtualCam="Stop Virtual Camera" Basic.Main.Group="Group %1" +Basic.Main.NewGroup="New Group" Basic.Main.GroupItems="Group Selected Items" Basic.Main.Ungroup="Ungroup" Basic.Main.GridMode="Grid Mode" diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index 557b35a4f..f3ece482d 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -2430,6 +2430,48 @@ OBSBasicAdvAudio #scrollAreaWidgetContents { border: 1px solid var(--input_border_hover); } +/* Add Source Dialog */ +SourceSelectButton { + background: var(--grey5); + padding: var(--padding_base) var(--padding_large); + margin: var(--spacing_base); + border-radius: var(--border_radius); + border: 1px solid var(--grey3); + outline: none; +} + +SourceSelectButton QLabel { + padding: var(--padding_large) 0; + text-align: center; +} + +SourceSelectButton #thumbnail { + background: var(--grey6); + border-radius: var(--border_radius); + padding: 0; + margin-top: var(--spacing_base); +} + +SourceSelectButton:hover { + background-color: var(--button_bg_hover); +} + +SourceSelectButton:checked { + background-color: var(--primary); + border-color: var(--primary_light); +} + +SourceSelectButton:focus, +SourceSelectButton:checked:focus { + border-color: var(--white3); +} + +SourceSelectButton:pressed, +SourceSelectButton:pressed:hover { + background-color: var(--button_bg_down); + border-color: var(--button_border); +} + /* Idian Widgets */ idian--Group { border-radius: var(--border_radius); diff --git a/frontend/data/themes/Yami_Acri.ovt b/frontend/data/themes/Yami_Acri.ovt index cda26606d..ce189d7bd 100644 --- a/frontend/data/themes/Yami_Acri.ovt +++ b/frontend/data/themes/Yami_Acri.ovt @@ -201,3 +201,14 @@ idian--ToggleSwitch { qproperty-background_checked: var(--button_bg); qproperty-background_checked_hover: var(--primary_light); } + +/* Add Source Dialog */ +SourceSelectButton QPushButton, +SourceSelectButton #thumbnail { + border-color: var(--grey3); +} + +SourceSelectButton QPushButton:checked { + background: var(--button_bg_red); + border-color: var(--button_bg_red_hover); +} diff --git a/frontend/data/themes/Yami_Classic.ovt b/frontend/data/themes/Yami_Classic.ovt index a7c153705..58e4bc4b9 100644 --- a/frontend/data/themes/Yami_Classic.ovt +++ b/frontend/data/themes/Yami_Classic.ovt @@ -22,6 +22,7 @@ --primary: rgb(25,52,76); --primary_light: rgb(33,71,109); + --primary_dark: rgb(19, 40, 58); /* Layout */ --padding_large: min(max(0px, calc(1px * var(--padding_base_value))), 5px); @@ -83,6 +84,13 @@ --padding_menu_y: calc(3px + calc(1 * var(--padding_base_value))); } +/* --------------------- */ +/* General Styling Hints */ + +.dialog-frame { + background: var(--bg_window); +} + QStatusBar { background-color: var(--bg_window); } @@ -253,3 +261,8 @@ VolumeMeter { OBSBasicStats { background: var(--bg_window); } + +/* Add Source Dialog */ +SourceSelectButton QPushButton { + background-color: var(--grey7); +} diff --git a/frontend/data/themes/Yami_Light.ovt b/frontend/data/themes/Yami_Light.ovt index f2aa4bb1a..82ff22fcc 100644 --- a/frontend/data/themes/Yami_Light.ovt +++ b/frontend/data/themes/Yami_Light.ovt @@ -10,7 +10,7 @@ --grey1: rgb(140,140,140); --grey2: rgb(254,254,254); --grey3: rgb(254,254,254); - --grey4: rgb(243,243,243); + --grey4: rgb(245,245,245); --grey5: rgb(236,236,236); --grey6: rgb(229,229,229); --grey7: rgb(211,211,211); @@ -18,13 +18,14 @@ --primary: rgb(140,181,255); --primary_light: rgb(178,207,255); - --primary_dark: rgb(22,31,65); + --primary_dark: rgb(122, 164, 243); --bg_window: var(--grey7); --bg_base: var(--grey6); --bg_preview: var(--grey8); --text: var(--black1); + --text_light: var(--black3); --text_muted: var(--black4); --text_disabled: var(--text_muted); @@ -33,7 +34,10 @@ --input_bg_hover: var(--grey3); --input_bg_focus: var(--grey3); + --input_border_hover: var(--black5); + --button_bg_disabled: var(--grey7); + --button_border_hover: var(--black5); --separator_hover: var(--black1); @@ -43,6 +47,13 @@ --scrollbar_border: var(--grey7); } +/* --------------------- */ +/* General Styling Hints */ + +.button-primary:hover { + border-color: var(--button_border_hover); +} + /* Mute Button */ .btn-mute { @@ -397,3 +408,19 @@ idian--ExpandButton::indicator { idian--ExpandButton::indicator:checked { image: url(theme:Light/up.svg); } + +/* Add Source Dialog */ +SourceSelectButton QPushButton, +SourceSelectButton #thumbnail { + border-color: var(--grey3); +} + +SourceSelectButton QPushButton:checked { + background: var(--primary); + border-color: var(--primary_light); +} + +SourceSelectButton QPushButton:checked:focus, +SourceSelectButton QPushButton:focus { + border-color: var(--black3); +} diff --git a/frontend/data/themes/Yami_Rachni.ovt b/frontend/data/themes/Yami_Rachni.ovt index 585f1e56f..d271517b4 100644 --- a/frontend/data/themes/Yami_Rachni.ovt +++ b/frontend/data/themes/Yami_Rachni.ovt @@ -200,3 +200,9 @@ VolumeMeter { qproperty-majorTickColor: palette(window-text); qproperty-minorTickColor: palette(mid); } + +/* Add Source Dialog */ +SourceSelectButton QPushButton, +SourceSelectButton #thumbnail { + border-color: var(--grey3); +} diff --git a/frontend/dialogs/OBSBasicSourceSelect.cpp b/frontend/dialogs/OBSBasicSourceSelect.cpp index 44c4ee766..7cd5f39ff 100644 --- a/frontend/dialogs/OBSBasicSourceSelect.cpp +++ b/frontend/dialogs/OBSBasicSourceSelect.cpp @@ -1,5 +1,6 @@ /****************************************************************************** Copyright (C) 2023 by Lain Bailey + Copyright (C) 2025 by Taylor Giampaolo 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 @@ -15,14 +16,28 @@ along with this program. If not, see . ******************************************************************************/ +#include "OBSApp.hpp" #include "OBSBasicSourceSelect.hpp" -#include +#include +#include +#include + +#include "qt-wrappers.hpp" + +#include +#include #include "moc_OBSBasicSourceSelect.cpp" +constexpr int kUnversionedIdRole = Qt::UserRole + 1; +constexpr int kDeprecatedRole = Qt::UserRole + 2; + +constexpr QStringView kRecentTypeId = u"_recent"; +constexpr int kRecentListLimit = 16; + struct AddSourceData { - /* Input data */ + // Input data obs_source_t *source; bool visible; obs_transform_info *transform = nullptr; @@ -38,118 +53,85 @@ struct AddSourceData { uint32_t hide_transition_duration = 300; OBSData private_settings; - /* Return data */ + // Return data obs_sceneitem_t *scene_item = nullptr; }; -bool OBSBasicSourceSelect::EnumSources(void *data, obs_source_t *source) +namespace { +QString getDisplayNameForSourceType(QString type) { - if (obs_source_is_hidden(source)) - return true; - - OBSBasicSourceSelect *window = static_cast(data); - const char *name = obs_source_get_name(source); - const char *id = obs_source_get_unversioned_id(source); - - if (strcmp(id, window->id) == 0) - window->ui->sourceList->addItem(QT_UTF8(name)); - - return true; -} - -bool OBSBasicSourceSelect::EnumGroups(void *data, obs_source_t *source) -{ - OBSBasicSourceSelect *window = static_cast(data); - const char *name = obs_source_get_name(source); - const char *id = obs_source_get_unversioned_id(source); - - if (strcmp(id, window->id) == 0) { - OBSBasic *main = OBSBasic::Get(); - OBSScene scene = main->GetCurrentScene(); - - obs_sceneitem_t *existing = obs_scene_get_group(scene, name); - if (!existing) - window->ui->sourceList->addItem(QT_UTF8(name)); + if (type == "scene") { + return QTStr("Basic.Scene"); } - return true; + const char *inputChar = obs_get_latest_input_type_id(type.toUtf8().constData()); + const char *displayName = obs_source_get_display_name(inputChar); + + if (!displayName) { + return QString(); + } + + return QString::fromUtf8(displayName); } -void OBSBasicSourceSelect::OBSSourceAdded(void *data, calldata_t *calldata) +std::string getNewSourceName(const std::string name) { - OBSBasicSourceSelect *window = static_cast(data); - obs_source_t *source = (obs_source_t *)calldata_ptr(calldata, "source"); + std::string newName{name}; + int suffix = 1; - QMetaObject::invokeMethod(window, "SourceAdded", Q_ARG(OBSSource, source)); + for (;;) { + OBSSourceAutoRelease existing_source = obs_get_source_by_name(newName.c_str()); + if (!existing_source) { + break; + } + + char nextName[256]; + std::snprintf(nextName, sizeof(nextName), "%s %d", name.data(), ++suffix); + newName = nextName; + } + + return newName; } -void OBSBasicSourceSelect::OBSSourceRemoved(void *data, calldata_t *calldata) -{ - OBSBasicSourceSelect *window = static_cast(data); - obs_source_t *source = (obs_source_t *)calldata_ptr(calldata, "source"); - - QMetaObject::invokeMethod(window, "SourceRemoved", Q_ARG(OBSSource, source)); -} - -void OBSBasicSourceSelect::SourceAdded(OBSSource source) -{ - const char *name = obs_source_get_name(source); - const char *sourceId = obs_source_get_unversioned_id(source); - - if (strcmp(sourceId, id) != 0) - return; - - ui->sourceList->addItem(name); -} - -void OBSBasicSourceSelect::SourceRemoved(OBSSource source) -{ - const char *name = obs_source_get_name(source); - const char *sourceId = obs_source_get_unversioned_id(source); - - if (strcmp(sourceId, id) != 0) - return; - - QList items = ui->sourceList->findItems(name, Qt::MatchFixedString); - - if (!items.count()) - return; - - delete items[0]; -} - -static void AddSource(void *_data, obs_scene_t *scene) +void setupSceneItem(void *_data, obs_scene_t *scene) { AddSourceData *data = (AddSourceData *)_data; obs_sceneitem_t *sceneitem; sceneitem = obs_scene_add(scene, data->source); - if (data->transform != nullptr) + if (data->transform != nullptr) { obs_sceneitem_set_info2(sceneitem, data->transform); - if (data->crop != nullptr) + } + if (data->crop != nullptr) { obs_sceneitem_set_crop(sceneitem, data->crop); - if (data->blend_method != nullptr) + } + if (data->blend_method != nullptr) { obs_sceneitem_set_blending_method(sceneitem, *data->blend_method); - if (data->blend_mode != nullptr) + } + if (data->blend_mode != nullptr) { obs_sceneitem_set_blending_mode(sceneitem, *data->blend_mode); - if (data->scale_type != nullptr) + } + if (data->scale_type != nullptr) { obs_sceneitem_set_scale_filter(sceneitem, *data->scale_type); + } if (data->show_transition_id && *data->show_transition_id) { OBSSourceAutoRelease source = obs_source_create(data->show_transition_id, data->show_transition_id, data->show_transition_settings, nullptr); - if (source) + if (source) { obs_sceneitem_set_transition(sceneitem, true, source); + } } if (data->hide_transition_id && *data->hide_transition_id) { OBSSourceAutoRelease source = obs_source_create(data->hide_transition_id, data->hide_transition_id, data->hide_transition_settings, nullptr); - if (source) + if (source) { obs_sceneitem_set_transition(sceneitem, false, source); + } } obs_sceneitem_set_transition_duration(sceneitem, true, data->show_transition_duration); @@ -165,44 +147,32 @@ static void AddSource(void *_data, obs_scene_t *scene) data->scene_item = sceneitem; } -char *get_new_source_name(const char *name, const char *format) +std::optional setupExistingSource(std::string_view uuid, bool visible, bool duplicate, + SourceCopyInfo *info = nullptr) { - struct dstr new_name = {0}; - int inc = 0; - - dstr_copy(&new_name, name); - - for (;;) { - OBSSourceAutoRelease existing_source = obs_get_source_by_name(new_name.array); - if (!existing_source) - break; - - dstr_printf(&new_name, format, name, ++inc + 1); + OBSSourceAutoRelease temp = obs_get_source_by_uuid(uuid.data()); + if (!temp) { + return std::nullopt; } - return new_name.array; -} - -static void AddExisting(OBSSource source, bool visible, bool duplicate, SourceCopyInfo *info = nullptr) -{ OBSBasic *main = OBSBasic::Get(); OBSScene scene = main->GetCurrentScene(); - if (!scene) - return; + if (!scene) { + return std::nullopt; + } if (duplicate) { - OBSSource from = source; - char *new_name = get_new_source_name(obs_source_get_name(source), "%s %d"); - source = obs_source_duplicate(from, new_name, false); - obs_source_release(source); - bfree(new_name); + OBSSource source = temp.Get(); + std::string new_name = getNewSourceName(obs_source_get_name(source)); + temp = obs_source_duplicate(source, new_name.c_str(), false); - if (!source) - return; + if (!source) { + return std::nullopt; + } } AddSourceData data; - data.source = source; + data.source = temp; data.visible = visible; if (info) { @@ -221,173 +191,743 @@ static void AddExisting(OBSSource source, bool visible, bool duplicate, SourceCo } obs_enter_graphics(); - obs_scene_atomic_update(scene, AddSource, &data); + obs_scene_atomic_update(scene, setupSceneItem, &data); obs_leave_graphics(); + + return OBSSceneItem(data.scene_item); } -static void AddExisting(const char *name, bool visible, bool duplicate) -{ - OBSSourceAutoRelease source = obs_get_source_by_name(name); - if (source) { - AddExisting(source.Get(), visible, duplicate); - } -} - -bool AddNew(QWidget *parent, const char *id, const char *name, const bool visible, OBSSource &newSource, - OBSSceneItem &newSceneItem) +std::optional setupNewSource(QWidget *parent, const char *id, const char *name) { OBSBasic *main = OBSBasic::Get(); OBSScene scene = main->GetCurrentScene(); - bool success = false; - if (!scene) - return false; + + if (!scene) { + return std::nullopt; + } OBSSourceAutoRelease source = obs_get_source_by_name(name); if (source && parent) { OBSMessageBox::information(parent, QTStr("NameExists.Title"), QTStr("NameExists.Text")); - - } else { - const char *v_id = obs_get_latest_input_type_id(id); - source = obs_source_create(v_id, name, NULL, nullptr); - - if (source) { - AddSourceData data; - data.source = source; - data.visible = visible; - - obs_enter_graphics(); - obs_scene_atomic_update(scene, AddSource, &data); - obs_leave_graphics(); - - newSource = source; - newSceneItem = data.scene_item; - - /* set monitoring if source monitors by default */ - uint32_t flags = obs_source_get_output_flags(source); - if ((flags & OBS_SOURCE_MONITOR_BY_DEFAULT) != 0) { - obs_source_set_monitoring_type(source, OBS_MONITORING_TYPE_MONITOR_ONLY); - } - - success = true; - } + return std::nullopt; } - return success; -} - -void OBSBasicSourceSelect::on_buttonBox_accepted() -{ - bool useExisting = ui->selectExisting->isChecked(); - bool visible = ui->sourceVisible->isChecked(); - - if (useExisting) { - QListWidgetItem *item = ui->sourceList->currentItem(); - if (!item) - return; - - QString source_name = item->text(); - AddExisting(QT_TO_UTF8(source_name), visible, false); - - OBSBasic *main = OBSBasic::Get(); - const char *scene_name = obs_source_get_name(main->GetCurrentSceneSource()); - - auto undo = [scene_name, main](const std::string &) { - obs_source_t *scene_source = obs_get_source_by_name(scene_name); - main->SetCurrentScene(scene_source, true); - obs_source_release(scene_source); - - obs_scene_t *scene = obs_get_scene_by_name(scene_name); - OBSSceneItem item; - auto cb = [](obs_scene_t *, obs_sceneitem_t *sceneitem, void *data) { - OBSSceneItem &last = *static_cast(data); - last = sceneitem; - return true; - }; - obs_scene_enum_items(scene, cb, &item); - - obs_sceneitem_remove(item); - obs_scene_release(scene); - }; - - auto redo = [scene_name, main, source_name, visible](const std::string &) { - obs_source_t *scene_source = obs_get_source_by_name(scene_name); - main->SetCurrentScene(scene_source, true); - obs_source_release(scene_source); - AddExisting(QT_TO_UTF8(source_name), visible, false); - }; - - undo_s.add_action(QTStr("Undo.Add").arg(source_name), undo, redo, "", ""); - } else { - if (ui->sourceName->text().isEmpty()) { - OBSMessageBox::warning(this, QTStr("NoNameEntered.Title"), QTStr("NoNameEntered.Text")); - return; - } - - OBSSceneItem item; - if (!AddNew(this, id, QT_TO_UTF8(ui->sourceName->text()), visible, newSource, item)) - return; - - OBSBasic *main = OBSBasic::Get(); - std::string scene_name = obs_source_get_name(main->GetCurrentSceneSource()); - auto undo = [scene_name, main](const std::string &data) { - OBSSourceAutoRelease source = obs_get_source_by_name(data.c_str()); - obs_source_remove(source); - - OBSSourceAutoRelease scene_source = obs_get_source_by_name(scene_name.c_str()); - main->SetCurrentScene(scene_source.Get(), true); - }; - OBSDataAutoRelease wrapper = obs_data_create(); - obs_data_set_string(wrapper, "id", id); - obs_data_set_int(wrapper, "item_id", obs_sceneitem_get_id(item)); - obs_data_set_string(wrapper, "name", ui->sourceName->text().toUtf8().constData()); - obs_data_set_bool(wrapper, "visible", visible); - - auto redo = [scene_name, main](const std::string &data) { - OBSSourceAutoRelease scene_source = obs_get_source_by_name(scene_name.c_str()); - main->SetCurrentScene(scene_source.Get(), true); - - OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str()); - OBSSource source; - OBSSceneItem item; - AddNew(NULL, obs_data_get_string(dat, "id"), obs_data_get_string(dat, "name"), - obs_data_get_bool(dat, "visible"), source, item); - obs_sceneitem_set_id(item, (int64_t)obs_data_get_int(dat, "item_id")); - }; - undo_s.add_action(QTStr("Undo.Add").arg(ui->sourceName->text()), undo, redo, - std::string(obs_source_get_name(newSource)), std::string(obs_data_get_json(wrapper))); - } - - done(DialogCode::Accepted); -} - -void OBSBasicSourceSelect::on_buttonBox_rejected() -{ - done(DialogCode::Rejected); -} - -static inline const char *GetSourceDisplayName(const char *id) -{ - if (strcmp(id, "scene") == 0) - return Str("Basic.Scene"); - else if (strcmp(id, "group") == 0) - return Str("Group"); const char *v_id = obs_get_latest_input_type_id(id); - return obs_source_get_display_name(v_id); -} -OBSBasicSourceSelect::OBSBasicSourceSelect(OBSBasic *parent, const char *id_, undo_stack &undo_s) + source = obs_source_create(v_id, name, NULL, nullptr); + if (!source) { + return std::nullopt; + } + + return OBSSource(source.Get()); +} +} // namespace + +OBSBasicSourceSelect::OBSBasicSourceSelect(OBSBasic *parent, undo_stack &undo_s) : QDialog(parent), ui(new Ui::OBSBasicSourceSelect), - id(id_), - undo_s(undo_s) + undo_s(undo_s), + selectedTypeId(kRecentTypeId.toString()), + sourceButtons(new QButtonGroup(this)) { setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); ui->setupUi(this); - ui->sourceList->setAttribute(Qt::WA_MacShowFocusRect, false); + existingFlowLayout = ui->existingListFrame->flowLayout(); + existingFlowLayout->setContentsMargins(0, 0, 0, 0); + existingFlowLayout->setSpacing(0); - QString placeHolderText{QT_UTF8(GetSourceDisplayName(id))}; + // The scroll viewport is not accessible via Designer, so we have to disable autoFillBackground here. + // Additionally when Qt calls setWidget on a scrollArea to set the contents widget, it force sets + // autoFillBackground to true overriding whatever is set in Designer so we have to do that here too. + ui->existingScrollArea->viewport()->setAutoFillBackground(false); + ui->existingScrollContents->setAutoFillBackground(false); + + ResizeSignaler *resizeSignaler = new ResizeSignaler(ui->existingScrollArea); + ui->existingScrollArea->installEventFilter(resizeSignaler); + + connect(resizeSignaler, &ResizeSignaler::resized, this, &OBSBasicSourceSelect::updateButtonVisibility); + connect(ui->existingScrollArea->verticalScrollBar(), &QScrollBar::valueChanged, this, + &OBSBasicSourceSelect::updateButtonVisibility); + connect(ui->existingScrollArea->horizontalScrollBar(), &QScrollBar::valueChanged, this, + &OBSBasicSourceSelect::updateButtonVisibility); + + ui->createNewFrame->setVisible(false); + ui->deprecatedCreateLabel->setVisible(false); + ui->deprecatedCreateLabel->setProperty("class", "text-muted"); + + rebuildSourceTypeList(); + refreshSources(); + + updateExistingSources(kRecentListLimit); + + signalHandlers.reserve(2); + signalHandlers.emplace_back(obs_get_signal_handler(), "source_create", &OBSBasicSourceSelect::obsSourceCreated, + this); + signalHandlers.emplace_back(obs_get_signal_handler(), "source_destroy", &OBSBasicSourceSelect::obsSourceRemoved, + this); + + connect(ui->sourceTypeList, &QListWidget::itemDoubleClicked, this, &OBSBasicSourceSelect::createNew); + connect(ui->sourceTypeList, &QListWidget::currentItemChanged, this, &OBSBasicSourceSelect::sourceTypeSelected); + connect(ui->newSourceName, &QLineEdit::returnPressed, this, &OBSBasicSourceSelect::createNew); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + connect(ui->addExistingButton, &QAbstractButton::clicked, this, &OBSBasicSourceSelect::addSelectedSources); + connect(this, &OBSBasicSourceSelect::selectedItemsChanged, this, [=]() { + ui->addExistingButton->setEnabled(selectedItems.size() > 0); + if (selectedItems.size() > 0) { + ui->addExistingButton->setText(QTStr("Add %1 Existing").arg(selectedItems.size())); + } else { + ui->addExistingButton->setText("Add Existing"); + } + }); + + App()->DisableHotkeys(); +} + +OBSBasicSourceSelect::~OBSBasicSourceSelect() +{ + App()->UpdateHotkeyFocusSetting(); +} + +void OBSBasicSourceSelect::obsSourceCreated(void *data, calldata_t *) +{ + QMetaObject::invokeMethod(static_cast(data), "handleSourceCreated", + Qt::QueuedConnection); +} + +void OBSBasicSourceSelect::obsSourceRemoved(void *data, calldata_t *params) +{ + obs_source_t *source = static_cast(calldata_ptr(params, "source")); + const char *uuidPointer = obs_source_get_uuid(source); + + if (!uuidPointer) { + return; + } + + QMetaObject::invokeMethod(static_cast(data), "handleSourceRemoved", + Qt::QueuedConnection, Q_ARG(QString, QString::fromUtf8(uuidPointer))); +} + +void OBSBasicSourceSelect::sourcePaste(SourceCopyInfo &info, bool duplicate) +{ + OBSSource source = OBSGetStrongRef(info.weak_source); + if (!source) { + return; + } + + std::string uuid = obs_source_get_uuid(source); + + setupExistingSource(uuid, info.visible, duplicate, &info); +} + +void OBSBasicSourceSelect::showEvent(QShowEvent *) +{ + QTimer::singleShot(0, this, [this] { updateButtonVisibility(); }); +} + +void OBSBasicSourceSelect::updateButtonVisibility() +{ + QList buttons = sourceButtons->buttons(); + + if (buttons.size() <= 0) { + return; + } + + QAbstractButton *firstButton = buttons.first(); + + // Allow some room for previous/next rows to make scrolling a bit more seamless + QRect scrollAreaRect(QPoint(0, 0), ui->existingScrollArea->size()); + scrollAreaRect.setTop(scrollAreaRect.top() - firstButton->rect().height()); + scrollAreaRect.setBottom(scrollAreaRect.bottom() + firstButton->rect().height()); + + for (const auto &button : buttons) { + SourceSelectButton *sourceButton = qobject_cast(button); + if (sourceButton) { + QRect buttonRect = button->rect(); + buttonRect.moveTo(button->mapTo(ui->existingScrollArea, buttonRect.topLeft())); + + if (scrollAreaRect.intersects(buttonRect)) { + sourceButton->setThumbnailEnabled(true); + } else { + sourceButton->setThumbnailEnabled(false); + } + } + } +} + +void OBSBasicSourceSelect::refreshSources() +{ + weakSources.clear(); + + obs_enum_sources(enumSourcesCallback, this); + + struct obs_frontend_source_list list = {}; + obs_frontend_get_scenes(&list); + + for (size_t i = 0; i < list.sources.num; ++i) { + OBSSource source = list.sources.array[i]; + + OBSWeakSourceAutoRelease weakSource = obs_source_get_weak_source(source); + weakSources.emplace_back(weakSource); + } + obs_frontend_source_list_free(&list); + + emit sourcesUpdated(); +} + +void OBSBasicSourceSelect::updateExistingSources(int limit) +{ + QLayout *layout = ui->existingListFrame->flowLayout(); + + // Clear existing buttons when switching types + QLayoutItem *child = nullptr; + while ((child = layout->takeAt(0)) != nullptr) { + if (child->widget()) { + child->widget()->deleteLater(); + } + delete child; + } + + if (sourceButtons) { + sourceButtons->deleteLater(); + } + + sourceButtons = new QButtonGroup(this); + sourceButtons->setExclusive(false); + + std::vector matchingSources{}; + std::copy_if(weakSources.begin(), weakSources.end(), std::back_inserter(matchingSources), + [this](obs_weak_source_t *weak) { + OBSSource source = OBSGetStrongRef(weak); + + if (!source || obs_source_removed(source)) { + return false; + } + + const char *id = obs_source_get_unversioned_id(source); + QString sourceTypeId = QString(id); + + if (sourceTypeId.compare("group") == 0) { + return false; + } + + if (selectedTypeId.compare(kRecentTypeId) == 0) { + // Skip listing scenes in recent sources list + if (sourceTypeId.compare("scene") == 0) { + return false; + } + + return true; + } + + if (selectedTypeId.compare(sourceTypeId) == 0) { + return true; + } + + return false; + }); + + QWidget *prevTabWidget = ui->sourceTypeList; + + auto createSourceButton = [this, &prevTabWidget](obs_weak_source_t *weak) { + OBSSource source{OBSGetStrongRef(weak)}; + + if (!source) { + return; + } + + SourceSelectButton *newButton = new SourceSelectButton(weak, ui->existingListFrame); + std::string uuid = obs_source_get_uuid(source); + + existingFlowLayout->addWidget(newButton); + sourceButtons->addButton(newButton); + + bool isSelected = false; + if (selectedItems.size() > 0) { + if (std::find(selectedItems.begin(), selectedItems.end(), uuid) != selectedItems.end()) { + isSelected = true; + } + } + + newButton->setChecked(isSelected); + + if (!prevTabWidget) { + setTabOrder(ui->existingListFrame, newButton); + } else { + setTabOrder(prevTabWidget, newButton); + } + + prevTabWidget = newButton; + }; + + bool isReverseListOrder = selectedTypeId.compare(kRecentTypeId) == 0; + size_t iterationLimit = limit > 0 ? std::min(static_cast(limit), matchingSources.size()) + : matchingSources.size(); + if (isReverseListOrder) { + std::for_each(matchingSources.rbegin(), matchingSources.rbegin() + iterationLimit, createSourceButton); + } else { + std::for_each(matchingSources.begin(), matchingSources.begin() + iterationLimit, createSourceButton); + } + + setTabOrder(prevTabWidget, ui->addExistingContainer); + + connect(sourceButtons, &QButtonGroup::buttonToggled, this, &OBSBasicSourceSelect::sourceButtonToggled); + + ui->existingListFrame->adjustSize(); +} + +bool OBSBasicSourceSelect::enumSourcesCallback(void *data, obs_source_t *source) +{ + if (obs_source_is_hidden(source)) { + return true; + } + + OBSBasicSourceSelect *window = static_cast(data); + + OBSWeakSourceAutoRelease weakSource = obs_source_get_weak_source(source); + window->weakSources.emplace_back(weakSource); + + return true; +} + +void OBSBasicSourceSelect::rebuildSourceTypeList() +{ + ui->sourceTypeList->clear(); + + OBSBasic *main = qobject_cast(App()->GetMainWindow()); + + const char *unversioned_type; + const char *type; + + size_t idx = 0; + + while (obs_enum_input_types2(++idx, &type, &unversioned_type)) { + const char *name = obs_source_get_display_name(type); + uint32_t caps = obs_get_source_output_flags(type); + + if ((caps & OBS_SOURCE_CAP_DISABLED) != 0) { + continue; + } + + QListWidgetItem *newItem = new QListWidgetItem(ui->sourceTypeList); + newItem->setData(Qt::DisplayRole, name); + newItem->setData(kUnversionedIdRole, unversioned_type); + + if ((caps & OBS_SOURCE_DEPRECATED) != 0) { + newItem->setData(kDeprecatedRole, true); + } else { + newItem->setData(kDeprecatedRole, false); + + QIcon icon; + icon = main->GetSourceIcon(type); + newItem->setIcon(icon); + } + } + + QListWidgetItem *newItem = new QListWidgetItem(ui->sourceTypeList); + newItem->setData(Qt::DisplayRole, Str("Basic.Scene")); + newItem->setData(kUnversionedIdRole, "scene"); + + QIcon icon = main->GetSceneIcon(); + newItem->setIcon(icon); + + ui->sourceTypeList->sortItems(); + + // Shift Deprecated sources to the bottom + QList deprecatedItems; + for (int i = 0; i < ui->sourceTypeList->count(); ++i) { + QListWidgetItem *item = ui->sourceTypeList->item(i); + if (!item) { + break; + } + + bool isDeprecated = item->data(kDeprecatedRole).toBool(); + if (isDeprecated) { + ui->sourceTypeList->takeItem(i); + + QVariant unversionedIdData = item->data(kUnversionedIdRole); + + deprecatedItems.append(item); + } + } + + for (const auto &item : deprecatedItems) { + ui->sourceTypeList->addItem(item); + } + + QListWidgetItem *allSources = new QListWidgetItem(); + allSources->setData(Qt::DisplayRole, Str("Basic.SourceSelect.Recent")); + allSources->setData(kUnversionedIdRole, QVariant(kRecentTypeId.toString())); + ui->sourceTypeList->insertItem(0, allSources); + + ui->sourceTypeList->setCurrentItem(allSources); + ui->sourceTypeList->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); +} + +void OBSBasicSourceSelect::sourceButtonToggled(QAbstractButton *button, bool checked) +{ + SourceSelectButton *sourceButton = dynamic_cast(button); + + Qt::KeyboardModifiers modifiers = QGuiApplication::keyboardModifiers(); + bool ctrlDown = (modifiers & Qt::ControlModifier); + bool shiftDown = (modifiers & Qt::ShiftModifier); + + if (!sourceButton) { + clearSelectedItems(); + return; + } + + int toggledIndex = existingFlowLayout->indexOf(sourceButton); + + std::string_view toggledUuid = sourceButton->uuid(); + + if (shiftDown) { + if (!ctrlDown) { + clearSelectedItems(); + } + + QSignalBlocker block(sourceButtons); + + int originalSelectedIndex = lastSelectedIndex; + + int start = std::min(toggledIndex, lastSelectedIndex); + int end = std::max(toggledIndex, lastSelectedIndex); + + for (int i = start; i <= end; ++i) { + QLayoutItem *item = existingFlowLayout->itemAt(i); + if (!item) { + continue; + } + + QWidget *widget = item->widget(); + if (!widget) { + continue; + } + + SourceSelectButton *entry = dynamic_cast(widget); + if (entry) { + QSignalBlocker blocker(entry); + entry->setChecked(true); + + std::string_view uuid = entry->uuid(); + addSelectedItem(uuid.data()); + } + } + + lastSelectedIndex = originalSelectedIndex; + + } else if (ctrlDown) { + lastSelectedIndex = toggledIndex; + + if (checked) { + addSelectedItem(toggledUuid.data()); + } else { + removeSelectedItem(toggledUuid.data()); + } + } else { + lastSelectedIndex = toggledIndex; + + bool reselectItem = selectedItems.size() > 1; + clearSelectedItems(); + if (checked) { + addSelectedItem(toggledUuid.data()); + } else if (reselectItem) { + QSignalBlocker blocker(button); + button->setChecked(true); + addSelectedItem(toggledUuid.data()); + } + } +} + +void OBSBasicSourceSelect::sourceDropped(QString uuid) +{ + OBSSourceAutoRelease source = obs_get_source_by_uuid(uuid.toStdString().c_str()); + if (source) { + bool visible = ui->sourceVisible->isChecked(); + + addExisting(uuid.toStdString(), visible); + } +} + +void OBSBasicSourceSelect::addSelectedItem(const std::string &uuid) +{ + auto it = std::find(selectedItems.begin(), selectedItems.end(), uuid); + + if (it == selectedItems.end()) { + selectedItems.emplace_back(uuid); + emit selectedItemsChanged(); + } + + SourceSelectButton *button = findButtonForUuid(uuid); + if (!button) { + return; + } + + lastSelectedIndex = existingFlowLayout->indexOf(button); +} + +void OBSBasicSourceSelect::removeSelectedItem(const std::string &uuid) +{ + auto it = std::find(selectedItems.begin(), selectedItems.end(), uuid); + if (it != selectedItems.end()) { + selectedItems.erase(it); + emit selectedItemsChanged(); + } + + SourceSelectButton *button = findButtonForUuid(uuid); + if (!button) { + return; + } + + lastSelectedIndex = existingFlowLayout->indexOf(button); +} + +void OBSBasicSourceSelect::clearSelectedItems() +{ + if (selectedItems.size() == 0) { + return; + } + + sourceButtons->blockSignals(true); + for (const auto &uuid : selectedItems) { + auto sourceButton = findButtonForUuid(uuid); + if (sourceButton) { + QSignalBlocker block(sourceButton); + sourceButton->setChecked(false); + } + } + sourceButtons->blockSignals(false); + + selectedItems.clear(); + emit selectedItemsChanged(); +} + +SourceSelectButton *OBSBasicSourceSelect::findButtonForUuid(const std::string &uuid) +{ + for (int i = 0; i <= existingFlowLayout->count(); ++i) { + QLayoutItem *layoutItem = existingFlowLayout->itemAt(i); + if (!layoutItem || !layoutItem->widget()) { + continue; + } + + QWidget *widget = layoutItem->widget(); + if (!widget) { + continue; + } + + SourceSelectButton *entry = dynamic_cast(widget); + if (entry && entry->uuid() == uuid) { + return entry; + } + } + + return nullptr; +} + +void OBSBasicSourceSelect::createNew() +{ + bool visible = ui->sourceVisible->isChecked(); + + if (ui->newSourceName->text().isEmpty()) { + return; + } + + if (selectedTypeId.compare(kRecentTypeId) == 0) { + return; + } + + if (selectedTypeId.compare("scene") == 0) { + return; + } + + std::string sourceType = selectedTypeId.toStdString(); + const char *id = sourceType.c_str(); + std::string newName = ui->newSourceName->text().toStdString(); + + std::optional createResult = setupNewSource(this, id, newName.c_str()); + if (!createResult.has_value()) { + return; + } + + OBSSource newSource = createResult.value(); + if (strcmp(obs_source_get_id(newSource), "group") == 0) { + return; + } + + std::optional addResult = setupExistingSource(obs_source_get_uuid(newSource), visible, false); + if (!addResult.has_value()) { + return; + } + + OBSSceneItem item = addResult.value(); + + OBSBasic *main = reinterpret_cast(App()->GetMainWindow()); + std::string sceneUuid = obs_source_get_uuid(main->GetCurrentSceneSource()); + auto undo = [sceneUuid](const std::string &data) { + OBSBasic *main = OBSBasic::Get(); + + OBSSourceAutoRelease source = obs_get_source_by_uuid(data.c_str()); + obs_source_remove(source); + + OBSSourceAutoRelease sceneSource = obs_get_source_by_uuid(sceneUuid.c_str()); + main->SetCurrentScene(sceneSource.Get(), true); + }; + OBSDataAutoRelease wrapper = obs_data_create(); + obs_data_set_string(wrapper, "id", id); + obs_data_set_int(wrapper, "item_id", obs_sceneitem_get_id(item)); + obs_data_set_string(wrapper, "name", ui->newSourceName->text().toUtf8().constData()); + obs_data_set_bool(wrapper, "visible", visible); + + auto redo = [sceneUuid](const std::string &data) { + OBSBasic *main = OBSBasic::Get(); + + OBSSourceAutoRelease sceneSource = obs_get_source_by_uuid(sceneUuid.c_str()); + main->SetCurrentScene(sceneSource.Get(), true); + + OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str()); + + std::optional createResult = + setupNewSource(NULL, obs_data_get_string(dat, "id"), obs_data_get_string(dat, "name")); + if (!createResult.has_value()) { + return; + } + + OBSSource source = createResult.value(); + + std::optional addResult = + setupExistingSource(obs_source_get_uuid(source), obs_data_get_bool(dat, "visible"), false); + if (!addResult.has_value()) { + return; + } + + OBSSceneItem item = addResult.value(); + + obs_sceneitem_set_id(item, static_cast(obs_data_get_int(dat, "item_id"))); + }; + undo_s.add_action(QTStr("Undo.Add").arg(ui->newSourceName->text()), undo, redo, + std::string(obs_source_get_uuid(newSource)), std::string(obs_data_get_json(wrapper))); + + main->CreatePropertiesWindow(newSource); + + close(); +} + +void OBSBasicSourceSelect::addExisting(const std::string &uuid, bool visible) +{ + OBSSourceAutoRelease source = obs_get_source_by_uuid(uuid.c_str()); + if (!source) { + return; + } + + QString name = obs_source_get_name(source); + setupExistingSource(uuid, visible, false); + + OBSBasic *main = OBSBasic::Get(); + const char *sceneUuidPtr = obs_source_get_uuid(main->GetCurrentSceneSource()); + if (!sceneUuidPtr) { + return; + } + + std::string sceneUuid{sceneUuidPtr}; + + auto undo = [sceneUuid, main](const std::string &) { + OBSSourceAutoRelease sceneSource = obs_get_source_by_uuid(sceneUuid.c_str()); + main->SetCurrentScene(sceneSource.Get(), true); + + OBSScene scene = obs_scene_from_source(sceneSource); + OBSSceneItem item; + auto cb = [](obs_scene_t *, obs_sceneitem_t *sceneitem, void *data) { + OBSSceneItem &last = *static_cast(data); + last = sceneitem; + return true; + }; + obs_scene_enum_items(scene, cb, &item); + + obs_sceneitem_remove(item); + }; + + auto redo = [sceneUuid, main, uuid, visible](const std::string &) { + OBSSourceAutoRelease sceneSource = obs_get_source_by_uuid(sceneUuid.c_str()); + main->SetCurrentScene(sceneSource.Get(), true); + + setupExistingSource(uuid, visible, false); + }; + + undo_s.add_action(QTStr("Undo.Add").arg(name), undo, redo, "", ""); +} + +void OBSBasicSourceSelect::on_createNewSource_clicked(bool) +{ + createNew(); +} + +void OBSBasicSourceSelect::addSelectedSources() +{ + if (selectedItems.size() == 0) { + return; + } + + bool visible = ui->sourceVisible->isChecked(); + + for (const auto &uuid : selectedItems) { + addExisting(uuid, visible); + } + close(); +} + +void OBSBasicSourceSelect::handleSourceCreated() +{ + refreshSources(); + + if (selectedTypeId.compare(kRecentTypeId) == 0) { + updateExistingSources(kRecentListLimit); + } else { + updateExistingSources(); + } +} + +void OBSBasicSourceSelect::handleSourceRemoved(QString uuid) +{ + refreshSources(); + + removeSelectedItem(uuid.toStdString()); + + if (selectedTypeId.compare(kRecentTypeId) == 0) { + updateExistingSources(kRecentListLimit); + } else { + updateExistingSources(); + } +} + +void OBSBasicSourceSelect::sourceTypeSelected(QListWidgetItem *current, QListWidgetItem *) +{ + clearSelectedItems(); + + QVariant unversionedIdData = current->data(kUnversionedIdRole); + QVariant deprecatedData = current->data(kDeprecatedRole); + + if (unversionedIdData.toString().compare(kRecentTypeId) == 0) { + selectedTypeId = kRecentTypeId.toString(); + ui->createNewFrame->setVisible(false); + updateExistingSources(kRecentListLimit); + return; + } + + QString type = unversionedIdData.toString(); + if (type.compare(selectedTypeId) == 0) { + return; + } + + ui->createNewFrame->setVisible(true); + + bool isDeprecatedType = deprecatedData.toBool(); + ui->deprecatedCreateLabel->setVisible(isDeprecatedType); + + selectedTypeId = type; + + QString placeHolderText{getDisplayNameForSourceType(selectedTypeId)}; QString text{placeHolderText}; int i = 2; @@ -396,63 +936,15 @@ OBSBasicSourceSelect::OBSBasicSourceSelect(OBSBasic *parent, const char *id_, un text = QString("%1 %2").arg(placeHolderText).arg(i++); } - ui->sourceName->setText(text); - ui->sourceName->setFocus(); //Fixes deselect of text. - ui->sourceName->selectAll(); + ui->newSourceName->setText(text); - installEventFilter(CreateShortcutFilter()); + updateExistingSources(); - connect(ui->createNew, &QRadioButton::pressed, this, [&]() { - QPushButton *button = ui->buttonBox->button(QDialogButtonBox::Ok); - if (!button->isEnabled()) - button->setEnabled(true); - }); - connect(ui->selectExisting, &QRadioButton::pressed, this, [&]() { - QPushButton *button = ui->buttonBox->button(QDialogButtonBox::Ok); - bool enabled = ui->sourceList->selectedItems().size() != 0; - if (button->isEnabled() != enabled) - button->setEnabled(enabled); - }); - connect(ui->sourceList, &QListWidget::itemSelectionChanged, this, [&]() { - QPushButton *button = ui->buttonBox->button(QDialogButtonBox::Ok); - if (!button->isEnabled()) - button->setEnabled(true); - }); - - if (strcmp(id_, "scene") == 0) { - OBSBasic *main = OBSBasic::Get(); - OBSSource curSceneSource = main->GetCurrentSceneSource(); - - ui->selectExisting->setChecked(true); - ui->createNew->setChecked(false); - ui->createNew->setEnabled(false); - ui->sourceName->setEnabled(false); - ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); - - int count = main->ui->scenes->count(); - for (int i = 0; i < count; i++) { - QListWidgetItem *item = main->ui->scenes->item(i); - OBSScene scene = GetOBSRef(item); - OBSSource sceneSource = obs_scene_get_source(scene); - - if (curSceneSource == sceneSource) - continue; - - const char *name = obs_source_get_name(sceneSource); - ui->sourceList->addItem(QT_UTF8(name)); - } - } else if (strcmp(id_, "group") == 0) { - obs_enum_sources(EnumGroups, this); - } else { - obs_enum_sources(EnumSources, this); + if (existingFlowLayout->count() == 0) { + QLabel *noExisting = new QLabel(); + noExisting->setText( + QTStr("Basic.SourceSelect.NoExisting").arg(getDisplayNameForSourceType(selectedTypeId))); + noExisting->setProperty("class", "text-muted"); + existingFlowLayout->addWidget(noExisting); } } - -void OBSBasicSourceSelect::SourcePaste(SourceCopyInfo &info, bool dup) -{ - OBSSource source = OBSGetStrongRef(info.weak_source); - if (!source) - return; - - AddExisting(source, info.visible, dup, &info); -} diff --git a/frontend/dialogs/OBSBasicSourceSelect.hpp b/frontend/dialogs/OBSBasicSourceSelect.hpp index 077d8d298..585f882a1 100644 --- a/frontend/dialogs/OBSBasicSourceSelect.hpp +++ b/frontend/dialogs/OBSBasicSourceSelect.hpp @@ -1,5 +1,6 @@ /****************************************************************************** Copyright (C) 2023 by Lain Bailey + Copyright (C) 2025 by Taylor Giampaolo 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 @@ -17,40 +18,79 @@ #pragma once +#include #include "ui_OBSBasicSourceSelect.h" +#include +#include #include #include -#include - +#include #include class OBSBasicSourceSelect : public QDialog { Q_OBJECT -private: - std::unique_ptr ui; - const char *id; - undo_stack &undo_s; - - static bool EnumSources(void *data, obs_source_t *source); - static bool EnumGroups(void *data, obs_source_t *source); - - static void OBSSourceRemoved(void *data, calldata_t *calldata); - static void OBSSourceAdded(void *data, calldata_t *calldata); - -private slots: - void on_buttonBox_accepted(); - void on_buttonBox_rejected(); - - void SourceAdded(OBSSource source); - void SourceRemoved(OBSSource source); - public: - OBSBasicSourceSelect(OBSBasic *parent, const char *id, undo_stack &undo_s); + OBSBasicSourceSelect(OBSBasic *parent, undo_stack &undo_s); + ~OBSBasicSourceSelect(); OBSSource newSource; - static void SourcePaste(SourceCopyInfo &info, bool duplicate); + static void sourcePaste(SourceCopyInfo &info, bool duplicate); + +protected: + void showEvent(QShowEvent *event) override; + +private: + std::unique_ptr ui; + QString selectedTypeId; + undo_stack &undo_s; + + QPointer sourceButtons; + + std::vector signalHandlers; + static void obsSourceCreated(void *param, calldata_t *calldata); + static void obsSourceRemoved(void *param, calldata_t *calldata); + + std::vector weakSources; + + QPointer existingFlowLayout = nullptr; + + void refreshSources(); + void updateExistingSources(int limit = 0); + + static bool enumSourcesCallback(void *data, obs_source_t *source); + + void rebuildSourceTypeList(); + + int lastSelectedIndex = -1; + std::vector selectedItems; + void addSelectedItem(const std::string &uuid); + void removeSelectedItem(const std::string &uuid); + void clearSelectedItems(); + + SourceSelectButton *findButtonForUuid(const std::string &uuid); + + void createNew(); + void addExisting(const std::string &uuid, bool visible); + + void updateButtonVisibility(); + +signals: + void sourcesUpdated(); + void selectedItemsChanged(); + +public slots: + void on_createNewSource_clicked(bool checked); + void addSelectedSources(); + + void handleSourceCreated(); + void handleSourceRemoved(QString uuid); + + void sourceTypeSelected(QListWidgetItem *current, QListWidgetItem *previous); + + void sourceButtonToggled(QAbstractButton *button, bool checked); + void sourceDropped(QString uuid); }; diff --git a/frontend/forms/OBSBasicSourceSelect.ui b/frontend/forms/OBSBasicSourceSelect.ui index 07f64ab47..d063616e1 100644 --- a/frontend/forms/OBSBasicSourceSelect.ui +++ b/frontend/forms/OBSBasicSourceSelect.ui @@ -3,106 +3,674 @@ OBSBasicSourceSelect - Qt::WindowModal + Qt::NonModal 0 0 - 352 - 314 + 1010 + 614 + + + 0 + 0 + + + + + 16777215 + 1000 + + Basic.SourceSelect - + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + - - - - - Basic.SourceSelect.CreateNew - - - true - - - - - - - - - - Basic.SourceSelect.AddExisting - - - - - - - false - - - true - - - - - - - Basic.SourceSelect.AddVisible - - - true - - - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + 0 + 0 + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + dialog-container + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + 0 + 0 + + + + Basic.SourceSelect.SelectType + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + dialog-frame + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 460 + + + + Basic.SourceSelect.SelectType + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::ScrollPerPixel + + + QAbstractItemView::ScrollPerPixel + + + QListView::Adjust + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 17 + 4 + + + + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Basic.SourceSelect.AddExisting + + + QFrame::Plain + + + 0 + + + dialog-container dialog-frame + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Basic.SourceSelect.Existing + + + text-title + + + + + + + Qt::NoFocus + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + true + + + + + 0 + 0 + 800 + 510 + + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + + + + Qt::Vertical + + + + 20 + 10 + + + + + + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 10 + + + + + + + + Qt::Horizontal + + + + 259 + 10 + + + + + + + + false + + + + 0 + 0 + + + + Add Existing + + + button-primary + + + + + + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + dialog-container dialog-frame + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Basic.SourceSelect.NewSource + + + text-title + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + Basic.SourceSelect.Deprecated.Create + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Basic.SourceSelect.Accessible.SourceName + + + + + + + + 0 + 0 + + + + Basic.SourceSelect.CreateButton + + + button-primary margin-left + + + + + + + + + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + dialog-container + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Basic.SourceSelect.AddVisible + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + QDialogButtonBox::Cancel + + + false + + + + + + + + + + - - - - createNew - toggled(bool) - sourceName - setEnabled(bool) - - - 79 - 29 - - - 108 - 53 - - - - - selectExisting - toggled(bool) - sourceList - setEnabled(bool) - - - 51 - 80 - - - 65 - 128 - - - - + + + FlowFrame + QFrame +
components/FlowFrame.hpp
+ 1 +
+
+ + sourceTypeList + existingScrollArea + addExistingButton + newSourceName + createNewSource + sourceVisible + + + + + diff --git a/frontend/utility/ResizeSignaler.hpp b/frontend/utility/ResizeSignaler.hpp new file mode 100644 index 000000000..f3337f734 --- /dev/null +++ b/frontend/utility/ResizeSignaler.hpp @@ -0,0 +1,39 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + + 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 + +class ResizeSignaler : public QObject { + Q_OBJECT + +public: + inline ResizeSignaler(QObject *parent) : QObject(parent) {} + +signals: + void resized(); + +protected: + bool eventFilter(QObject *object, QEvent *event) override + { + if (event->type() == QEvent::Resize) { + emit resized(); + } + return QObject::eventFilter(object, event); + } +}; diff --git a/frontend/utility/ScreenshotObj.cpp b/frontend/utility/ScreenshotObj.cpp index 0fff65d6f..fa4383d56 100644 --- a/frontend/utility/ScreenshotObj.cpp +++ b/frontend/utility/ScreenshotObj.cpp @@ -1,5 +1,6 @@ /****************************************************************************** Copyright (C) 2023 by Lain Bailey + Copyright (C) 2025 by Taylor Giampaolo 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 @@ -16,6 +17,7 @@ ******************************************************************************/ #include "ScreenshotObj.hpp" +#include "display-helpers.hpp" #include @@ -30,11 +32,23 @@ #include "moc_ScreenshotObj.cpp" -static void ScreenshotTick(void *param, float); +namespace { +void renderTick(void *param, float) +{ + ScreenshotObj *self = static_cast(param); + if (self->stage() == ScreenshotObj::Stage::Finished) { + return; + } + + obs_enter_graphics(); + self->processStage(); + obs_leave_graphics(); +} +} // namespace ScreenshotObj::ScreenshotObj(obs_source_t *source) : weakSource(OBSGetWeakRef(source)) { - obs_add_tick_callback(ScreenshotTick, this); + obs_add_tick_callback(renderTick, this); } ScreenshotObj::~ScreenshotObj() @@ -44,40 +58,26 @@ ScreenshotObj::~ScreenshotObj() gs_texrender_destroy(texrender); obs_leave_graphics(); - obs_remove_tick_callback(ScreenshotTick, this); - - if (th.joinable()) { - th.join(); - - if (cx && cy) { - OBSBasic *main = OBSBasic::Get(); - main->ShowStatusBarMessage( - QTStr("Basic.StatusBar.ScreenshotSavedTo").arg(QT_UTF8(path.c_str()))); - - main->lastScreenshot = path; - - main->OnEvent(OBS_FRONTEND_EVENT_SCREENSHOT_TAKEN); - } - } + obs_remove_tick_callback(renderTick, this); } -void ScreenshotObj::Screenshot() +void ScreenshotObj::renderScreenshot() { - OBSSource source = OBSGetStrongRef(weakSource); + OBSSourceAutoRelease source = OBSGetStrongRef(weakSource); if (source) { - cx = obs_source_get_width(source); - cy = obs_source_get_height(source); + sourceWidth = obs_source_get_width(source); + sourceHeight = obs_source_get_height(source); } else { obs_video_info ovi; obs_get_video_info(&ovi); - cx = ovi.base_width; - cy = ovi.base_height; + sourceWidth = ovi.base_width; + sourceHeight = ovi.base_height; } - if (!cx || !cy) { - blog(LOG_WARNING, "Cannot screenshot, invalid target size"); - obs_remove_tick_callback(ScreenshotTick, this); + if (!sourceWidth || !sourceHeight) { + blog(LOG_WARNING, "Cannot render source, invalid target size"); + obs_remove_tick_callback(renderTick, this); deleteLater(); return; } @@ -94,15 +94,34 @@ void ScreenshotObj::Screenshot() #endif const enum gs_color_format format = gs_get_format_from_space(space); - texrender = gs_texrender_create(format, GS_ZS_NONE); - stagesurf = gs_stagesurface_create(cx, cy, format); + outputWidth = customSize.isValid() ? customSize.width() : sourceWidth; + outputHeight = customSize.isValid() ? customSize.height() : sourceHeight; - if (gs_texrender_begin_with_color_space(texrender, cx, cy, space)) { + texrender = gs_texrender_create(format, GS_ZS_NONE); + stagesurf = gs_stagesurface_create(outputWidth, outputHeight, format); + + if (gs_texrender_begin_with_color_space(texrender, outputWidth, outputHeight, space)) { vec4 zero; vec4_zero(&zero); + int x{0}; + int y{0}; + int scaledWidth{0}; + int scaledHeight{0}; + float scale{0.0}; + + GetScaleAndCenterPos(sourceWidth, sourceHeight, outputWidth, outputHeight, x, y, scale); + + scaledWidth = int(scale * float(sourceWidth)); + scaledHeight = int(scale * float(sourceHeight)); + gs_clear(GS_CLEAR_COLOR, &zero, 0.0f, 0); - gs_ortho(0.0f, (float)cx, 0.0f, (float)cy, -100.0f, 100.0f); + + gs_viewport_push(); + gs_projection_push(); + + gs_ortho(0.0f, (float)sourceWidth, 0.0f, (float)sourceHeight, -100.0f, 100.0f); + gs_set_viewport(x, y, scaledWidth, scaledHeight); gs_blend_state_push(); gs_blend_function(GS_BLEND_ONE, GS_BLEND_ZERO); @@ -115,44 +134,75 @@ void ScreenshotObj::Screenshot() obs_render_main_texture(); } + gs_projection_pop(); + gs_viewport_pop(); + gs_blend_state_pop(); gs_texrender_end(texrender); } } -void ScreenshotObj::Download() +void ScreenshotObj::processStage() +{ + switch (stage_) { + case Stage::Render: + renderScreenshot(); + stage_ = Stage::Download; + break; + case Stage::Download: + downloadData(); + stage_ = Stage::Output; + break; + case Stage::Output: + copyData(); + QMetaObject::invokeMethod(this, &ScreenshotObj::handleSave, Qt::QueuedConnection); + obs_remove_tick_callback(renderTick, this); + stage_ = Stage::Finished; + break; + case Stage::Finished: + break; + } +} + +void ScreenshotObj::downloadData() { gs_stage_texture(stagesurf, gs_texrender_get_texture(texrender)); } -void ScreenshotObj::Copy() +void ScreenshotObj::copyData() { uint8_t *videoData = nullptr; uint32_t videoLinesize = 0; if (gs_stagesurface_map(stagesurf, &videoData, &videoLinesize)) { if (gs_stagesurface_get_color_format(stagesurf) == GS_RGBA16F) { - const uint32_t linesize = cx * 8; - half_bytes.reserve(cx * cy * 8); + const uint32_t linesize = outputWidth * 8; + half_bytes.reserve(outputWidth * outputHeight * 8); - for (uint32_t y = 0; y < cy; y++) { + for (uint32_t y = 0; y < outputHeight; ++y) { const uint8_t *const line = videoData + (y * videoLinesize); half_bytes.insert(half_bytes.end(), line, line + linesize); } } else { - image = QImage(cx, cy, QImage::Format::Format_RGBX8888); + image = QImage(outputWidth, outputHeight, QImage::Format::Format_RGBX8888); int linesize = image.bytesPerLine(); - for (int y = 0; y < (int)cy; y++) + for (int y = 0; y < (int)outputHeight; ++y) { memcpy(image.scanLine(y), videoData + (y * videoLinesize), linesize); + } } gs_stagesurface_unmap(stagesurf); } } -void ScreenshotObj::Save() +void ScreenshotObj::saveToFile() { + if (!outputToFile) { + QMetaObject::invokeMethod(this, &ScreenshotObj::onFinished, Qt::QueuedConnection); + return; + } + OBSBasic *main = OBSBasic::Get(); config_t *config = main->Config(); @@ -171,7 +221,10 @@ void ScreenshotObj::Save() path = GetOutputFilename(rec_path, ext, noSpace, overwriteIfExists, GetFormatString(filenameFormat, "Screenshot", nullptr).c_str()); - th = std::thread([this] { MuxAndFinish(); }); + thread = std::thread([this] { + muxFile(); + QMetaObject::invokeMethod(this, &ScreenshotObj::onFinished, Qt::QueuedConnection); + }); } #ifdef _WIN32 @@ -185,36 +238,44 @@ static HRESULT SaveJxrImage(LPCWSTR path, uint8_t *pixels, uint32_t cx, uint32_t value.vt = VT_BOOL; value.bVal = TRUE; HRESULT hr = options->Write(1, &bag, &value); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = frameEncode->Initialize(options); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = frameEncode->SetSize(cx, cy); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = frameEncode->SetResolution(72, 72); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat64bppRGBAHalf; hr = frameEncode->SetPixelFormat(&pixelFormat); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } - if (memcmp(&pixelFormat, &GUID_WICPixelFormat64bppRGBAHalf, sizeof(WICPixelFormatGUID)) != 0) + if (memcmp(&pixelFormat, &GUID_WICPixelFormat64bppRGBAHalf, sizeof(WICPixelFormatGUID)) != 0) { return E_FAIL; + } hr = frameEncode->WritePixels(cy, cx * 8, cx * cy * 8, pixels); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = frameEncode->Commit(); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } return S_OK; } @@ -224,43 +285,50 @@ static HRESULT SaveJxr(LPCWSTR path, uint8_t *pixels, uint32_t cx, uint32_t cy) Microsoft::WRL::ComPtr factory; HRESULT hr = CoCreateInstance(CLSID_WICImagingFactory, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.GetAddressOf())); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } Microsoft::WRL::ComPtr stream; hr = factory->CreateStream(stream.GetAddressOf()); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = stream->InitializeFromFilename(path, GENERIC_WRITE); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } Microsoft::WRL::ComPtr encoder = NULL; hr = factory->CreateEncoder(GUID_ContainerFormatWmp, NULL, encoder.GetAddressOf()); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } Microsoft::WRL::ComPtr frameEncode; Microsoft::WRL::ComPtr options; hr = encoder->CreateNewFrame(frameEncode.GetAddressOf(), options.GetAddressOf()); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } hr = SaveJxrImage(path, pixels, cx, cy, frameEncode.Get(), options.Get()); - if (FAILED(hr)) + if (FAILED(hr)) { return hr; + } encoder->Commit(); return S_OK; } #endif // #ifdef _WIN32 -void ScreenshotObj::MuxAndFinish() +void ScreenshotObj::muxFile() { if (half_bytes.empty()) { image.save(QT_UTF8(path.c_str())); @@ -270,45 +338,50 @@ void ScreenshotObj::MuxAndFinish() wchar_t *path_w = nullptr; os_utf8_to_wcs_ptr(path.c_str(), 0, &path_w); if (path_w) { - SaveJxr(path_w, half_bytes.data(), cx, cy); + SaveJxr(path_w, half_bytes.data(), outputWidth, outputHeight); bfree(path_w); } -#endif // #ifdef _WIN32 +#endif } - - deleteLater(); } -#define STAGE_SCREENSHOT 0 -#define STAGE_DOWNLOAD 1 -#define STAGE_COPY_AND_SAVE 2 -#define STAGE_FINISH 3 - -static void ScreenshotTick(void *param, float) +void ScreenshotObj::onFinished() { - ScreenshotObj *data = static_cast(param); - - if (data->stage == STAGE_FINISH) { - return; + if (thread.joinable()) { + thread.join(); } - obs_enter_graphics(); + if (outputWidth > 0 && outputHeight > 0) { + if (outputToFile) { + OBSBasic *main = OBSBasic::Get(); + main->ShowStatusBarMessage( + QTStr("Basic.StatusBar.ScreenshotSavedTo").arg(QT_UTF8(path.c_str()))); + main->lastScreenshot = path; + main->OnEvent(OBS_FRONTEND_EVENT_SCREENSHOT_TAKEN); + } - switch (data->stage) { - case STAGE_SCREENSHOT: - data->Screenshot(); - break; - case STAGE_DOWNLOAD: - data->Download(); - break; - case STAGE_COPY_AND_SAVE: - data->Copy(); - QMetaObject::invokeMethod(data, "Save"); - obs_remove_tick_callback(ScreenshotTick, data); - break; + emit imageReady(image.copy()); } - obs_leave_graphics(); - - data->stage++; + this->deleteLater(); +} + +void ScreenshotObj::setSize(QSize size) +{ + customSize = size; +} + +void ScreenshotObj::setSize(int width, int height) +{ + setSize(QSize(width, height)); +} + +void ScreenshotObj::setSaveToFile(bool save) +{ + outputToFile = save; +} + +void ScreenshotObj::handleSave() +{ + saveToFile(); } diff --git a/frontend/utility/ScreenshotObj.hpp b/frontend/utility/ScreenshotObj.hpp index 01a4fefd8..8a5568282 100644 --- a/frontend/utility/ScreenshotObj.hpp +++ b/frontend/utility/ScreenshotObj.hpp @@ -1,5 +1,6 @@ /****************************************************************************** Copyright (C) 2023 by Lain Bailey + Copyright (C) 2025 by Taylor Giampaolo 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 @@ -30,10 +31,26 @@ class ScreenshotObj : public QObject { public: ScreenshotObj(obs_source_t *source); ~ScreenshotObj() override; - void Screenshot(); - void Download(); - void Copy(); - void MuxAndFinish(); + + enum class Stage { Render, Download, Output, Finished }; + + void processStage(); + void renderScreenshot(); + void downloadData(); + void copyData(); + void saveToFile(); + void muxFile(); + void onFinished(); + + Stage stage() { return stage_; } + void setStage(Stage stage) { stage_ = stage; } + + void setSize(QSize size); + void setSize(int width, int height); + void setSaveToFile(bool save); + +private: + Stage stage_ = Stage::Render; gs_texrender_t *texrender = nullptr; gs_stagesurf_t *stagesurf = nullptr; @@ -41,12 +58,19 @@ public: std::string path; QImage image; std::vector half_bytes; - uint32_t cx; - uint32_t cy; - std::thread th; + QSize customSize; + uint32_t sourceWidth = 0; + uint32_t sourceHeight = 0; + uint32_t outputWidth = 0; + uint32_t outputHeight = 0; - int stage = 0; + std::thread thread; + std::shared_ptr imagePtr; + bool outputToFile = true; -public slots: - void Save(); +signals: + void imageReady(QImage image); + +private slots: + void handleSave(); }; diff --git a/frontend/utility/ThumbnailItem.cpp b/frontend/utility/ThumbnailItem.cpp new file mode 100644 index 000000000..8f3cd9fe6 --- /dev/null +++ b/frontend/utility/ThumbnailItem.cpp @@ -0,0 +1,196 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 . +******************************************************************************/ + +#include "ThumbnailItem.hpp" + +#include +#include +#include + +#include +#include +#include + +static constexpr int kDefaultWidth = 320; +static constexpr int kDefaultHeight = 180; + +namespace { +QPixmap getDefaultThumbnail(obs_source_t *source) +{ + const char *id = obs_source_get_id(source); + OBSBasic *main = OBSBasic::Get(); + if (main && id) { + QIcon icon = OBSBasic::Get()->GetSourceIcon(id); + QPixmap iconPixmap = icon.pixmap(90, 90); + + QPixmap defaultPixmap(kDefaultWidth, kDefaultHeight); + defaultPixmap.fill(Qt::transparent); + + QPainter painter(&defaultPixmap); + + const int x = (defaultPixmap.width() - iconPixmap.width()) / 2; + const int y = (defaultPixmap.height() - iconPixmap.height()) / 2; + + painter.drawPixmap(x, y, iconPixmap); + + return defaultPixmap; + } + + return QPixmap(); +} +} // namespace + +ThumbnailItem::ThumbnailItem(const std::string &uuid, ThumbnailManager *manager) : QObject(manager), uuid(uuid) +{ + OBSSourceAutoRelease source = obs_get_source_by_uuid(uuid.c_str()); + if (!source) { + return; + } + + weakSource = OBSGetWeakRef(source); + + std::optional cachedPixmap = manager->getCachedPixmap(uuid); + if (cachedPixmap.has_value()) { + setPixmap(cachedPixmap.value()); + isDefaultPixmap_ = false; + } else { + setPixmap(getDefaultThumbnail(source)); + } + + if ((obs_source_get_output_flags(source) & OBS_SOURCE_VIDEO) != 0) { + isVideoSource = true; + } +} + +void ThumbnailItem::updatePixmapFromImage(const QImage &image) +{ + if (!image.isNull()) { + setPixmap(QPixmap::fromImage(image)); + isDefaultPixmap_ = false; + } +} + +bool ThumbnailItem::shouldUpdate() const +{ + return isValid() && (viewCount > 0) && (enabledCount > 0); +} + +QPixmap ThumbnailItem::getPixmap() const +{ + return pixmap; +} + +void ThumbnailItem::setPixmap(QPixmap pixmap) +{ + this->pixmap = std::move(pixmap); + + emit pixmapUpdated(this->pixmap); +} + +void ThumbnailItem::incrementViewCount() +{ + ++viewCount; +} + +ThumbnailView *ThumbnailItem::createView(QObject *parent) +{ + auto *view = new ThumbnailView(parent, this); + + incrementViewCount(); + if (view->isEnabled()) { + incrementEnabledCount(); + } + + // Connect ThumbnailView signals + connect(view, &QObject::destroyed, this, [this, view]() { + decrementViewCount(); + if (view->isEnabled()) { + decrementEnabledCount(); + } + }); + connect(view, &ThumbnailView::enabledChanged, this, [this](bool enabled) { + if (enabled) { + incrementEnabledCount(); + } else { + decrementEnabledCount(); + } + }); + + return view; +} + +void ThumbnailItem::decrementViewCount() +{ + if (viewCount > 0) { + --viewCount; + } + + if (viewCount <= 0) { + emit noViewsRemaining(); + } +} + +void ThumbnailItem::incrementEnabledCount() +{ + ++enabledCount; +} + +void ThumbnailItem::decrementEnabledCount() +{ + if (enabledCount > 0) { + --enabledCount; + } +} + +bool ThumbnailItem::update() +{ + if (!isVideoSource) { + return false; + } + + if (!shouldUpdate()) { + return false; + } + + OBSSource source = OBSGetStrongRef(weakSource); + if (!source) { + return false; + } + + QPixmap pixmap; + if (this->pixmap.isNull()) { + this->pixmap = pixmap; + } + + if (source) { + uint32_t sourceWidth = obs_source_get_width(source); + uint32_t sourceHeight = obs_source_get_height(source); + + if (sourceWidth == 0 || sourceHeight == 0) { + return false; + } + + auto *obj = new ScreenshotObj(source); + obj->setSaveToFile(false); + obj->setSize(kDefaultWidth, kDefaultHeight); + + connect(obj, &ScreenshotObj::imageReady, this, &ThumbnailItem::updatePixmapFromImage); + } + + return true; +} diff --git a/frontend/utility/ThumbnailItem.hpp b/frontend/utility/ThumbnailItem.hpp new file mode 100644 index 000000000..2f4ee50e5 --- /dev/null +++ b/frontend/utility/ThumbnailItem.hpp @@ -0,0 +1,70 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 + +class ThumbnailManager; +class ThumbnailView; + +class ThumbnailItem : public QObject { + Q_OBJECT + + std::string uuid; + OBSWeakSource weakSource; + QPixmap pixmap; + bool isVideoSource = false; + + int viewCount{0}; + int enabledCount{0}; + + bool isDefaultPixmap_ = true; + void updatePixmapFromImage(const QImage &image); + [[nodiscard]] bool shouldUpdate() const; + +public: + ThumbnailItem(const std::string &uuid, ThumbnailManager *manager); + ~ThumbnailItem() = default; + + bool update(); + [[nodiscard]] QPixmap getPixmap() const; + void setPixmap(QPixmap pixmap); + + [[nodiscard]] bool isDefaultPixmap() const { return isDefaultPixmap_; } + [[nodiscard]] bool isValid() const + { + return isVideoSource && weakSource && !obs_weak_source_expired(weakSource); + } + [[nodiscard]] const std::string &getUuid() const { return uuid; } + + ThumbnailView *createView(QObject *parent); + +public slots: + void incrementViewCount(); + void decrementViewCount(); + void incrementEnabledCount(); + void decrementEnabledCount(); + +signals: + void pixmapUpdated(QPixmap pixmap); + void noViewsRemaining(); +}; diff --git a/frontend/utility/ThumbnailManager.cpp b/frontend/utility/ThumbnailManager.cpp new file mode 100644 index 000000000..a76369947 --- /dev/null +++ b/frontend/utility/ThumbnailManager.cpp @@ -0,0 +1,268 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 . +******************************************************************************/ + +#include "ThumbnailManager.hpp" + +#include +#include + +#include "display-helpers.hpp" + +#include + +constexpr int kMinimumThumbnailUpdateInterval = 100; +constexpr int kThumbnailUpdateInterval = 5000; + +namespace { +bool updateItem(ThumbnailItem *item) +{ + if (!item) { + return false; + } + + return item->update(); +} +} // namespace + +void ThumbnailManager::ThumbnailCache::put(const std::string &key, const QPixmap &value) +{ + auto it = cacheMap.find(key); + if (it != cacheMap.end()) { + cacheList.erase(it->second); + cacheMap.erase(it); + } + cacheList.emplace_front(key, value); + cacheMap[key] = cacheList.begin(); + if (cacheMap.size() > maxSize) { + const CacheEntry &lastEntry = cacheList.back(); + cacheMap.erase(lastEntry.first); + cacheList.pop_back(); + } +} + +std::optional ThumbnailManager::ThumbnailCache::get(const std::string &key) +{ + auto it = cacheMap.find(key); + if (it == cacheMap.end()) { + return std::nullopt; + } + + auto entry = it->second; + return entry->second; +} + +ThumbnailManager::ThumbnailManager(QObject *parent) : QObject(parent) +{ + elapsedTimer.start(); + connect(&updateTimer, &QTimer::timeout, this, &ThumbnailManager::updateTick); + updateTickInterval(kMinimumThumbnailUpdateInterval); + + signalHandlers.emplace_back(obs_get_signal_handler(), "source_destroy", &ThumbnailManager::obsSourceRemoved, + this); +} + +ThumbnailView *ThumbnailManager::createView(QWidget *parent, obs_source_t *source) +{ + if (!source) { + return new ThumbnailView(parent, nullptr); + } + + const char *uuidPointer = obs_source_get_uuid(source); + if (!uuidPointer) { + return new ThumbnailView(parent, nullptr); + } + + std::string uuid{uuidPointer}; + + ThumbnailItem *item = getThumbnailItem(uuid); + ThumbnailView *view = item->createView(parent); + + connect(view, &ThumbnailView::updateRequested, this, [this](const std::string &uuid) { + bool updateImmediately = true; + addToPriorityQueue(uuid, updateImmediately); + }); + + return view; +} + +std::optional ThumbnailManager::getCachedPixmap(const std::string &uuid) +{ + return thumbnailCache.get(uuid); +} + +void ThumbnailManager::createThumbnailItem(const std::string &uuid) +{ + QPointer item = new ThumbnailItem(uuid, this); + thumbnailList[uuid] = item; + + if (item->isDefaultPixmap()) { + updateQueue.emplace_front(uuid); + updateTickInterval(kMinimumThumbnailUpdateInterval); + } else { + updateQueue.emplace_back(uuid); + } + + connect(item, &ThumbnailItem::noViewsRemaining, this, [this, uuid]() { deleteItemById(uuid); }); +} + +ThumbnailItem *ThumbnailManager::getThumbnailItem(const std::string &uuid) +{ + if (thumbnailList.find(uuid) == thumbnailList.end()) { + createThumbnailItem(uuid); + } + + return thumbnailList[uuid]; +} + +void ThumbnailManager::obsSourceRemoved(void *data, calldata_t *params) +{ + auto *source = static_cast(calldata_ptr(params, "source")); + const char *uuidPointer = obs_source_get_uuid(source); + + if (!uuidPointer) { + return; + } + + auto *manager = static_cast(data); + QMetaObject::invokeMethod(manager, "deleteItemById", Qt::QueuedConnection, Q_ARG(std::string, uuidPointer)); +} + +void ThumbnailManager::updateTickInterval(int newInterval) +{ + if (updateTimer.interval() != newInterval) { + elapsedTimer.restart(); + updateTimer.start(newInterval); + } +} + +void ThumbnailManager::updateNextItem(size_t cycleDepth) +{ + if (thumbnailList.empty()) { + return; + } + + QPointer item; + bool quickUpdate = false; + + if (!priorityQueue.empty()) { + std::string uuid = priorityQueue.front(); + priorityQueue.pop_front(); + + item = thumbnailList[uuid]; + updateQueue.emplace_back(std::move(uuid)); + + if (!updateItem(item) && cycleDepth < thumbnailList.size()) { + updateNextItem(cycleDepth + 1); + return; + } + } else if (!updateQueue.empty()) { + std::string uuid = updateQueue.front(); + + updateQueue.pop_front(); + item = thumbnailList[uuid]; + updateQueue.emplace_back(std::move(uuid)); + + if (item->isDefaultPixmap()) { + quickUpdate = true; + } + + if (!updateItem(item) && cycleDepth < thumbnailList.size()) { + updateNextItem(cycleDepth + 1); + return; + } + } + + int nextIntervalMS = kMinimumThumbnailUpdateInterval; + if (!priorityQueue.empty() && !quickUpdate) { + nextIntervalMS = kThumbnailUpdateInterval; + } + + updateTickInterval(nextIntervalMS); +} + +void ThumbnailManager::updateTick() +{ + updateNextItem(); +} + +void ThumbnailManager::removeIdFromQueues(const std::string &uuid) +{ + auto it = std::find(updateQueue.begin(), updateQueue.end(), uuid); + if (it != updateQueue.end()) { + updateQueue.erase(it); + } + + it = std::find(priorityQueue.begin(), priorityQueue.end(), uuid); + if (it != priorityQueue.end()) { + priorityQueue.erase(it); + } +} + +void ThumbnailManager::addToPriorityQueue(const std::string &uuid, bool immediate) +{ + // Skip if uuid is already at the front of the priority queue + if (!priorityQueue.empty() && priorityQueue[0] == uuid) { + return; + } + + removeIdFromQueues(uuid); + + if (immediate) { + priorityQueue.emplace_front(uuid); + + qint64 elapsed = elapsedTimer.elapsed(); + if (elapsed > kMinimumThumbnailUpdateInterval) { + updateTick(); + } + } else { + priorityQueue.emplace_back(uuid); + } +} + +void ThumbnailManager::addItemToCache(std::string &uuid, QPixmap &pixmap) +{ + if (pixmap.isNull()) { + return; + } + + thumbnailCache.put(uuid, pixmap); +} + +void ThumbnailManager::deleteItem(ThumbnailItem *item) +{ + deleteItemById(item->getUuid()); + item->deleteLater(); +} + +void ThumbnailManager::deleteItemById(const std::string &uuid) +{ + auto entry = thumbnailList.find(uuid); + if (entry != thumbnailList.end()) { + removeIdFromQueues(uuid); + + QPointer item = entry->second; + + if (item && !item->isDefaultPixmap()) { + std::string uuid = item->getUuid(); + QPixmap pixmap = item->getPixmap(); + addItemToCache(uuid, pixmap); + } + + thumbnailList.erase(uuid); + } +} diff --git a/frontend/utility/ThumbnailManager.hpp b/frontend/utility/ThumbnailManager.hpp new file mode 100644 index 000000000..939b6eaa7 --- /dev/null +++ b/frontend/utility/ThumbnailManager.hpp @@ -0,0 +1,91 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 +#include +#include + +#include +#include +#include + +class ThumbnailItem; +class ThumbnailView; + +class ThumbnailManager : public QObject { + Q_OBJECT + + std::vector signalHandlers; + static void obsSourceRemoved(void *data, calldata_t *params); + + struct ThumbnailCache { + size_t maxSize; + + using CacheEntry = std::pair; + + std::list cacheList; + std::unordered_map::iterator> cacheMap; + + explicit ThumbnailCache(size_t maxSize) : maxSize(maxSize) {} + + void put(const std::string &key, const QPixmap &value); + std::optional get(const std::string &key); + }; + + std::deque priorityQueue; + std::deque updateQueue; + std::unordered_map thumbnailList; + static constexpr int kMaxThumbnails = 64; + ThumbnailCache thumbnailCache{kMaxThumbnails}; + + QTimer updateTimer; + QElapsedTimer elapsedTimer; + +public: + explicit ThumbnailManager(QObject *parent = nullptr); + ~ThumbnailManager() = default; + + ThumbnailView *createView(QWidget *parent, obs_source_t *source); + std::optional getCachedPixmap(const std::string &uuid); + +private: + Q_DISABLE_COPY_MOVE(ThumbnailManager); + + void updateNextItem(size_t cycleDepth = 0); + + void removeIdFromQueues(const std::string &uuid); + void updateTickInterval(int newInterval); + + void createThumbnailItem(const std::string &uuid); + ThumbnailItem *getThumbnailItem(const std::string &uuid); + +private slots: + void updateTick(); + void addToPriorityQueue(const std::string &uuid, bool immediate = false); + void addItemToCache(std::string &uuid, QPixmap &pixmap); + void deleteItem(ThumbnailItem *item); + void deleteItemById(const std::string &uuid); +}; diff --git a/frontend/utility/ThumbnailView.cpp b/frontend/utility/ThumbnailView.cpp new file mode 100644 index 000000000..17a547d58 --- /dev/null +++ b/frontend/utility/ThumbnailView.cpp @@ -0,0 +1,65 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 . +******************************************************************************/ + +#include "ThumbnailView.hpp" + +ThumbnailView::ThumbnailView(QObject *parent, const QPointer &item) : QObject(parent) +{ + if (!parent) { + deleteLater(); + return; + } + + if (!item) { + return; + } + + uuid = item->getUuid(); + setPixmap(item->getPixmap()); + + connect(item, &ThumbnailItem::pixmapUpdated, this, &ThumbnailView::setPixmap); + + if (!item->isValid()) { + setEnabled(false); + } +} + +void ThumbnailView::setEnabled(bool enabled) +{ + if (this->enabled != enabled) { + this->enabled = enabled; + + emit enabledChanged(enabled); + } +} + +void ThumbnailView::requestUpdate() +{ + if (!this->enabled) { + return; + } + + emit updateRequested(uuid); +} + +void ThumbnailView::setPixmap(QPixmap pixmap) +{ + this->pixmap = std::move(pixmap); + + emit updated(this->pixmap); +} diff --git a/frontend/utility/ThumbnailView.hpp b/frontend/utility/ThumbnailView.hpp new file mode 100644 index 000000000..093b364eb --- /dev/null +++ b/frontend/utility/ThumbnailView.hpp @@ -0,0 +1,54 @@ +/****************************************************************************** + Copyright (C) 2025 by Taylor Giampaolo + 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 ThumbnailView : public QObject { + Q_OBJECT + + QPixmap pixmap{}; + bool enabled = true; + +public: + ThumbnailView(QObject *parent, const QPointer &item); + ~ThumbnailView() = default; + + std::string uuid; + [[nodiscard]] QPixmap getPixmap() const { return pixmap; } + + [[nodiscard]] bool isEnabled() const { return enabled; } + void setEnabled(bool enabled); + + void requestUpdate(); + +signals: + void updated(QPixmap pixmap); + void enabledChanged(bool enabled); + void updateRequested(std::string &uuid); + +public slots: + void setPixmap(QPixmap pixmap); +}; diff --git a/frontend/widgets/OBSBasic.hpp b/frontend/widgets/OBSBasic.hpp index 33b7e5a9f..9f4a74bb4 100644 --- a/frontend/widgets/OBSBasic.hpp +++ b/frontend/widgets/OBSBasic.hpp @@ -52,6 +52,7 @@ class OBSBasicAdvAudio; class OBSBasicFilters; class OBSBasicInteraction; class OBSBasicProperties; +class OBSBasicSourceSelect; class OBSBasicTransform; class OBSLogViewer; class OBSMissingFiles; @@ -471,6 +472,9 @@ private: void dragMoveEvent(QDragMoveEvent *event) override; void dropEvent(QDropEvent *event) override; +signals: + void sourceUuidDropped(QString uuid); + /* ------------------------------------- * MARK: - OBSBasic_Hotkeys * ------------------------------------- @@ -566,6 +570,7 @@ private: QPointer advAudioWindow; QPointer filters; QPointer about; + QPointer addWindow; QPointer logView; QPointer stats; QPointer remux; @@ -1168,11 +1173,8 @@ private: static void SourceRemoved(void *data, calldata_t *params); static void SourceRenamed(void *data, calldata_t *params); - void AddSource(const char *id); - QMenu *CreateAddSourcePopupMenu(); - void AddSourcePopupMenu(const QPoint &pos); - private slots: + void AddSourceDialog(); void RenameSources(OBSSource source, QString newName, QString prevName); void ReorderSources(OBSScene scene); diff --git a/frontend/widgets/OBSBasic_Clipboard.cpp b/frontend/widgets/OBSBasic_Clipboard.cpp index 4ed383811..8f8207f82 100644 --- a/frontend/widgets/OBSBasic_Clipboard.cpp +++ b/frontend/widgets/OBSBasic_Clipboard.cpp @@ -134,7 +134,7 @@ void OBSBasic::on_actionPasteRef_triggered() continue; } - OBSBasicSourceSelect::SourcePaste(copyInfo, false); + OBSBasicSourceSelect::sourcePaste(copyInfo, false); RefreshSources(scene); } @@ -156,7 +156,7 @@ void OBSBasic::on_actionPasteDup_triggered() for (size_t i = clipboard.size(); i > 0; i--) { SourceCopyInfo ©Info = clipboard[i - 1]; - OBSBasicSourceSelect::SourcePaste(copyInfo, true); + OBSBasicSourceSelect::sourcePaste(copyInfo, true); RefreshSources(GetCurrentScene()); } diff --git a/frontend/widgets/OBSBasic_Dropfiles.cpp b/frontend/widgets/OBSBasic_Dropfiles.cpp index 53c2f3e4f..4e28ecfe4 100644 --- a/frontend/widgets/OBSBasic_Dropfiles.cpp +++ b/frontend/widgets/OBSBasic_Dropfiles.cpp @@ -214,6 +214,10 @@ void OBSBasic::AddDropSource(const char *data, DropType image) void OBSBasic::dragEnterEvent(QDragEnterEvent *event) { + if (event->mimeData()->hasFormat("application/x-obs-source-uuid")) { + event->acceptProposedAction(); + } + // refuse drops of our own widgets if (event->source() != nullptr) { event->setDropAction(Qt::IgnoreAction); @@ -324,5 +328,10 @@ void OBSBasic::dropEvent(QDropEvent *event) } } else if (mimeData->hasText()) { AddDropSource(QT_TO_UTF8(mimeData->text()), DropType_RawText); + } else if (event->mimeData()->hasFormat("application/x-obs-source-uuid")) { + QString uuid = QString::fromUtf8(event->mimeData()->data("application/x-obs-source-uuid")); + + emit sourceUuidDropped(uuid); + event->acceptProposedAction(); } } diff --git a/frontend/widgets/OBSBasic_SceneItems.cpp b/frontend/widgets/OBSBasic_SceneItems.cpp index 763c464c9..7f8c4fec7 100644 --- a/frontend/widgets/OBSBasic_SceneItems.cpp +++ b/frontend/widgets/OBSBasic_SceneItems.cpp @@ -49,6 +49,25 @@ void setHiddenInMixer(obs_source_t *source, bool hidden) OBSDataAutoRelease priv_settings = obs_source_get_private_settings(source); obs_data_set_bool(priv_settings, "mixer_hidden", hidden); } + +std::string getNewSourceName(std::string_view name) +{ + std::string newName{name}; + int suffix = 1; + + for (;;) { + OBSSourceAutoRelease existing_source = obs_get_source_by_name(newName.c_str()); + if (!existing_source) { + break; + } + + char nextName[256]; + std::snprintf(nextName, sizeof(nextName), "%s (%d)", name.data(), ++suffix); + newName = nextName; + } + + return newName; +} } // namespace static inline bool HasAudioDevices(const char *source_id) @@ -198,8 +217,6 @@ void OBSBasic::SourceRenamed(void *data, calldata_t *params) blog(LOG_INFO, "Source '%s' renamed to '%s'", prevName, newName); } -extern char *get_new_source_name(const char *name, const char *format); - void OBSBasic::ResetAudioDevice(const char *sourceId, const char *deviceId, const char *deviceDesc, int channel) { bool disable = deviceId && strcmp(deviceId, "disabled") == 0; @@ -220,11 +237,11 @@ void OBSBasic::ResetAudioDevice(const char *sourceId, const char *deviceId, cons } } else if (!disable) { - BPtr name = get_new_source_name(deviceDesc, "%s (%d)"); + std::string name = getNewSourceName(deviceDesc); settings = obs_data_create(); obs_data_set_string(settings, "device_id", deviceId); - source = obs_source_create(sourceId, name, settings, nullptr); + source = obs_source_create(sourceId, name.c_str(), settings, nullptr); obs_set_output_source(channel, source); } @@ -568,10 +585,14 @@ void OBSBasic::CreateSourcePopupMenu(int idx, bool preview) } // Add new source - QPointer addSourceMenu = CreateAddSourcePopupMenu(); - if (addSourceMenu) { - popup.addMenu(addSourceMenu); - popup.addSeparator(); + QAction *addSource = popup.addAction(QTStr("AddSource"), this, &OBSBasic::AddSourceDialog); + popup.addAction(addSource); + popup.addSeparator(); + + if (!preview && !sourceSelected) { + QAction *addGroup = new QAction(QTStr("Basic.Main.NewGroup"), this); + connect(addGroup, &QAction::triggered, ui->sources, &SourceTree::AddGroup); + popup.addAction(addGroup); } // Preview menu entries @@ -687,14 +708,12 @@ void OBSBasic::CreateSourcePopupMenu(int idx, bool preview) // Source grouping if (ui->sources->MultipleBaseSelected()) { - popup.addSeparator(); popup.addAction(QTStr("Basic.Main.GroupItems"), ui->sources, &SourceTree::GroupSelectedItems); - - } else if (ui->sources->GroupsSelected()) { popup.addSeparator(); + } else if (ui->sources->GroupsSelected()) { popup.addAction(QTStr("Basic.Main.Ungroup"), ui->sources, &SourceTree::UngroupSelectedGroups); + popup.addSeparator(); } - popup.addSeparator(); popup.addAction(ui->actionCopySource); popup.addAction(ui->actionPasteRef); @@ -774,115 +793,27 @@ static inline bool should_show_properties(obs_source_t *source, const char *id) return true; } -void OBSBasic::AddSource(const char *id) +void OBSBasic::AddSourceDialog() { - if (id && *id) { - OBSBasicSourceSelect sourceSelect(this, id, undo_s); - sourceSelect.exec(); - if (should_show_properties(sourceSelect.newSource, id)) { - CreatePropertiesWindow(sourceSelect.newSource); - } - } -} - -QMenu *OBSBasic::CreateAddSourcePopupMenu() -{ - const char *unversioned_type; - const char *type; - bool foundValues = false; - bool foundDeprecated = false; - size_t idx = 0; - - QMenu *popup = new QMenu(QTStr("AddSource"), this); - QMenu *deprecated = new QMenu(QTStr("Deprecated"), popup); - - auto getActionAfter = [](QMenu *menu, const QString &name) { - QList actions = menu->actions(); - - for (QAction *menuAction : actions) { - if (menuAction->text().compare(name, Qt::CaseInsensitive) >= 0) - return menuAction; - } - - return (QAction *)nullptr; - }; - - auto addSource = [this, getActionAfter](QMenu *popup, const char *type, const char *name) { - QString qname = QT_UTF8(name); - QAction *popupItem = new QAction(qname, this); - connect(popupItem, &QAction::triggered, this, [this, type]() { AddSource(type); }); - - QIcon icon; - - if (strcmp(type, "scene") == 0) - icon = GetSceneIcon(); - else - icon = GetSourceIcon(type); - - popupItem->setIcon(icon); - - QAction *after = getActionAfter(popup, qname); - popup->insertAction(after, popupItem); - }; - - while (obs_enum_input_types2(idx++, &type, &unversioned_type)) { - const char *name = obs_source_get_display_name(type); - uint32_t caps = obs_get_source_output_flags(type); - - if ((caps & OBS_SOURCE_CAP_DISABLED) != 0) - continue; - - if ((caps & OBS_SOURCE_DEPRECATED) == 0) { - addSource(popup, unversioned_type, name); - } else { - addSource(deprecated, unversioned_type, name); - foundDeprecated = true; - } - foundValues = true; - } - - addSource(popup, "scene", Str("Basic.Scene")); - - popup->addSeparator(); - QAction *addGroup = new QAction(QTStr("Group"), this); - addGroup->setIcon(GetGroupIcon()); - connect(addGroup, &QAction::triggered, this, [this]() { AddSource("group"); }); - popup->addAction(addGroup); - - if (!foundDeprecated) { - delete deprecated; - deprecated = nullptr; - } - - if (!foundValues) { - delete popup; - popup = nullptr; - - } else if (foundDeprecated) { - popup->addSeparator(); - popup->addMenu(deprecated); - } - - return popup; -} - -void OBSBasic::AddSourcePopupMenu(const QPoint &pos) -{ - if (!GetCurrentScene()) { - // Tell the user he needs a scene first (help beginners). - OBSMessageBox::information(this, QTStr("Basic.Main.AddSourceHelp.Title"), - QTStr("Basic.Main.AddSourceHelp.Text")); + QAction *action = qobject_cast(sender()); + if (!action) { return; } - QScopedPointer popup(CreateAddSourcePopupMenu()); - if (popup) - popup->exec(pos); + if (addWindow) { + addWindow->close(); + } + + addWindow = new OBSBasicSourceSelect(this, undo_s); + addWindow->show(); + + addWindow->setAttribute(Qt::WA_DeleteOnClose, true); + connect(this, &OBSBasic::sourceUuidDropped, addWindow, &OBSBasicSourceSelect::sourceDropped); } void OBSBasic::on_actionAddSource_triggered() { - AddSourcePopupMenu(QCursor::pos()); + AddSourceDialog(); } static bool remove_items(obs_scene_t *, obs_sceneitem_t *item, void *param)