frontend: Replace add source dropdown with dialog

Co-Authored-By: Lain <134130700+Lain-B@users.noreply.github.com>
This commit is contained in:
Warchamp7
2025-09-27 00:53:02 -04:00
committed by Ryan Foster
parent b1a15af06e
commit 12597e9484
32 changed files with 3362 additions and 635 deletions

View File

@@ -929,6 +929,7 @@ OBSApp::OBSApp(int &argc, char **argv, profiler_name_store_t *store)
#endif
setDesktopFileName("com.obsproject.Studio");
pluginManager_ = std::make_unique<OBS::PluginManager>();
}
@@ -1262,6 +1263,8 @@ bool OBSApp::OBSInit()
setQuitOnLastWindowClosed(false);
thumbnailManager = new ThumbnailManager(this);
mainWindow = new OBSBasic();
mainWindow->setAttribute(Qt::WA_DeleteOnClose, true);

View File

@@ -17,8 +17,9 @@
#pragma once
#include <utility/OBSTheme.hpp>
#include <utility/NativeEventFilter.hpp>
#include <utility/OBSTheme.hpp>
#include <utility/ThumbnailManager.hpp>
#include <widgets/OBSMainWindow.hpp>
#include <obs-frontend-api.h>
@@ -90,6 +91,8 @@ private:
std::deque<obs_frontend_translate_ui_cb> translatorHooks;
ThumbnailManager *thumbnailManager = nullptr;
std::unique_ptr<OBS::PluginManager> 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();

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,134 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include <components/FlowFrame.hpp>
#include <QKeyEvent>
#include <QScrollArea>
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<QScrollArea *>(scrollParent);
if (scrollArea) {
scrollArea->ensureWidgetVisible(nextFocus, 20, 20);
break;
}
scrollParent = scrollParent->parentWidget();
}
}
}

View File

@@ -0,0 +1,37 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include "FlowLayout.hpp"
#include <QFrame>
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;
};

View File

@@ -0,0 +1,170 @@
/******************************************************************************
Example provided by Qt
<https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
Copyright (C) 2016 The Qt Company Ltd.
SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
******************************************************************************/
#include "FlowLayout.hpp"
#include <QWidget>
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<QWidget *>(parent);
return pw->style()->pixelMetric(pm, nullptr, pw);
} else {
return static_cast<QLayout *>(parent)->spacing();
}
}

View File

@@ -0,0 +1,40 @@
/******************************************************************************
Example provided by Qt
<https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
Copyright (C) 2016 The Qt Company Ltd.
SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
******************************************************************************/
#pragma once
#include <QLayout>
#include <QStyle>
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<QLayoutItem *> itemList;
int m_hSpace;
int m_vSpace;
};

View File

@@ -0,0 +1,222 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "SourceSelectButton.hpp"
#include <utility/ThumbnailManager.hpp>
#include <utility/ThumbnailView.hpp>
#include <widgets/OBSBasic.hpp>
#include <QDrag>
#include <QFrame>
#include <QMimeData>
#include <QPainter>
#include <QStyleOptionButton>
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<SourceSelectButton *>(data), &SourceSelectButton::handleSourceRemoved);
}
void SourceSelectButton::obsSourceRenamed(void *data, calldata_t *params)
{
const char *newNamePtr = static_cast<const char *>(calldata_ptr(params, "new_name"));
if (!newNamePtr) {
return;
}
QMetaObject::invokeMethod(static_cast<SourceSelectButton *>(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));
}
}

View File

@@ -0,0 +1,75 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <obs.hpp>
#include <QLabel>
#include <QPointer>
#include <QPushButton>
#include <QTimer>
#include <QVBoxLayout>
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<ThumbnailView> thumbnail;
QPointer<QLabel> image;
std::string sourceUuid;
std::vector<OBSSignal> 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();
};

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
/******************************************************************************
Copyright (C) 2023 by Lain Bailey <lain@obsproject.com>
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <OBSApp.hpp>
#include "ui_OBSBasicSourceSelect.h"
#include <components/FlowLayout.hpp>
#include <components/SourceSelectButton.hpp>
#include <utility/undo_stack.hpp>
#include <widgets/OBSBasic.hpp>
#include <obs.hpp>
#include <QButtonGroup>
#include <QDialog>
class OBSBasicSourceSelect : public QDialog {
Q_OBJECT
private:
std::unique_ptr<Ui::OBSBasicSourceSelect> 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::OBSBasicSourceSelect> ui;
QString selectedTypeId;
undo_stack &undo_s;
QPointer<QButtonGroup> sourceButtons;
std::vector<OBSSignal> signalHandlers;
static void obsSourceCreated(void *param, calldata_t *calldata);
static void obsSourceRemoved(void *param, calldata_t *calldata);
std::vector<OBSWeakSource> weakSources;
QPointer<FlowLayout> 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<std::string> 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);
};

View File

@@ -3,106 +3,674 @@
<class>OBSBasicSourceSelect</class>
<widget class="QDialog" name="OBSBasicSourceSelect">
<property name="windowModality">
<enum>Qt::WindowModal</enum>
<enum>Qt::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>352</width>
<height>314</height>
<width>1010</width>
<height>614</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>1000</height>
</size>
</property>
<property name="windowTitle">
<string>Basic.SourceSelect</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="class" stdset="0">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QRadioButton" name="createNew">
<property name="text">
<string>Basic.SourceSelect.CreateNew</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="sourceName"/>
</item>
<item>
<widget class="QRadioButton" name="selectExisting">
<property name="text">
<string>Basic.SourceSelect.AddExisting</string>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="sourceList">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="sourceVisible">
<property name="text">
<string>Basic.SourceSelect.AddVisible</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
<widget class="QFrame" name="dialogInner">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string>dialog-container</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="1,4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="selectTypeFrame">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="accessibleName">
<string>Basic.SourceSelect.SelectType</string>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string>dialog-frame</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QListWidget" name="sourceTypeList">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>460</height>
</size>
</property>
<property name="accessibleName">
<string>Basic.SourceSelect.SelectType</string>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContentsOnFirstShow</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="verticalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="horizontalScrollMode">
<enum>QAbstractItemView::ScrollPerPixel</enum>
</property>
<property name="resizeMode">
<enum>QListView::Adjust</enum>
</property>
<property name="class" stdset="0">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>17</width>
<height>4</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="sourcesFrame">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout_8" stretch="0,0">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="selectSourceContainer">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="selectExistingFrame">
<property name="accessibleName">
<string>Basic.SourceSelect.AddExisting</string>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string>dialog-container dialog-frame</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="existingLabel">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Basic.SourceSelect.Existing</string>
</property>
<property name="class" stdset="0">
<string>text-title</string>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="existingScrollArea">
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="existingScrollContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>510</height>
</rect>
</property>
<property name="autoFillBackground">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="FlowFrame" name="existingListFrame">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QWidget" name="addExistingContainer" native="true">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>259</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="addExistingButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Add Existing</string>
</property>
<property name="class" stdset="0">
<string>button-primary</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="createNewFrame">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string>dialog-container dialog-frame</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Basic.SourceSelect.NewSource</string>
</property>
<property name="class" stdset="0">
<string>text-title</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="deprecatedCreateLabel">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="text">
<string>Basic.SourceSelect.Deprecated.Create</string>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLineEdit" name="newSourceName">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="accessibleName">
<string>Basic.SourceSelect.Accessible.SourceName</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="createNewSource">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Basic.SourceSelect.CreateButton</string>
</property>
<property name="class" stdset="0">
<string>button-primary margin-left</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="footer">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="class" stdset="0">
<string>dialog-container</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QCheckBox" name="sourceVisible">
<property name="text">
<string>Basic.SourceSelect.AddVisible</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
<property name="centerButtons">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>createNew</sender>
<signal>toggled(bool)</signal>
<receiver>sourceName</receiver>
<slot>setEnabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>79</x>
<y>29</y>
</hint>
<hint type="destinationlabel">
<x>108</x>
<y>53</y>
</hint>
</hints>
</connection>
<connection>
<sender>selectExisting</sender>
<signal>toggled(bool)</signal>
<receiver>sourceList</receiver>
<slot>setEnabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>51</x>
<y>80</y>
</hint>
<hint type="destinationlabel">
<x>65</x>
<y>128</y>
</hint>
</hints>
</connection>
</connections>
<customwidgets>
<customwidget>
<class>FlowFrame</class>
<extends>QFrame</extends>
<header>components/FlowFrame.hpp</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>sourceTypeList</tabstop>
<tabstop>existingScrollArea</tabstop>
<tabstop>addExistingButton</tabstop>
<tabstop>newSourceName</tabstop>
<tabstop>createNewSource</tabstop>
<tabstop>sourceVisible</tabstop>
</tabstops>
<resources>
<include location="obs.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -0,0 +1,39 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <QObject>
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);
}
};

View File

@@ -1,5 +1,6 @@
/******************************************************************************
Copyright (C) 2023 by Lain Bailey <lain@obsproject.com>
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <widgets/OBSBasic.hpp>
@@ -30,11 +32,23 @@
#include "moc_ScreenshotObj.cpp"
static void ScreenshotTick(void *param, float);
namespace {
void renderTick(void *param, float)
{
ScreenshotObj *self = static_cast<ScreenshotObj *>(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<IWICImagingFactory> 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<IWICStream> 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<IWICBitmapEncoder> 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<IWICBitmapFrameEncode> frameEncode;
Microsoft::WRL::ComPtr<IPropertyBag2> 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<ScreenshotObj *>(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();
}

View File

@@ -1,5 +1,6 @@
/******************************************************************************
Copyright (C) 2023 by Lain Bailey <lain@obsproject.com>
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
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<uint8_t> 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<QImage> imagePtr;
bool outputToFile = true;
public slots:
void Save();
signals:
void imageReady(QImage image);
private slots:
void handleSave();
};

View File

@@ -0,0 +1,196 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "ThumbnailItem.hpp"
#include <utility/ScreenshotObj.hpp>
#include <utility/ThumbnailView.hpp>
#include <widgets/OBSBasic.hpp>
#include <QIcon>
#include <QPainter>
#include <QPixmap>
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<QPixmap> 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;
}

View File

@@ -0,0 +1,70 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <obs.hpp>
#include <QObject>
#include <QPixmap>
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();
};

View File

@@ -0,0 +1,268 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "ThumbnailManager.hpp"
#include <utility/ThumbnailView.hpp>
#include <widgets/OBSBasic.hpp>
#include "display-helpers.hpp"
#include <QImageWriter>
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<QPixmap> 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<QPixmap> ThumbnailManager::getCachedPixmap(const std::string &uuid)
{
return thumbnailCache.get(uuid);
}
void ThumbnailManager::createThumbnailItem(const std::string &uuid)
{
QPointer<ThumbnailItem> 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<obs_source_t *>(calldata_ptr(params, "source"));
const char *uuidPointer = obs_source_get_uuid(source);
if (!uuidPointer) {
return;
}
auto *manager = static_cast<ThumbnailManager *>(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<ThumbnailItem> 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<ThumbnailItem> item = entry->second;
if (item && !item->isDefaultPixmap()) {
std::string uuid = item->getUuid();
QPixmap pixmap = item->getPixmap();
addItemToCache(uuid, pixmap);
}
thumbnailList.erase(uuid);
}
}

View File

@@ -0,0 +1,91 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <obs.hpp>
#include <utility/ScreenshotObj.hpp>
#include <QElapsedTimer>
#include <QObject>
#include <QPixmap>
#include <QPointer>
#include <QTimer>
#include <deque>
#include <functional>
#include <unordered_map>
class ThumbnailItem;
class ThumbnailView;
class ThumbnailManager : public QObject {
Q_OBJECT
std::vector<OBSSignal> signalHandlers;
static void obsSourceRemoved(void *data, calldata_t *params);
struct ThumbnailCache {
size_t maxSize;
using CacheEntry = std::pair<std::string, QPixmap>;
std::list<CacheEntry> cacheList;
std::unordered_map<std::string, std::list<CacheEntry>::iterator> cacheMap;
explicit ThumbnailCache(size_t maxSize) : maxSize(maxSize) {}
void put(const std::string &key, const QPixmap &value);
std::optional<QPixmap> get(const std::string &key);
};
std::deque<std::string> priorityQueue;
std::deque<std::string> updateQueue;
std::unordered_map<std::string, ThumbnailItem *> 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<QPixmap> 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);
};

View File

@@ -0,0 +1,65 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "ThumbnailView.hpp"
ThumbnailView::ThumbnailView(QObject *parent, const QPointer<ThumbnailItem> &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);
}

View File

@@ -0,0 +1,54 @@
/******************************************************************************
Copyright (C) 2025 by Taylor Giampaolo <warchamp7@obsproject.com>
Lain Bailey <lain@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <obs.hpp>
#include <utility/ThumbnailItem.hpp>
#include <QObject>
#include <QPixmap>
#include <QPointer>
class ThumbnailView : public QObject {
Q_OBJECT
QPixmap pixmap{};
bool enabled = true;
public:
ThumbnailView(QObject *parent, const QPointer<ThumbnailItem> &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);
};

View File

@@ -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<OBSBasicAdvAudio> advAudioWindow;
QPointer<OBSBasicFilters> filters;
QPointer<OBSAbout> about;
QPointer<OBSBasicSourceSelect> addWindow;
QPointer<OBSLogViewer> logView;
QPointer<QWidget> stats;
QPointer<QWidget> 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);

View File

@@ -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 &copyInfo = clipboard[i - 1];
OBSBasicSourceSelect::SourcePaste(copyInfo, true);
OBSBasicSourceSelect::sourcePaste(copyInfo, true);
RefreshSources(GetCurrentScene());
}

View File

@@ -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();
}
}

View File

@@ -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<char> 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<QMenu> 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<QAction *> 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<QAction *>(sender());
if (!action) {
return;
}
QScopedPointer<QMenu> 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)