Răsfoiți Sursa

frontend: Merge main OBSBasic source with module sources

PatTheMav 1 an în urmă
părinte
comite
9b8517003b

+ 0 - 720
UI/window-basic-main-scene-collections.cpp

@@ -1,720 +0,0 @@
-/******************************************************************************
-    Copyright (C) 2023 by Lain Bailey <[email protected]>
-
-    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 <filesystem>
-#include <string>
-
-#include <obs.hpp>
-#include <util/util.hpp>
-#include <QMessageBox>
-#include <QVariant>
-#include <QFileDialog>
-#include <QStandardPaths>
-#include <qt-wrappers.hpp>
-#include "item-widget-helpers.hpp"
-#include "window-basic-main.hpp"
-#include "window-importer.hpp"
-#include "window-namedialog.hpp"
-
-// MARK: Constant Expressions
-
-constexpr std::string_view OBSSceneCollectionPath = "/obs-studio/basic/scenes/";
-
-// MARK: - Anonymous Namespace
-namespace {
-QList<QString> sortedSceneCollections{};
-
-void updateSortedSceneCollections(const OBSSceneCollectionCache &collections)
-{
-	const QLocale locale = QLocale::system();
-	QList<QString> newList{};
-
-	for (auto [collectionName, _] : collections) {
-		QString entry = QString::fromStdString(collectionName);
-		newList.append(entry);
-	}
-
-	std::sort(newList.begin(), newList.end(), [&locale](const QString &lhs, const QString &rhs) -> bool {
-		int result = QString::localeAwareCompare(locale.toLower(lhs), locale.toLower(rhs));
-
-		return (result < 0);
-	});
-
-	sortedSceneCollections.swap(newList);
-}
-
-void cleanBackupCollision(const OBSSceneCollection &collection)
-{
-	std::filesystem::path backupFilePath = collection.collectionFile;
-	backupFilePath.replace_extension(".json.bak");
-
-	if (std::filesystem::exists(backupFilePath)) {
-		try {
-			std::filesystem::remove(backupFilePath);
-		} catch (std::filesystem::filesystem_error &) {
-			throw std::logic_error("Failed to remove pre-existing scene collection backup file: " +
-					       backupFilePath.u8string());
-		}
-	}
-}
-} // namespace
-
-// MARK: - Main Scene Collection Management Functions
-
-void OBSBasic::SetupNewSceneCollection(const std::string &collectionName)
-{
-	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
-
-	cleanBackupCollision(newCollection);
-	ActivateSceneCollection(newCollection);
-
-	blog(LOG_INFO, "Created scene collection '%s' (clean, %s)", newCollection.name.c_str(),
-	     newCollection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-}
-
-void OBSBasic::SetupDuplicateSceneCollection(const std::string &collectionName)
-{
-	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
-	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
-
-	SaveProjectNow();
-
-	const auto copyOptions = std::filesystem::copy_options::overwrite_existing;
-
-	try {
-		std::filesystem::copy(currentCollection.collectionFile, newCollection.collectionFile, copyOptions);
-	} catch (const std::filesystem::filesystem_error &error) {
-		blog(LOG_DEBUG, "%s", error.what());
-		throw std::logic_error("Failed to copy file for cloned scene collection: " + newCollection.name);
-	}
-
-	OBSDataAutoRelease collection = obs_data_create_from_json_file(newCollection.collectionFile.u8string().c_str());
-
-	obs_data_set_string(collection, "name", newCollection.name.c_str());
-
-	OBSDataArrayAutoRelease sources = obs_data_get_array(collection, "sources");
-
-	if (sources) {
-		obs_data_erase(collection, "sources");
-
-		obs_data_array_enum(
-			sources,
-			[](obs_data_t *data, void *) -> void {
-				const char *uuid = os_generate_uuid();
-
-				obs_data_set_string(data, "uuid", uuid);
-
-				bfree((void *)uuid);
-			},
-			nullptr);
-
-		obs_data_set_array(collection, "sources", sources);
-	}
-
-	obs_data_save_json_safe(collection, newCollection.collectionFile.u8string().c_str(), "tmp", nullptr);
-
-	cleanBackupCollision(newCollection);
-	ActivateSceneCollection(newCollection);
-
-	blog(LOG_INFO, "Created scene collection '%s' (duplicate, %s)", newCollection.name.c_str(),
-	     newCollection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-}
-
-void OBSBasic::SetupRenameSceneCollection(const std::string &collectionName)
-{
-	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
-	const OBSSceneCollection currentCollection = GetCurrentSceneCollection();
-
-	SaveProjectNow();
-
-	const auto copyOptions = std::filesystem::copy_options::overwrite_existing;
-
-	try {
-		std::filesystem::copy(currentCollection.collectionFile, newCollection.collectionFile, copyOptions);
-	} catch (const std::filesystem::filesystem_error &error) {
-		blog(LOG_DEBUG, "%s", error.what());
-		throw std::logic_error("Failed to copy file for scene collection: " + currentCollection.name);
-	}
-
-	collections.erase(currentCollection.name);
-
-	OBSDataAutoRelease collection = obs_data_create_from_json_file(newCollection.collectionFile.u8string().c_str());
-
-	obs_data_set_string(collection, "name", newCollection.name.c_str());
-
-	obs_data_save_json_safe(collection, newCollection.collectionFile.u8string().c_str(), "tmp", nullptr);
-
-	cleanBackupCollision(newCollection);
-	ActivateSceneCollection(newCollection);
-	RemoveSceneCollection(currentCollection);
-
-	blog(LOG_INFO, "Renamed scene collection '%s' to '%s' (%s)", currentCollection.name.c_str(),
-	     newCollection.name.c_str(), newCollection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_RENAMED);
-}
-
-// MARK: - Scene Collection File Management Functions
-
-const OBSSceneCollection &OBSBasic::CreateSceneCollection(const std::string &collectionName)
-{
-	if (const auto &foundCollection = GetSceneCollectionByName(collectionName)) {
-		throw std::invalid_argument("Scene collection already exists: " + collectionName);
-	}
-
-	std::string fileName;
-	if (!GetFileSafeName(collectionName.c_str(), fileName)) {
-		throw std::invalid_argument("Failed to create safe directory for new scene collection: " +
-					    collectionName);
-	}
-
-	std::string collectionFile;
-	collectionFile.reserve(App()->userScenesLocation.u8string().size() + OBSSceneCollectionPath.size() +
-			       fileName.size());
-	collectionFile.append(App()->userScenesLocation.u8string()).append(OBSSceneCollectionPath).append(fileName);
-
-	if (!GetClosestUnusedFileName(collectionFile, "json")) {
-		throw std::invalid_argument("Failed to get closest file name for new scene collection: " + fileName);
-	}
-
-	const std::filesystem::path collectionFilePath = std::filesystem::u8path(collectionFile);
-
-	auto [iterator, success] = collections.try_emplace(
-		collectionName,
-		OBSSceneCollection{collectionName, collectionFilePath.filename().u8string(), collectionFilePath});
-
-	return iterator->second;
-}
-
-void OBSBasic::RemoveSceneCollection(OBSSceneCollection collection)
-{
-	try {
-		std::filesystem::remove(collection.collectionFile);
-	} catch (const std::filesystem::filesystem_error &error) {
-		blog(LOG_DEBUG, "%s", error.what());
-		throw std::logic_error("Failed to remove scene collection file: " + collection.fileName);
-	}
-
-	blog(LOG_INFO, "Removed scene collection '%s' (%s)", collection.name.c_str(), collection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-}
-
-// MARK: - Scene Collection UI Handling Functions
-
-bool OBSBasic::CreateNewSceneCollection(const QString &name)
-{
-	try {
-		SetupNewSceneCollection(name.toStdString());
-		return true;
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-		return false;
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-		return false;
-	}
-}
-
-bool OBSBasic::CreateDuplicateSceneCollection(const QString &name)
-{
-	try {
-		SetupDuplicateSceneCollection(name.toStdString());
-		return true;
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-		return false;
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-		return false;
-	}
-}
-
-void OBSBasic::DeleteSceneCollection(const QString &name)
-{
-	const std::string_view currentCollectionName{
-		config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
-
-	if (currentCollectionName == name.toStdString()) {
-		on_actionRemoveSceneCollection_triggered();
-		return;
-	}
-
-	OBSSceneCollection currentCollection = GetCurrentSceneCollection();
-
-	RemoveSceneCollection(currentCollection);
-
-	collections.erase(name.toStdString());
-
-	RefreshSceneCollections();
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
-}
-
-void OBSBasic::ChangeSceneCollection()
-{
-	QAction *action = reinterpret_cast<QAction *>(sender());
-
-	if (!action) {
-		return;
-	}
-
-	const std::string_view currentCollectionName{
-		config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
-	const QVariant qCollectionName = action->property("collection_name");
-	const std::string selectedCollectionName{qCollectionName.toString().toStdString()};
-
-	if (currentCollectionName == selectedCollectionName) {
-		action->setChecked(true);
-		return;
-	}
-
-	const std::optional<OBSSceneCollection> foundCollection = GetSceneCollectionByName(selectedCollectionName);
-
-	if (!foundCollection) {
-		const std::string errorMessage{"Selected scene collection not found: "};
-
-		throw std::invalid_argument(errorMessage + currentCollectionName.data());
-	}
-
-	const OBSSceneCollection &selectedCollection = foundCollection.value();
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
-
-	ActivateSceneCollection(selectedCollection);
-
-	blog(LOG_INFO, "Switched to scene collection '%s' (%s)", selectedCollection.name.c_str(),
-	     selectedCollection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-}
-
-void OBSBasic::RefreshSceneCollections(bool refreshCache)
-{
-	std::string_view currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
-
-	QList<QAction *> menuActions = ui->sceneCollectionMenu->actions();
-
-	for (auto &action : menuActions) {
-		QVariant variant = action->property("file_name");
-		if (variant.typeName() != nullptr) {
-			delete action;
-		}
-	}
-
-	if (refreshCache) {
-		RefreshSceneCollectionCache();
-	}
-
-	updateSortedSceneCollections(collections);
-
-	size_t numAddedCollections = 0;
-	for (auto &name : sortedSceneCollections) {
-		const std::string collectionName = name.toStdString();
-		try {
-			const OBSSceneCollection &collection = collections.at(collectionName);
-			const QString qCollectionName = QString().fromStdString(collectionName);
-
-			QAction *action = new QAction(qCollectionName, this);
-			action->setProperty("collection_name", qCollectionName);
-			action->setProperty("file_name", QString().fromStdString(collection.fileName));
-			connect(action, &QAction::triggered, this, &OBSBasic::ChangeSceneCollection);
-			action->setCheckable(true);
-			action->setChecked(collectionName == currentCollectionName);
-
-			ui->sceneCollectionMenu->addAction(action);
-
-			numAddedCollections += 1;
-		} catch (const std::out_of_range &error) {
-			blog(LOG_ERROR, "No scene collection with name %s found in scene collection cache.\n%s",
-			     collectionName.c_str(), error.what());
-		}
-	}
-
-	ui->actionRemoveSceneCollection->setEnabled(numAddedCollections > 1);
-
-	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
-
-	main->ui->actionPasteFilters->setEnabled(false);
-	main->ui->actionPasteRef->setEnabled(false);
-	main->ui->actionPasteDup->setEnabled(false);
-}
-
-// MARK: - Scene Collection Cache Functions
-
-void OBSBasic::RefreshSceneCollectionCache()
-{
-	OBSSceneCollectionCache foundCollections{};
-
-	const std::filesystem::path collectionsPath =
-		App()->userScenesLocation / std::filesystem::u8path(OBSSceneCollectionPath.substr(1));
-
-	if (!std::filesystem::exists(collectionsPath)) {
-		blog(LOG_WARNING, "Failed to get scene collections config path");
-		return;
-	}
-
-	for (const auto &entry : std::filesystem::directory_iterator(collectionsPath)) {
-		if (entry.is_directory()) {
-			continue;
-		}
-
-		if (entry.path().extension().u8string() != ".json") {
-			continue;
-		}
-
-		OBSDataAutoRelease collectionData =
-			obs_data_create_from_json_file_safe(entry.path().u8string().c_str(), "bak");
-
-		std::string candidateName;
-		const char *collectionName = obs_data_get_string(collectionData, "name");
-
-		if (!collectionName) {
-			candidateName = entry.path().filename().u8string();
-		} else {
-			candidateName = collectionName;
-		}
-
-		foundCollections.try_emplace(candidateName,
-					     OBSSceneCollection{candidateName, entry.path().filename().u8string(),
-								entry.path()});
-	}
-
-	collections.swap(foundCollections);
-}
-
-const OBSSceneCollection &OBSBasic::GetCurrentSceneCollection() const
-{
-	std::string currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
-
-	if (currentCollectionName.empty()) {
-		throw std::invalid_argument("No valid scene collection name in configuration Basic->SceneCollection");
-	}
-
-	const auto &foundCollection = collections.find(currentCollectionName);
-
-	if (foundCollection != collections.end()) {
-		return foundCollection->second;
-	} else {
-		throw std::invalid_argument("Scene collection not found in collection list: " + currentCollectionName);
-	}
-}
-
-std::optional<OBSSceneCollection> OBSBasic::GetSceneCollectionByName(const std::string &collectionName) const
-{
-	auto foundCollection = collections.find(collectionName);
-
-	if (foundCollection == collections.end()) {
-		return {};
-	} else {
-		return foundCollection->second;
-	}
-}
-
-std::optional<OBSSceneCollection> OBSBasic::GetSceneCollectionByFileName(const std::string &fileName) const
-{
-	for (auto &[iterator, collection] : collections) {
-		if (collection.fileName == fileName) {
-			return collection;
-		}
-	}
-
-	return {};
-}
-
-// MARK: - Qt Slot Functions
-
-void OBSBasic::on_actionNewSceneCollection_triggered()
-{
-	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
-		if (GetSceneCollectionByName(result.promptValue)) {
-			return false;
-		}
-
-		return true;
-	};
-
-	const OBSPromptRequest request{Str("Basic.Main.AddSceneCollection.Title"),
-				       Str("Basic.Main.AddSceneCollection.Text")};
-
-	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
-
-	if (!result.success) {
-		return;
-	}
-
-	try {
-		SetupNewSceneCollection(result.promptValue);
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	}
-}
-
-void OBSBasic::on_actionDupSceneCollection_triggered()
-{
-	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
-		if (GetSceneCollectionByName(result.promptValue)) {
-			return false;
-		}
-
-		return true;
-	};
-
-	const OBSPromptRequest request{Str("Basic.Main.AddSceneCollection.Title"),
-				       Str("Basic.Main.AddSceneCollection.Text")};
-
-	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
-
-	if (!result.success) {
-		return;
-	}
-
-	try {
-		SetupDuplicateSceneCollection(result.promptValue);
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	}
-}
-
-void OBSBasic::on_actionRenameSceneCollection_triggered()
-{
-	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
-
-	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
-		if (GetSceneCollectionByName(result.promptValue)) {
-			return false;
-		}
-
-		return true;
-	};
-
-	const OBSPromptRequest request{Str("Basic.Main.RenameSceneCollection.Title"),
-				       Str("Basic.Main.AddSceneCollection.Text"), currentCollection.name};
-
-	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
-
-	if (!result.success) {
-		return;
-	}
-
-	try {
-		SetupRenameSceneCollection(result.promptValue);
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	}
-}
-
-void OBSBasic::on_actionRemoveSceneCollection_triggered(bool skipConfirmation)
-{
-	if (collections.size() < 2) {
-		return;
-	}
-
-	OBSSceneCollection currentCollection;
-
-	try {
-		currentCollection = GetCurrentSceneCollection();
-
-		if (!skipConfirmation) {
-			const QString confirmationText =
-				QTStr("ConfirmRemove.Text").arg(QString::fromStdString(currentCollection.name));
-			const QMessageBox::StandardButton button =
-				OBSMessageBox::question(this, QTStr("ConfirmRemove.Title"), confirmationText);
-
-			if (button == QMessageBox::No) {
-				return;
-			}
-		}
-
-		OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
-
-		collections.erase(currentCollection.name);
-	} catch (const std::invalid_argument &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	} catch (const std::logic_error &error) {
-		blog(LOG_ERROR, "%s", error.what());
-	}
-
-	const OBSSceneCollection &newCollection = collections.begin()->second;
-
-	ActivateSceneCollection(newCollection);
-	RemoveSceneCollection(currentCollection);
-
-	blog(LOG_INFO, "Switched to scene collection '%s' (%s)", newCollection.name.c_str(),
-	     newCollection.fileName.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
-}
-
-void OBSBasic::on_actionImportSceneCollection_triggered()
-{
-	OBSImporter imp(this);
-	imp.exec();
-
-	RefreshSceneCollections(true);
-}
-
-void OBSBasic::on_actionExportSceneCollection_triggered()
-{
-	SaveProjectNow();
-
-	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
-
-	const QString home = QDir::homePath();
-
-	const QString destinationFileName = SaveFile(this, QTStr("Basic.MainMenu.SceneCollection.Export"),
-						     home + "/" + currentCollection.fileName.c_str(),
-						     "JSON Files (*.json)");
-
-	if (!destinationFileName.isEmpty() && !destinationFileName.isNull()) {
-		const std::filesystem::path sourceFile = currentCollection.collectionFile;
-		const std::filesystem::path destinationFile =
-			std::filesystem::u8path(destinationFileName.toStdString());
-
-		OBSDataAutoRelease collection = obs_data_create_from_json_file(sourceFile.u8string().c_str());
-
-		OBSDataArrayAutoRelease sources = obs_data_get_array(collection, "sources");
-		if (!sources) {
-			blog(LOG_WARNING, "No sources in exported scene collection");
-			return;
-		}
-
-		obs_data_erase(collection, "sources");
-
-		using OBSDataVector = std::vector<OBSData>;
-
-		OBSDataVector sourceItems;
-		obs_data_array_enum(
-			sources,
-			[](obs_data_t *data, void *vector) -> void {
-				OBSDataVector &sourceItems{*static_cast<OBSDataVector *>(vector)};
-				sourceItems.push_back(data);
-			},
-			&sourceItems);
-
-		std::sort(sourceItems.begin(), sourceItems.end(), [](const OBSData &a, const OBSData &b) {
-			return astrcmpi(obs_data_get_string(a, "name"), obs_data_get_string(b, "name")) < 0;
-		});
-
-		OBSDataArrayAutoRelease newSources = obs_data_array_create();
-		for (auto &item : sourceItems) {
-			obs_data_array_push_back(newSources, item);
-		}
-
-		obs_data_set_array(collection, "sources", newSources);
-		obs_data_save_json_pretty_safe(collection, destinationFile.u8string().c_str(), "tmp", "bak");
-	}
-}
-
-void OBSBasic::on_actionRemigrateSceneCollection_triggered()
-{
-	if (Active()) {
-		OBSMessageBox::warning(this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
-				       QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.Active"));
-		return;
-	}
-
-	OBSDataAutoRelease priv = obs_get_private_data();
-
-	if (!usingAbsoluteCoordinates && !migrationBaseResolution) {
-		OBSMessageBox::warning(
-			this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
-			QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.UnknownBaseResolution"));
-		return;
-	}
-
-	obs_video_info ovi;
-	obs_get_video_info(&ovi);
-
-	if (!usingAbsoluteCoordinates && migrationBaseResolution->first == ovi.base_width &&
-	    migrationBaseResolution->second == ovi.base_height) {
-		OBSMessageBox::warning(
-			this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
-			QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.BaseResolutionMatches"));
-		return;
-	}
-
-	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
-
-	QString name = QString::fromStdString(currentCollection.name);
-	QString message =
-		QTStr("Basic.Main.RemigrateSceneCollection.Text").arg(name).arg(ovi.base_width).arg(ovi.base_height);
-
-	auto answer = OBSMessageBox::question(this, QTStr("Basic.Main.RemigrateSceneCollection.Title"), message);
-
-	if (answer == QMessageBox::No)
-		return;
-
-	lastOutputResolution = {ovi.base_width, ovi.base_height};
-
-	if (!usingAbsoluteCoordinates) {
-		/* Temporarily change resolution to migration resolution */
-		ovi.base_width = migrationBaseResolution->first;
-		ovi.base_height = migrationBaseResolution->second;
-
-		if (obs_reset_video(&ovi) != OBS_VIDEO_SUCCESS) {
-			OBSMessageBox::critical(
-				this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
-				QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.FailedVideoReset"));
-			return;
-		}
-	}
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
-
-	/* Save and immediately reload to (re-)run migrations. */
-	SaveProjectNow();
-	/* Reset video if we potentially changed to a temporary resolution */
-	if (!usingAbsoluteCoordinates) {
-		ResetVideo();
-	}
-
-	ActivateSceneCollection(currentCollection);
-}
-
-// MARK: - Scene Collection Management Helper Functions
-
-void OBSBasic::ActivateSceneCollection(const OBSSceneCollection &collection)
-{
-	const std::string currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
-
-	if (auto foundCollection = GetSceneCollectionByName(currentCollectionName)) {
-		if (collection.name != foundCollection.value().name) {
-			SaveProjectNow();
-		}
-	}
-
-	config_set_string(App()->GetUserConfig(), "Basic", "SceneCollection", collection.name.c_str());
-	config_set_string(App()->GetUserConfig(), "Basic", "SceneCollectionFile", collection.fileName.c_str());
-
-	Load(collection.collectionFile.u8string().c_str());
-
-	RefreshSceneCollections();
-
-	UpdateTitleBar();
-
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
-}

+ 562 - 0
frontend/widgets/OBSBasic_Browser.cpp

@@ -1,3 +1,565 @@
+#include "moc_window-extra-browsers.cpp"
+#include "window-dock-browser.hpp"
+#include "window-basic-main.hpp"
+
+#include <qt-wrappers.hpp>
+#include <QLineEdit>
+#include <QHBoxLayout>
+#include <QUuid>
+
+#include <json11.hpp>
+
+#include "ui_OBSExtraBrowsers.h"
+
+using namespace json11;
+
+#define OBJ_NAME_SUFFIX "_extraBrowser"
+
+enum class Column : int {
+	Title,
+	Url,
+	Delete,
+
+	Count,
+};
+
+/* ------------------------------------------------------------------------- */
+
+void ExtraBrowsersModel::Reset()
+{
+	items.clear();
+
+	OBSBasic *main = OBSBasic::Get();
+
+	for (int i = 0; i < main->extraBrowserDocks.size(); i++) {
+		Item item;
+		item.prevIdx = i;
+		item.title = main->extraBrowserDockNames[i];
+		item.url = main->extraBrowserDockTargets[i];
+		items.push_back(item);
+	}
+}
+
+int ExtraBrowsersModel::rowCount(const QModelIndex &) const
+{
+	int count = items.size() + 1;
+	return count;
+}
+
+int ExtraBrowsersModel::columnCount(const QModelIndex &) const
+{
+	return (int)Column::Count;
+}
+
+QVariant ExtraBrowsersModel::data(const QModelIndex &index, int role) const
+{
+	int column = index.column();
+	int idx = index.row();
+	int count = items.size();
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (!validRole)
+		return QVariant();
+
+	if (idx >= 0 && idx < count) {
+		switch (column) {
+		case (int)Column::Title:
+			return items[idx].title;
+		case (int)Column::Url:
+			return items[idx].url;
+		}
+	} else if (idx == count) {
+		switch (column) {
+		case (int)Column::Title:
+			return newTitle;
+		case (int)Column::Url:
+			return newURL;
+		}
+	}
+
+	return QVariant();
+}
+
+QVariant ExtraBrowsersModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+	bool validRole = role == Qt::DisplayRole || role == Qt::AccessibleTextRole;
+
+	if (validRole && orientation == Qt::Orientation::Horizontal) {
+		switch (section) {
+		case (int)Column::Title:
+			return QTStr("ExtraBrowsers.DockName");
+		case (int)Column::Url:
+			return QStringLiteral("URL");
+		}
+	}
+
+	return QVariant();
+}
+
+Qt::ItemFlags ExtraBrowsersModel::flags(const QModelIndex &index) const
+{
+	Qt::ItemFlags flags = QAbstractTableModel::flags(index);
+
+	if (index.column() != (int)Column::Delete)
+		flags |= Qt::ItemIsEditable;
+
+	return flags;
+}
+
+class DelButton : public QPushButton {
+public:
+	inline DelButton(QModelIndex index_) : QPushButton(), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+class EditWidget : public QLineEdit {
+public:
+	inline EditWidget(QWidget *parent, QModelIndex index_) : QLineEdit(parent), index(index_) {}
+
+	QPersistentModelIndex index;
+};
+
+void ExtraBrowsersModel::AddDeleteButton(int idx)
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	QModelIndex index = createIndex(idx, (int)Column::Delete, nullptr);
+
+	QPushButton *del = new DelButton(index);
+	del->setProperty("class", "icon-trash");
+	del->setObjectName("extraPanelDelete");
+	del->setMinimumSize(QSize(20, 20));
+	connect(del, &QPushButton::clicked, this, &ExtraBrowsersModel::DeleteItem);
+
+	widget->setIndexWidget(index, del);
+	widget->setRowHeight(idx, 20);
+	widget->setColumnWidth(idx, 20);
+}
+
+void ExtraBrowsersModel::CheckToAdd()
+{
+	if (newTitle.isEmpty() || newURL.isEmpty())
+		return;
+
+	int idx = items.size() + 1;
+	beginInsertRows(QModelIndex(), idx, idx);
+
+	Item item;
+	item.prevIdx = -1;
+	item.title = newTitle;
+	item.url = newURL;
+	items.push_back(item);
+
+	newTitle = "";
+	newURL = "";
+
+	endInsertRows();
+
+	AddDeleteButton(idx - 1);
+}
+
+void ExtraBrowsersModel::UpdateItem(Item &item)
+{
+	int idx = item.prevIdx;
+
+	OBSBasic *main = OBSBasic::Get();
+	BrowserDock *dock = reinterpret_cast<BrowserDock *>(main->extraBrowserDocks[idx].get());
+	dock->setWindowTitle(item.title);
+	dock->setObjectName(item.title + OBJ_NAME_SUFFIX);
+
+	if (main->extraBrowserDockNames[idx] != item.title) {
+		main->extraBrowserDockNames[idx] = item.title;
+		dock->toggleViewAction()->setText(item.title);
+		dock->setTitle(item.title);
+	}
+
+	if (main->extraBrowserDockTargets[idx] != item.url) {
+		dock->cefWidget->setURL(QT_TO_UTF8(item.url));
+		main->extraBrowserDockTargets[idx] = item.url;
+	}
+}
+
+void ExtraBrowsersModel::DeleteItem()
+{
+	QTableView *widget = reinterpret_cast<QTableView *>(parent());
+
+	DelButton *del = reinterpret_cast<DelButton *>(sender());
+	int row = del->index.row();
+
+	/* there's some sort of internal bug in Qt and deleting certain index
+	 * widgets or "editors" that can cause a crash inside Qt if the widget
+	 * is not manually removed, at least on 5.7 */
+	widget->setIndexWidget(del->index, nullptr);
+	del->deleteLater();
+
+	/* --------- */
+
+	beginRemoveRows(QModelIndex(), row, row);
+
+	int prevIdx = items[row].prevIdx;
+	items.removeAt(row);
+
+	if (prevIdx != -1) {
+		int i = 0;
+		for (; i < deleted.size() && deleted[i] < prevIdx; i++)
+			;
+		deleted.insert(i, prevIdx);
+	}
+
+	endRemoveRows();
+}
+
+void ExtraBrowsersModel::Apply()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	for (Item &item : items) {
+		if (item.prevIdx != -1) {
+			UpdateItem(item);
+		} else {
+			QString uuid = QUuid::createUuid().toString();
+			uuid.replace(QRegularExpression("[{}-]"), "");
+			main->AddExtraBrowserDock(item.title, item.url, uuid, true);
+		}
+	}
+
+	for (int i = deleted.size() - 1; i >= 0; i--) {
+		int idx = deleted[i];
+		main->extraBrowserDockTargets.removeAt(idx);
+		main->extraBrowserDockNames.removeAt(idx);
+		main->extraBrowserDocks.removeAt(idx);
+	}
+
+	if (main->extraBrowserDocks.empty())
+		main->extraBrowserMenuDocksSeparator.clear();
+
+	deleted.clear();
+
+	Reset();
+}
+
+void ExtraBrowsersModel::TabSelection(bool forward)
+{
+	QListView *widget = reinterpret_cast<QListView *>(parent());
+	QItemSelectionModel *selModel = widget->selectionModel();
+
+	QModelIndex sel = selModel->currentIndex();
+	int row = sel.row();
+	int col = sel.column();
+
+	switch (sel.column()) {
+	case (int)Column::Title:
+		if (!forward) {
+			if (row == 0) {
+				return;
+			}
+
+			row -= 1;
+		}
+
+		col += 1;
+		break;
+
+	case (int)Column::Url:
+		if (forward) {
+			if (row == items.size()) {
+				return;
+			}
+
+			row += 1;
+		}
+
+		col -= 1;
+	}
+
+	sel = createIndex(row, col, nullptr);
+	selModel->setCurrentIndex(sel, QItemSelectionModel::Clear);
+}
+
+void ExtraBrowsersModel::Init()
+{
+	for (int i = 0; i < items.count(); i++)
+		AddDeleteButton(i);
+}
+
+/* ------------------------------------------------------------------------- */
+
+QWidget *ExtraBrowsersDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &,
+					     const QModelIndex &index) const
+{
+	QLineEdit *text = new EditWidget(parent, index);
+	text->installEventFilter(const_cast<ExtraBrowsersDelegate *>(this));
+	text->setSizePolicy(QSizePolicy(QSizePolicy::Policy::Expanding, QSizePolicy::Policy::Expanding,
+					QSizePolicy::ControlType::LineEdit));
+	return text;
+}
+
+void ExtraBrowsersDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const
+{
+	QLineEdit *text = reinterpret_cast<QLineEdit *>(editor);
+	text->blockSignals(true);
+	text->setText(index.data().toString());
+	text->blockSignals(false);
+}
+
+bool ExtraBrowsersDelegate::eventFilter(QObject *object, QEvent *event)
+{
+	QLineEdit *edit = qobject_cast<QLineEdit *>(object);
+	if (!edit)
+		return false;
+
+	if (LineEditCanceled(event)) {
+		RevertText(edit);
+	}
+	if (LineEditChanged(event)) {
+		UpdateText(edit);
+
+		if (event->type() == QEvent::KeyPress) {
+			QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+			if (keyEvent->key() == Qt::Key_Tab) {
+				model->TabSelection(true);
+			} else if (keyEvent->key() == Qt::Key_Backtab) {
+				model->TabSelection(false);
+			}
+		}
+		return true;
+	}
+
+	return false;
+}
+
+bool ExtraBrowsersDelegate::ValidName(const QString &name) const
+{
+	for (auto &item : model->items) {
+		if (name.compare(item.title, Qt::CaseInsensitive) == 0) {
+			return false;
+		}
+	}
+	return true;
+}
+
+void ExtraBrowsersDelegate::RevertText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString oldText;
+	if (col == (int)Column::Title) {
+		oldText = newItem ? model->newTitle : model->items[row].title;
+	} else {
+		oldText = newItem ? model->newURL : model->items[row].url;
+	}
+
+	edit->setText(oldText);
+}
+
+bool ExtraBrowsersDelegate::UpdateText(QLineEdit *edit_)
+{
+	EditWidget *edit = reinterpret_cast<EditWidget *>(edit_);
+	int row = edit->index.row();
+	int col = edit->index.column();
+	bool newItem = (row == model->items.size());
+
+	QString text = edit->text().trimmed();
+
+	if (!newItem && text.isEmpty()) {
+		return false;
+	}
+
+	if (col == (int)Column::Title) {
+		QString oldText = newItem ? model->newTitle : model->items[row].title;
+		bool same = oldText.compare(text, Qt::CaseInsensitive) == 0;
+
+		if (!same && !ValidName(text)) {
+			edit->setText(oldText);
+			return false;
+		}
+	}
+
+	if (!newItem) {
+		/* if edited existing item, update it*/
+		switch (col) {
+		case (int)Column::Title:
+			model->items[row].title = text;
+			break;
+		case (int)Column::Url:
+			model->items[row].url = text;
+			break;
+		}
+	} else {
+		/* if both new values filled out, create new one */
+		switch (col) {
+		case (int)Column::Title:
+			model->newTitle = text;
+			break;
+		case (int)Column::Url:
+			model->newURL = text;
+			break;
+		}
+
+		model->CheckToAdd();
+	}
+
+	emit commitData(edit);
+	return true;
+}
+
+/* ------------------------------------------------------------------------- */
+
+OBSExtraBrowsers::OBSExtraBrowsers(QWidget *parent) : QDialog(parent), ui(new Ui::OBSExtraBrowsers)
+{
+	ui->setupUi(this);
+
+	setAttribute(Qt::WA_DeleteOnClose, true);
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	model = new ExtraBrowsersModel(ui->table);
+
+	ui->table->setModel(model);
+	ui->table->setItemDelegateForColumn((int)Column::Title, new ExtraBrowsersDelegate(model));
+	ui->table->setItemDelegateForColumn((int)Column::Url, new ExtraBrowsersDelegate(model));
+	ui->table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Stretch);
+	ui->table->horizontalHeader()->setSectionResizeMode((int)Column::Delete, QHeaderView::ResizeMode::Fixed);
+	ui->table->setEditTriggers(QAbstractItemView::EditTrigger::CurrentChanged);
+}
+
+OBSExtraBrowsers::~OBSExtraBrowsers() {}
+
+void OBSExtraBrowsers::closeEvent(QCloseEvent *event)
+{
+	QDialog::closeEvent(event);
+	model->Apply();
+}
+
+void OBSExtraBrowsers::on_apply_clicked()
+{
+	model->Apply();
+}
+
+/* ------------------------------------------------------------------------- */
+
+void OBSBasic::ClearExtraBrowserDocks()
+{
+	extraBrowserDockTargets.clear();
+	extraBrowserDockNames.clear();
+	extraBrowserDocks.clear();
+}
+
+void OBSBasic::LoadExtraBrowserDocks()
+{
+	const char *jsonStr = config_get_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks");
+
+	std::string err;
+	Json json = Json::parse(jsonStr, err);
+	if (!err.empty())
+		return;
+
+	Json::array array = json.array_items();
+	if (!array.empty())
+		extraBrowserMenuDocksSeparator = ui->menuDocks->addSeparator();
+
+	for (Json &item : array) {
+		std::string title = item["title"].string_value();
+		std::string url = item["url"].string_value();
+		std::string uuid = item["uuid"].string_value();
+
+		AddExtraBrowserDock(title.c_str(), url.c_str(), uuid.c_str(), false);
+	}
+}
+
+void OBSBasic::SaveExtraBrowserDocks()
+{
+	Json::array array;
+	for (int i = 0; i < extraBrowserDocks.size(); i++) {
+		QDockWidget *dock = extraBrowserDocks[i].get();
+		QString title = extraBrowserDockNames[i];
+		QString url = extraBrowserDockTargets[i];
+		QString uuid = dock->property("uuid").toString();
+		Json::object obj{
+			{"title", QT_TO_UTF8(title)},
+			{"url", QT_TO_UTF8(url)},
+			{"uuid", QT_TO_UTF8(uuid)},
+		};
+		array.push_back(obj);
+	}
+
+	std::string output = Json(array).dump();
+	config_set_string(App()->GetUserConfig(), "BasicWindow", "ExtraBrowserDocks", output.c_str());
+}
+
+void OBSBasic::ManageExtraBrowserDocks()
+{
+	if (!extraBrowsers.isNull()) {
+		extraBrowsers->show();
+		extraBrowsers->raise();
+		return;
+	}
+
+	extraBrowsers = new OBSExtraBrowsers(this);
+	extraBrowsers->show();
+}
+
+void OBSBasic::AddExtraBrowserDock(const QString &title, const QString &url, const QString &uuid, bool firstCreate)
+{
+	static int panel_version = -1;
+	if (panel_version == -1) {
+		panel_version = obs_browser_qcef_version();
+	}
+
+	BrowserDock *dock = new BrowserDock(title);
+	QString bId(uuid.isEmpty() ? QUuid::createUuid().toString() : uuid);
+	bId.replace(QRegularExpression("[{}-]"), "");
+	dock->setProperty("uuid", bId);
+	dock->setObjectName(title + OBJ_NAME_SUFFIX);
+	dock->resize(460, 600);
+	dock->setMinimumSize(80, 80);
+	dock->setWindowTitle(title);
+	dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	QCefWidget *browser = cef->create_widget(dock, QT_TO_UTF8(url), nullptr);
+	if (browser && panel_version >= 1)
+		browser->allowAllPopups(true);
+
+	dock->SetWidget(browser);
+
+	/* Add support for Twitch Dashboard panels */
+	if (url.contains("twitch.tv/popout") && url.contains("dashboard/live")) {
+		QRegularExpression re("twitch.tv\\/popout\\/([^/]+)\\/");
+		QRegularExpressionMatch match = re.match(url);
+		QString username = match.captured(1);
+		if (username.length() > 0) {
+			std::string script;
+			script = "Object.defineProperty(document, 'referrer', { get: () => '";
+			script += "https://twitch.tv/";
+			script += QT_TO_UTF8(username);
+			script += "/dashboard/live";
+			script += "'});";
+			browser->setStartupScript(script);
+		}
+	}
+
+	AddDockWidget(dock, Qt::RightDockWidgetArea, true);
+	extraBrowserDocks.push_back(std::shared_ptr<QDockWidget>(dock));
+	extraBrowserDockNames.push_back(title);
+	extraBrowserDockTargets.push_back(url);
+
+	if (firstCreate) {
+		dock->setFloating(true);
+
+		QPoint curPos = pos();
+		QSize wSizeD2 = size() / 2;
+		QSize dSizeD2 = dock->size() / 2;
+
+		curPos.setX(curPos.x() + wSizeD2.width() - dSizeD2.width());
+		curPos.setY(curPos.y() + wSizeD2.height() - dSizeD2.height());
+
+		dock->move(curPos);
+		dock->setVisible(true);
+	}
+}
 /******************************************************************************
     Copyright (C) 2023 by Lain Bailey <[email protected]>
 

+ 720 - 0
frontend/widgets/OBSBasic_SceneCollections.cpp

@@ -1,3 +1,723 @@
+/******************************************************************************
+    Copyright (C) 2023 by Lain Bailey <[email protected]>
+
+    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 <filesystem>
+#include <string>
+
+#include <obs.hpp>
+#include <util/util.hpp>
+#include <QMessageBox>
+#include <QVariant>
+#include <QFileDialog>
+#include <QStandardPaths>
+#include <qt-wrappers.hpp>
+#include "item-widget-helpers.hpp"
+#include "window-basic-main.hpp"
+#include "window-importer.hpp"
+#include "window-namedialog.hpp"
+
+// MARK: Constant Expressions
+
+constexpr std::string_view OBSSceneCollectionPath = "/obs-studio/basic/scenes/";
+
+// MARK: - Anonymous Namespace
+namespace {
+QList<QString> sortedSceneCollections{};
+
+void updateSortedSceneCollections(const OBSSceneCollectionCache &collections)
+{
+	const QLocale locale = QLocale::system();
+	QList<QString> newList{};
+
+	for (auto [collectionName, _] : collections) {
+		QString entry = QString::fromStdString(collectionName);
+		newList.append(entry);
+	}
+
+	std::sort(newList.begin(), newList.end(), [&locale](const QString &lhs, const QString &rhs) -> bool {
+		int result = QString::localeAwareCompare(locale.toLower(lhs), locale.toLower(rhs));
+
+		return (result < 0);
+	});
+
+	sortedSceneCollections.swap(newList);
+}
+
+void cleanBackupCollision(const OBSSceneCollection &collection)
+{
+	std::filesystem::path backupFilePath = collection.collectionFile;
+	backupFilePath.replace_extension(".json.bak");
+
+	if (std::filesystem::exists(backupFilePath)) {
+		try {
+			std::filesystem::remove(backupFilePath);
+		} catch (std::filesystem::filesystem_error &) {
+			throw std::logic_error("Failed to remove pre-existing scene collection backup file: " +
+					       backupFilePath.u8string());
+		}
+	}
+}
+} // namespace
+
+// MARK: - Main Scene Collection Management Functions
+
+void OBSBasic::SetupNewSceneCollection(const std::string &collectionName)
+{
+	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+	cleanBackupCollision(newCollection);
+	ActivateSceneCollection(newCollection);
+
+	blog(LOG_INFO, "Created scene collection '%s' (clean, %s)", newCollection.name.c_str(),
+	     newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+void OBSBasic::SetupDuplicateSceneCollection(const std::string &collectionName)
+{
+	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
+	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
+
+	SaveProjectNow();
+
+	const auto copyOptions = std::filesystem::copy_options::overwrite_existing;
+
+	try {
+		std::filesystem::copy(currentCollection.collectionFile, newCollection.collectionFile, copyOptions);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error("Failed to copy file for cloned scene collection: " + newCollection.name);
+	}
+
+	OBSDataAutoRelease collection = obs_data_create_from_json_file(newCollection.collectionFile.u8string().c_str());
+
+	obs_data_set_string(collection, "name", newCollection.name.c_str());
+
+	OBSDataArrayAutoRelease sources = obs_data_get_array(collection, "sources");
+
+	if (sources) {
+		obs_data_erase(collection, "sources");
+
+		obs_data_array_enum(
+			sources,
+			[](obs_data_t *data, void *) -> void {
+				const char *uuid = os_generate_uuid();
+
+				obs_data_set_string(data, "uuid", uuid);
+
+				bfree((void *)uuid);
+			},
+			nullptr);
+
+		obs_data_set_array(collection, "sources", sources);
+	}
+
+	obs_data_save_json_safe(collection, newCollection.collectionFile.u8string().c_str(), "tmp", nullptr);
+
+	cleanBackupCollision(newCollection);
+	ActivateSceneCollection(newCollection);
+
+	blog(LOG_INFO, "Created scene collection '%s' (duplicate, %s)", newCollection.name.c_str(),
+	     newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+void OBSBasic::SetupRenameSceneCollection(const std::string &collectionName)
+{
+	const OBSSceneCollection &newCollection = CreateSceneCollection(collectionName);
+	const OBSSceneCollection currentCollection = GetCurrentSceneCollection();
+
+	SaveProjectNow();
+
+	const auto copyOptions = std::filesystem::copy_options::overwrite_existing;
+
+	try {
+		std::filesystem::copy(currentCollection.collectionFile, newCollection.collectionFile, copyOptions);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error("Failed to copy file for scene collection: " + currentCollection.name);
+	}
+
+	collections.erase(currentCollection.name);
+
+	OBSDataAutoRelease collection = obs_data_create_from_json_file(newCollection.collectionFile.u8string().c_str());
+
+	obs_data_set_string(collection, "name", newCollection.name.c_str());
+
+	obs_data_save_json_safe(collection, newCollection.collectionFile.u8string().c_str(), "tmp", nullptr);
+
+	cleanBackupCollision(newCollection);
+	ActivateSceneCollection(newCollection);
+	RemoveSceneCollection(currentCollection);
+
+	blog(LOG_INFO, "Renamed scene collection '%s' to '%s' (%s)", currentCollection.name.c_str(),
+	     newCollection.name.c_str(), newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_RENAMED);
+}
+
+// MARK: - Scene Collection File Management Functions
+
+const OBSSceneCollection &OBSBasic::CreateSceneCollection(const std::string &collectionName)
+{
+	if (const auto &foundCollection = GetSceneCollectionByName(collectionName)) {
+		throw std::invalid_argument("Scene collection already exists: " + collectionName);
+	}
+
+	std::string fileName;
+	if (!GetFileSafeName(collectionName.c_str(), fileName)) {
+		throw std::invalid_argument("Failed to create safe directory for new scene collection: " +
+					    collectionName);
+	}
+
+	std::string collectionFile;
+	collectionFile.reserve(App()->userScenesLocation.u8string().size() + OBSSceneCollectionPath.size() +
+			       fileName.size());
+	collectionFile.append(App()->userScenesLocation.u8string()).append(OBSSceneCollectionPath).append(fileName);
+
+	if (!GetClosestUnusedFileName(collectionFile, "json")) {
+		throw std::invalid_argument("Failed to get closest file name for new scene collection: " + fileName);
+	}
+
+	const std::filesystem::path collectionFilePath = std::filesystem::u8path(collectionFile);
+
+	auto [iterator, success] = collections.try_emplace(
+		collectionName,
+		OBSSceneCollection{collectionName, collectionFilePath.filename().u8string(), collectionFilePath});
+
+	return iterator->second;
+}
+
+void OBSBasic::RemoveSceneCollection(OBSSceneCollection collection)
+{
+	try {
+		std::filesystem::remove(collection.collectionFile);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error("Failed to remove scene collection file: " + collection.fileName);
+	}
+
+	blog(LOG_INFO, "Removed scene collection '%s' (%s)", collection.name.c_str(), collection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+// MARK: - Scene Collection UI Handling Functions
+
+bool OBSBasic::CreateNewSceneCollection(const QString &name)
+{
+	try {
+		SetupNewSceneCollection(name.toStdString());
+		return true;
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	}
+}
+
+bool OBSBasic::CreateDuplicateSceneCollection(const QString &name)
+{
+	try {
+		SetupDuplicateSceneCollection(name.toStdString());
+		return true;
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	}
+}
+
+void OBSBasic::DeleteSceneCollection(const QString &name)
+{
+	const std::string_view currentCollectionName{
+		config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
+
+	if (currentCollectionName == name.toStdString()) {
+		on_actionRemoveSceneCollection_triggered();
+		return;
+	}
+
+	OBSSceneCollection currentCollection = GetCurrentSceneCollection();
+
+	RemoveSceneCollection(currentCollection);
+
+	collections.erase(name.toStdString());
+
+	RefreshSceneCollections();
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
+}
+
+void OBSBasic::ChangeSceneCollection()
+{
+	QAction *action = reinterpret_cast<QAction *>(sender());
+
+	if (!action) {
+		return;
+	}
+
+	const std::string_view currentCollectionName{
+		config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
+	const QVariant qCollectionName = action->property("collection_name");
+	const std::string selectedCollectionName{qCollectionName.toString().toStdString()};
+
+	if (currentCollectionName == selectedCollectionName) {
+		action->setChecked(true);
+		return;
+	}
+
+	const std::optional<OBSSceneCollection> foundCollection = GetSceneCollectionByName(selectedCollectionName);
+
+	if (!foundCollection) {
+		const std::string errorMessage{"Selected scene collection not found: "};
+
+		throw std::invalid_argument(errorMessage + currentCollectionName.data());
+	}
+
+	const OBSSceneCollection &selectedCollection = foundCollection.value();
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+	ActivateSceneCollection(selectedCollection);
+
+	blog(LOG_INFO, "Switched to scene collection '%s' (%s)", selectedCollection.name.c_str(),
+	     selectedCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+void OBSBasic::RefreshSceneCollections(bool refreshCache)
+{
+	std::string_view currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
+
+	QList<QAction *> menuActions = ui->sceneCollectionMenu->actions();
+
+	for (auto &action : menuActions) {
+		QVariant variant = action->property("file_name");
+		if (variant.typeName() != nullptr) {
+			delete action;
+		}
+	}
+
+	if (refreshCache) {
+		RefreshSceneCollectionCache();
+	}
+
+	updateSortedSceneCollections(collections);
+
+	size_t numAddedCollections = 0;
+	for (auto &name : sortedSceneCollections) {
+		const std::string collectionName = name.toStdString();
+		try {
+			const OBSSceneCollection &collection = collections.at(collectionName);
+			const QString qCollectionName = QString().fromStdString(collectionName);
+
+			QAction *action = new QAction(qCollectionName, this);
+			action->setProperty("collection_name", qCollectionName);
+			action->setProperty("file_name", QString().fromStdString(collection.fileName));
+			connect(action, &QAction::triggered, this, &OBSBasic::ChangeSceneCollection);
+			action->setCheckable(true);
+			action->setChecked(collectionName == currentCollectionName);
+
+			ui->sceneCollectionMenu->addAction(action);
+
+			numAddedCollections += 1;
+		} catch (const std::out_of_range &error) {
+			blog(LOG_ERROR, "No scene collection with name %s found in scene collection cache.\n%s",
+			     collectionName.c_str(), error.what());
+		}
+	}
+
+	ui->actionRemoveSceneCollection->setEnabled(numAddedCollections > 1);
+
+	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	main->ui->actionPasteFilters->setEnabled(false);
+	main->ui->actionPasteRef->setEnabled(false);
+	main->ui->actionPasteDup->setEnabled(false);
+}
+
+// MARK: - Scene Collection Cache Functions
+
+void OBSBasic::RefreshSceneCollectionCache()
+{
+	OBSSceneCollectionCache foundCollections{};
+
+	const std::filesystem::path collectionsPath =
+		App()->userScenesLocation / std::filesystem::u8path(OBSSceneCollectionPath.substr(1));
+
+	if (!std::filesystem::exists(collectionsPath)) {
+		blog(LOG_WARNING, "Failed to get scene collections config path");
+		return;
+	}
+
+	for (const auto &entry : std::filesystem::directory_iterator(collectionsPath)) {
+		if (entry.is_directory()) {
+			continue;
+		}
+
+		if (entry.path().extension().u8string() != ".json") {
+			continue;
+		}
+
+		OBSDataAutoRelease collectionData =
+			obs_data_create_from_json_file_safe(entry.path().u8string().c_str(), "bak");
+
+		std::string candidateName;
+		const char *collectionName = obs_data_get_string(collectionData, "name");
+
+		if (!collectionName) {
+			candidateName = entry.path().filename().u8string();
+		} else {
+			candidateName = collectionName;
+		}
+
+		foundCollections.try_emplace(candidateName,
+					     OBSSceneCollection{candidateName, entry.path().filename().u8string(),
+								entry.path()});
+	}
+
+	collections.swap(foundCollections);
+}
+
+const OBSSceneCollection &OBSBasic::GetCurrentSceneCollection() const
+{
+	std::string currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
+
+	if (currentCollectionName.empty()) {
+		throw std::invalid_argument("No valid scene collection name in configuration Basic->SceneCollection");
+	}
+
+	const auto &foundCollection = collections.find(currentCollectionName);
+
+	if (foundCollection != collections.end()) {
+		return foundCollection->second;
+	} else {
+		throw std::invalid_argument("Scene collection not found in collection list: " + currentCollectionName);
+	}
+}
+
+std::optional<OBSSceneCollection> OBSBasic::GetSceneCollectionByName(const std::string &collectionName) const
+{
+	auto foundCollection = collections.find(collectionName);
+
+	if (foundCollection == collections.end()) {
+		return {};
+	} else {
+		return foundCollection->second;
+	}
+}
+
+std::optional<OBSSceneCollection> OBSBasic::GetSceneCollectionByFileName(const std::string &fileName) const
+{
+	for (auto &[iterator, collection] : collections) {
+		if (collection.fileName == fileName) {
+			return collection;
+		}
+	}
+
+	return {};
+}
+
+// MARK: - Qt Slot Functions
+
+void OBSBasic::on_actionNewSceneCollection_triggered()
+{
+	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
+		if (GetSceneCollectionByName(result.promptValue)) {
+			return false;
+		}
+
+		return true;
+	};
+
+	const OBSPromptRequest request{Str("Basic.Main.AddSceneCollection.Title"),
+				       Str("Basic.Main.AddSceneCollection.Text")};
+
+	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
+
+	if (!result.success) {
+		return;
+	}
+
+	try {
+		SetupNewSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+}
+
+void OBSBasic::on_actionDupSceneCollection_triggered()
+{
+	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
+		if (GetSceneCollectionByName(result.promptValue)) {
+			return false;
+		}
+
+		return true;
+	};
+
+	const OBSPromptRequest request{Str("Basic.Main.AddSceneCollection.Title"),
+				       Str("Basic.Main.AddSceneCollection.Text")};
+
+	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
+
+	if (!result.success) {
+		return;
+	}
+
+	try {
+		SetupDuplicateSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+}
+
+void OBSBasic::on_actionRenameSceneCollection_triggered()
+{
+	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
+
+	const OBSPromptCallback sceneCollectionCallback = [this](const OBSPromptResult &result) {
+		if (GetSceneCollectionByName(result.promptValue)) {
+			return false;
+		}
+
+		return true;
+	};
+
+	const OBSPromptRequest request{Str("Basic.Main.RenameSceneCollection.Title"),
+				       Str("Basic.Main.AddSceneCollection.Text"), currentCollection.name};
+
+	OBSPromptResult result = PromptForName(request, sceneCollectionCallback);
+
+	if (!result.success) {
+		return;
+	}
+
+	try {
+		SetupRenameSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+}
+
+void OBSBasic::on_actionRemoveSceneCollection_triggered(bool skipConfirmation)
+{
+	if (collections.size() < 2) {
+		return;
+	}
+
+	OBSSceneCollection currentCollection;
+
+	try {
+		currentCollection = GetCurrentSceneCollection();
+
+		if (!skipConfirmation) {
+			const QString confirmationText =
+				QTStr("ConfirmRemove.Text").arg(QString::fromStdString(currentCollection.name));
+			const QMessageBox::StandardButton button =
+				OBSMessageBox::question(this, QTStr("ConfirmRemove.Title"), confirmationText);
+
+			if (button == QMessageBox::No) {
+				return;
+			}
+		}
+
+		OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+		collections.erase(currentCollection.name);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+
+	const OBSSceneCollection &newCollection = collections.begin()->second;
+
+	ActivateSceneCollection(newCollection);
+	RemoveSceneCollection(currentCollection);
+
+	blog(LOG_INFO, "Switched to scene collection '%s' (%s)", newCollection.name.c_str(),
+	     newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+void OBSBasic::on_actionImportSceneCollection_triggered()
+{
+	OBSImporter imp(this);
+	imp.exec();
+
+	RefreshSceneCollections(true);
+}
+
+void OBSBasic::on_actionExportSceneCollection_triggered()
+{
+	SaveProjectNow();
+
+	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
+
+	const QString home = QDir::homePath();
+
+	const QString destinationFileName = SaveFile(this, QTStr("Basic.MainMenu.SceneCollection.Export"),
+						     home + "/" + currentCollection.fileName.c_str(),
+						     "JSON Files (*.json)");
+
+	if (!destinationFileName.isEmpty() && !destinationFileName.isNull()) {
+		const std::filesystem::path sourceFile = currentCollection.collectionFile;
+		const std::filesystem::path destinationFile =
+			std::filesystem::u8path(destinationFileName.toStdString());
+
+		OBSDataAutoRelease collection = obs_data_create_from_json_file(sourceFile.u8string().c_str());
+
+		OBSDataArrayAutoRelease sources = obs_data_get_array(collection, "sources");
+		if (!sources) {
+			blog(LOG_WARNING, "No sources in exported scene collection");
+			return;
+		}
+
+		obs_data_erase(collection, "sources");
+
+		using OBSDataVector = std::vector<OBSData>;
+
+		OBSDataVector sourceItems;
+		obs_data_array_enum(
+			sources,
+			[](obs_data_t *data, void *vector) -> void {
+				OBSDataVector &sourceItems{*static_cast<OBSDataVector *>(vector)};
+				sourceItems.push_back(data);
+			},
+			&sourceItems);
+
+		std::sort(sourceItems.begin(), sourceItems.end(), [](const OBSData &a, const OBSData &b) {
+			return astrcmpi(obs_data_get_string(a, "name"), obs_data_get_string(b, "name")) < 0;
+		});
+
+		OBSDataArrayAutoRelease newSources = obs_data_array_create();
+		for (auto &item : sourceItems) {
+			obs_data_array_push_back(newSources, item);
+		}
+
+		obs_data_set_array(collection, "sources", newSources);
+		obs_data_save_json_pretty_safe(collection, destinationFile.u8string().c_str(), "tmp", "bak");
+	}
+}
+
+void OBSBasic::on_actionRemigrateSceneCollection_triggered()
+{
+	if (Active()) {
+		OBSMessageBox::warning(this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
+				       QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.Active"));
+		return;
+	}
+
+	OBSDataAutoRelease priv = obs_get_private_data();
+
+	if (!usingAbsoluteCoordinates && !migrationBaseResolution) {
+		OBSMessageBox::warning(
+			this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
+			QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.UnknownBaseResolution"));
+		return;
+	}
+
+	obs_video_info ovi;
+	obs_get_video_info(&ovi);
+
+	if (!usingAbsoluteCoordinates && migrationBaseResolution->first == ovi.base_width &&
+	    migrationBaseResolution->second == ovi.base_height) {
+		OBSMessageBox::warning(
+			this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
+			QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.BaseResolutionMatches"));
+		return;
+	}
+
+	const OBSSceneCollection &currentCollection = GetCurrentSceneCollection();
+
+	QString name = QString::fromStdString(currentCollection.name);
+	QString message =
+		QTStr("Basic.Main.RemigrateSceneCollection.Text").arg(name).arg(ovi.base_width).arg(ovi.base_height);
+
+	auto answer = OBSMessageBox::question(this, QTStr("Basic.Main.RemigrateSceneCollection.Title"), message);
+
+	if (answer == QMessageBox::No)
+		return;
+
+	lastOutputResolution = {ovi.base_width, ovi.base_height};
+
+	if (!usingAbsoluteCoordinates) {
+		/* Temporarily change resolution to migration resolution */
+		ovi.base_width = migrationBaseResolution->first;
+		ovi.base_height = migrationBaseResolution->second;
+
+		if (obs_reset_video(&ovi) != OBS_VIDEO_SUCCESS) {
+			OBSMessageBox::critical(
+				this, QTStr("Basic.Main.RemigrateSceneCollection.Title"),
+				QTStr("Basic.Main.RemigrateSceneCollection.CannotMigrate.FailedVideoReset"));
+			return;
+		}
+	}
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+	/* Save and immediately reload to (re-)run migrations. */
+	SaveProjectNow();
+	/* Reset video if we potentially changed to a temporary resolution */
+	if (!usingAbsoluteCoordinates) {
+		ResetVideo();
+	}
+
+	ActivateSceneCollection(currentCollection);
+}
+
+// MARK: - Scene Collection Management Helper Functions
+
+void OBSBasic::ActivateSceneCollection(const OBSSceneCollection &collection)
+{
+	const std::string currentCollectionName{config_get_string(App()->GetUserConfig(), "Basic", "SceneCollection")};
+
+	if (auto foundCollection = GetSceneCollectionByName(currentCollectionName)) {
+		if (collection.name != foundCollection.value().name) {
+			SaveProjectNow();
+		}
+	}
+
+	config_set_string(App()->GetUserConfig(), "Basic", "SceneCollection", collection.name.c_str());
+	config_set_string(App()->GetUserConfig(), "Basic", "SceneCollectionFile", collection.fileName.c_str());
+
+	Load(collection.collectionFile.u8string().c_str());
+
+	RefreshSceneCollections();
+
+	UpdateTitleBar();
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
+}
 /******************************************************************************
     Copyright (C) 2023 by Lain Bailey <[email protected]>
                           Zachary Lund <[email protected]>

+ 1699 - 0
frontend/widgets/OBSBasic_StudioMode.cpp

@@ -1,3 +1,1702 @@
+/******************************************************************************
+    Copyright (C) 2023 by Lain Bailey <[email protected]>
+
+    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 <QSpinBox>
+#include <QWidgetAction>
+#include <QToolTip>
+#include <QMessageBox>
+#include <util/dstr.hpp>
+#include <qt-wrappers.hpp>
+#include <slider-ignorewheel.hpp>
+#include "window-basic-main.hpp"
+#include "window-basic-main-outputs.hpp"
+#include "window-basic-vcam-config.hpp"
+#include "display-helpers.hpp"
+#include "window-namedialog.hpp"
+#include "menu-button.hpp"
+
+#include "obs-hotkey.h"
+
+using namespace std;
+
+Q_DECLARE_METATYPE(OBSScene);
+Q_DECLARE_METATYPE(OBSSource);
+Q_DECLARE_METATYPE(QuickTransition);
+
+static inline QString MakeQuickTransitionText(QuickTransition *qt)
+{
+	QString name;
+
+	if (!qt->fadeToBlack)
+		name = QT_UTF8(obs_source_get_name(qt->source));
+	else
+		name = QTStr("FadeToBlack");
+
+	if (!obs_transition_fixed(qt->source))
+		name += QString(" (%1ms)").arg(QString::number(qt->duration));
+	return name;
+}
+
+void OBSBasic::InitDefaultTransitions()
+{
+	std::vector<OBSSource> transitions;
+	size_t idx = 0;
+	const char *id;
+
+	/* automatically add transitions that have no configuration (things
+	 * such as cut/fade/etc) */
+	while (obs_enum_transition_types(idx++, &id)) {
+		if (!obs_is_source_configurable(id)) {
+			const char *name = obs_source_get_display_name(id);
+
+			OBSSourceAutoRelease tr = obs_source_create_private(id, name, NULL);
+			InitTransition(tr);
+			transitions.emplace_back(tr);
+
+			if (strcmp(id, "fade_transition") == 0)
+				fadeTransition = tr;
+			else if (strcmp(id, "cut_transition") == 0)
+				cutTransition = tr;
+		}
+	}
+
+	for (OBSSource &tr : transitions) {
+		ui->transitions->addItem(QT_UTF8(obs_source_get_name(tr)), QVariant::fromValue(OBSSource(tr)));
+	}
+}
+
+void OBSBasic::AddQuickTransitionHotkey(QuickTransition *qt)
+{
+	DStr hotkeyId;
+	QString hotkeyName;
+
+	dstr_printf(hotkeyId, "OBSBasic.QuickTransition.%d", qt->id);
+	hotkeyName = QTStr("QuickTransitions.HotkeyName").arg(MakeQuickTransitionText(qt));
+
+	auto quickTransition = [](void *data, obs_hotkey_id, obs_hotkey_t *, bool pressed) {
+		int id = (int)(uintptr_t)data;
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		if (pressed)
+			QMetaObject::invokeMethod(main, "TriggerQuickTransition", Qt::QueuedConnection, Q_ARG(int, id));
+	};
+
+	qt->hotkey = obs_hotkey_register_frontend(hotkeyId->array, QT_TO_UTF8(hotkeyName), quickTransition,
+						  (void *)(uintptr_t)qt->id);
+}
+
+void QuickTransition::SourceRenamed(void *param, calldata_t *)
+{
+	QuickTransition *qt = reinterpret_cast<QuickTransition *>(param);
+
+	QString hotkeyName = QTStr("QuickTransitions.HotkeyName").arg(MakeQuickTransitionText(qt));
+
+	obs_hotkey_set_description(qt->hotkey, QT_TO_UTF8(hotkeyName));
+}
+
+void OBSBasic::TriggerQuickTransition(int id)
+{
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt && previewProgramMode) {
+		OBSScene scene = GetCurrentScene();
+		obs_source_t *source = obs_scene_get_source(scene);
+
+		if (GetCurrentTransition() != qt->source) {
+			OverrideTransition(qt->source);
+			overridingTransition = true;
+		}
+
+		TransitionToScene(source, false, true, qt->duration, qt->fadeToBlack);
+	}
+}
+
+void OBSBasic::RemoveQuickTransitionHotkey(QuickTransition *qt)
+{
+	obs_hotkey_unregister(qt->hotkey);
+}
+
+void OBSBasic::InitTransition(obs_source_t *transition)
+{
+	auto onTransitionStop = [](void *data, calldata_t *) {
+		OBSBasic *window = (OBSBasic *)data;
+		QMetaObject::invokeMethod(window, "TransitionStopped", Qt::QueuedConnection);
+	};
+
+	auto onTransitionFullStop = [](void *data, calldata_t *) {
+		OBSBasic *window = (OBSBasic *)data;
+		QMetaObject::invokeMethod(window, "TransitionFullyStopped", Qt::QueuedConnection);
+	};
+
+	signal_handler_t *handler = obs_source_get_signal_handler(transition);
+	signal_handler_connect(handler, "transition_video_stop", onTransitionStop, this);
+	signal_handler_connect(handler, "transition_stop", onTransitionFullStop, this);
+}
+
+static inline OBSSource GetTransitionComboItem(QComboBox *combo, int idx)
+{
+	return combo->itemData(idx).value<OBSSource>();
+}
+
+void OBSBasic::CreateDefaultQuickTransitions()
+{
+	/* non-configurable transitions are always available, so add them
+	 * to the "default quick transitions" list */
+	quickTransitions.emplace_back(cutTransition, 300, quickTransitionIdCounter++);
+	quickTransitions.emplace_back(fadeTransition, 300, quickTransitionIdCounter++);
+	quickTransitions.emplace_back(fadeTransition, 300, quickTransitionIdCounter++, true);
+}
+
+void OBSBasic::LoadQuickTransitions(obs_data_array_t *array)
+{
+	size_t count = obs_data_array_count(array);
+
+	quickTransitionIdCounter = 1;
+
+	for (size_t i = 0; i < count; i++) {
+		OBSDataAutoRelease data = obs_data_array_item(array, i);
+		OBSDataArrayAutoRelease hotkeys = obs_data_get_array(data, "hotkeys");
+		const char *name = obs_data_get_string(data, "name");
+		int duration = obs_data_get_int(data, "duration");
+		int id = obs_data_get_int(data, "id");
+		bool toBlack = obs_data_get_bool(data, "fade_to_black");
+
+		if (id) {
+			obs_source_t *source = FindTransition(name);
+			if (source) {
+				quickTransitions.emplace_back(source, duration, id, toBlack);
+
+				if (quickTransitionIdCounter <= id)
+					quickTransitionIdCounter = id + 1;
+
+				int idx = (int)quickTransitions.size() - 1;
+				AddQuickTransitionHotkey(&quickTransitions[idx]);
+				obs_hotkey_load(quickTransitions[idx].hotkey, hotkeys);
+			}
+		}
+	}
+}
+
+obs_data_array_t *OBSBasic::SaveQuickTransitions()
+{
+	obs_data_array_t *array = obs_data_array_create();
+
+	for (QuickTransition &qt : quickTransitions) {
+		OBSDataAutoRelease data = obs_data_create();
+		OBSDataArrayAutoRelease hotkeys = obs_hotkey_save(qt.hotkey);
+
+		obs_data_set_string(data, "name", obs_source_get_name(qt.source));
+		obs_data_set_int(data, "duration", qt.duration);
+		obs_data_set_array(data, "hotkeys", hotkeys);
+		obs_data_set_int(data, "id", qt.id);
+		obs_data_set_bool(data, "fade_to_black", qt.fadeToBlack);
+
+		obs_data_array_push_back(array, data);
+	}
+
+	return array;
+}
+
+obs_source_t *OBSBasic::FindTransition(const char *name)
+{
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		OBSSource tr = ui->transitions->itemData(i).value<OBSSource>();
+		if (!tr)
+			continue;
+
+		const char *trName = obs_source_get_name(tr);
+		if (strcmp(trName, name) == 0)
+			return tr;
+	}
+
+	return nullptr;
+}
+
+void OBSBasic::TransitionToScene(OBSScene scene, bool force)
+{
+	obs_source_t *source = obs_scene_get_source(scene);
+	TransitionToScene(source, force);
+}
+
+void OBSBasic::TransitionStopped()
+{
+	if (swapScenesMode) {
+		OBSSource scene = OBSGetStrongRef(swapScene);
+		if (scene)
+			SetCurrentScene(scene);
+	}
+
+	EnableTransitionWidgets(true);
+	UpdatePreviewProgramIndicators();
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_STOPPED);
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_CHANGED);
+
+	swapScene = nullptr;
+}
+
+void OBSBasic::OverrideTransition(OBSSource transition)
+{
+	OBSSourceAutoRelease oldTransition = obs_get_output_source(0);
+
+	if (transition != oldTransition) {
+		obs_transition_swap_begin(transition, oldTransition);
+		obs_set_output_source(0, transition);
+		obs_transition_swap_end(transition, oldTransition);
+	}
+}
+
+void OBSBasic::TransitionFullyStopped()
+{
+	if (overridingTransition) {
+		OverrideTransition(GetCurrentTransition());
+		overridingTransition = false;
+	}
+}
+
+void OBSBasic::TransitionToScene(OBSSource source, bool force, bool quickTransition, int quickDuration, bool black,
+				 bool manual)
+{
+	obs_scene_t *scene = obs_scene_from_source(source);
+	bool usingPreviewProgram = IsPreviewProgramMode();
+	if (!scene)
+		return;
+
+	if (usingPreviewProgram) {
+		if (!tBarActive)
+			lastProgramScene = programScene;
+		programScene = OBSGetWeakRef(source);
+
+		if (!force && !black) {
+			OBSSource lastScene = OBSGetStrongRef(lastProgramScene);
+
+			if (!sceneDuplicationMode && lastScene == source)
+				return;
+
+			if (swapScenesMode && lastScene && lastScene != GetCurrentSceneSource())
+				swapScene = lastProgramScene;
+		}
+	}
+
+	if (usingPreviewProgram && sceneDuplicationMode) {
+		scene = obs_scene_duplicate(scene, obs_source_get_name(obs_scene_get_source(scene)),
+					    editPropertiesMode ? OBS_SCENE_DUP_PRIVATE_COPY
+							       : OBS_SCENE_DUP_PRIVATE_REFS);
+		source = obs_scene_get_source(scene);
+	}
+
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+	if (!transition) {
+		if (usingPreviewProgram && sceneDuplicationMode)
+			obs_scene_release(scene);
+		return;
+	}
+
+	float t = obs_transition_get_time(transition);
+	bool stillTransitioning = t < 1.0f && t > 0.0f;
+
+	// If actively transitioning, block new transitions from starting
+	if (usingPreviewProgram && stillTransitioning)
+		goto cleanup;
+
+	if (usingPreviewProgram) {
+		if (!black && !manual) {
+			const char *sceneName = obs_source_get_name(source);
+			blog(LOG_INFO, "User switched Program to scene '%s'", sceneName);
+
+		} else if (black && !prevFTBSource) {
+			OBSSourceAutoRelease target = obs_transition_get_active_source(transition);
+			const char *sceneName = obs_source_get_name(target);
+			blog(LOG_INFO, "User faded from scene '%s' to black", sceneName);
+
+		} else if (black && prevFTBSource) {
+			const char *sceneName = obs_source_get_name(prevFTBSource);
+			blog(LOG_INFO, "User faded from black to scene '%s'", sceneName);
+
+		} else if (manual) {
+			const char *sceneName = obs_source_get_name(source);
+			blog(LOG_INFO, "User started manual transition to scene '%s'", sceneName);
+		}
+	}
+
+	if (force) {
+		obs_transition_set(transition, source);
+		OnEvent(OBS_FRONTEND_EVENT_SCENE_CHANGED);
+	} else {
+		int duration = ui->transitionDuration->value();
+
+		/* check for scene override */
+		OBSSource trOverride = GetOverrideTransition(source);
+
+		if (trOverride && !overridingTransition && !quickTransition) {
+			transition = std::move(trOverride);
+			duration = GetOverrideTransitionDuration(source);
+			OverrideTransition(transition.Get());
+			overridingTransition = true;
+		}
+
+		if (black && !prevFTBSource) {
+			prevFTBSource = source;
+			source = nullptr;
+		} else if (black && prevFTBSource) {
+			source = prevFTBSource;
+			prevFTBSource = nullptr;
+		} else if (!black) {
+			prevFTBSource = nullptr;
+		}
+
+		if (quickTransition)
+			duration = quickDuration;
+
+		enum obs_transition_mode mode = manual ? OBS_TRANSITION_MODE_MANUAL : OBS_TRANSITION_MODE_AUTO;
+
+		EnableTransitionWidgets(false);
+
+		bool success = obs_transition_start(transition, mode, duration, source);
+
+		if (!success)
+			TransitionFullyStopped();
+	}
+
+cleanup:
+	if (usingPreviewProgram && sceneDuplicationMode)
+		obs_scene_release(scene);
+}
+
+static inline void SetComboTransition(QComboBox *combo, obs_source_t *tr)
+{
+	int idx = combo->findData(QVariant::fromValue<OBSSource>(tr));
+	if (idx != -1) {
+		combo->blockSignals(true);
+		combo->setCurrentIndex(idx);
+		combo->blockSignals(false);
+	}
+}
+
+void OBSBasic::SetTransition(OBSSource transition)
+{
+	OBSSourceAutoRelease oldTransition = obs_get_output_source(0);
+
+	if (oldTransition && transition) {
+		obs_transition_swap_begin(transition, oldTransition);
+		if (transition != GetCurrentTransition())
+			SetComboTransition(ui->transitions, transition);
+		obs_set_output_source(0, transition);
+		obs_transition_swap_end(transition, oldTransition);
+	} else {
+		obs_set_output_source(0, transition);
+	}
+
+	bool fixed = transition ? obs_transition_fixed(transition) : false;
+	ui->transitionDurationLabel->setVisible(!fixed);
+	ui->transitionDuration->setVisible(!fixed);
+
+	bool configurable = transition ? obs_source_configurable(transition) : false;
+	ui->transitionRemove->setEnabled(configurable);
+	ui->transitionProps->setEnabled(configurable);
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_CHANGED);
+}
+
+OBSSource OBSBasic::GetCurrentTransition()
+{
+	return ui->transitions->currentData().value<OBSSource>();
+}
+
+void OBSBasic::on_transitions_currentIndexChanged(int)
+{
+	OBSSource transition = GetCurrentTransition();
+	SetTransition(transition);
+}
+
+void OBSBasic::AddTransition(const char *id)
+{
+	string name;
+	QString placeHolderText = QT_UTF8(obs_source_get_display_name(id));
+	QString format = placeHolderText + " (%1)";
+	obs_source_t *source = nullptr;
+	int i = 1;
+
+	while ((FindTransition(QT_TO_UTF8(placeHolderText)))) {
+		placeHolderText = format.arg(++i);
+	}
+
+	bool accepted = NameDialog::AskForName(this, QTStr("TransitionNameDlg.Title"), QTStr("TransitionNameDlg.Text"),
+					       name, placeHolderText);
+
+	if (accepted) {
+		if (name.empty()) {
+			OBSMessageBox::warning(this, QTStr("NoNameEntered.Title"), QTStr("NoNameEntered.Text"));
+			AddTransition(id);
+			return;
+		}
+
+		source = FindTransition(name.c_str());
+		if (source) {
+			OBSMessageBox::warning(this, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+
+			AddTransition(id);
+			return;
+		}
+
+		source = obs_source_create_private(id, name.c_str(), NULL);
+		InitTransition(source);
+		ui->transitions->addItem(QT_UTF8(name.c_str()), QVariant::fromValue(OBSSource(source)));
+		ui->transitions->setCurrentIndex(ui->transitions->count() - 1);
+		CreatePropertiesWindow(source);
+		obs_source_release(source);
+
+		OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+		ClearQuickTransitionWidgets();
+		RefreshQuickTransitions();
+	}
+}
+
+void OBSBasic::on_transitionAdd_clicked()
+{
+	bool foundConfigurableTransitions = false;
+	QMenu menu(this);
+	size_t idx = 0;
+	const char *id;
+
+	while (obs_enum_transition_types(idx++, &id)) {
+		if (obs_is_source_configurable(id)) {
+			const char *name = obs_source_get_display_name(id);
+			QAction *action = new QAction(name, this);
+
+			connect(action, &QAction::triggered, [this, id]() { AddTransition(id); });
+
+			menu.addAction(action);
+			foundConfigurableTransitions = true;
+		}
+	}
+
+	if (foundConfigurableTransitions)
+		menu.exec(QCursor::pos());
+}
+
+void OBSBasic::on_transitionRemove_clicked()
+{
+	OBSSource tr = GetCurrentTransition();
+
+	if (!tr || !obs_source_configurable(tr) || !QueryRemoveSource(tr))
+		return;
+
+	int idx = ui->transitions->findData(QVariant::fromValue<OBSSource>(tr));
+	if (idx == -1)
+		return;
+
+	for (size_t i = quickTransitions.size(); i > 0; i--) {
+		QuickTransition &qt = quickTransitions[i - 1];
+		if (qt.source == tr) {
+			if (qt.button)
+				qt.button->deleteLater();
+			RemoveQuickTransitionHotkey(&qt);
+			quickTransitions.erase(quickTransitions.begin() + i - 1);
+		}
+	}
+
+	ui->transitions->removeItem(idx);
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+	ClearQuickTransitionWidgets();
+	RefreshQuickTransitions();
+}
+
+void OBSBasic::RenameTransition(OBSSource transition)
+{
+	string name;
+	QString placeHolderText = QT_UTF8(obs_source_get_name(transition));
+	obs_source_t *source = nullptr;
+
+	bool accepted = NameDialog::AskForName(this, QTStr("TransitionNameDlg.Title"), QTStr("TransitionNameDlg.Text"),
+					       name, placeHolderText);
+
+	if (!accepted)
+		return;
+	if (name.empty()) {
+		OBSMessageBox::warning(this, QTStr("NoNameEntered.Title"), QTStr("NoNameEntered.Text"));
+		RenameTransition(transition);
+		return;
+	}
+
+	source = FindTransition(name.c_str());
+	if (source) {
+		OBSMessageBox::warning(this, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+
+		RenameTransition(transition);
+		return;
+	}
+
+	obs_source_set_name(transition, name.c_str());
+	int idx = ui->transitions->findData(QVariant::fromValue(transition));
+	if (idx != -1) {
+		ui->transitions->setItemText(idx, QT_UTF8(name.c_str()));
+
+		OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+		ClearQuickTransitionWidgets();
+		RefreshQuickTransitions();
+	}
+}
+
+void OBSBasic::on_transitionProps_clicked()
+{
+	OBSSource source = GetCurrentTransition();
+
+	if (!obs_source_configurable(source))
+		return;
+
+	auto properties = [&]() {
+		CreatePropertiesWindow(source);
+	};
+
+	QMenu menu(this);
+
+	QAction *action = new QAction(QTStr("Rename"), &menu);
+	connect(action, &QAction::triggered, [this, source]() { RenameTransition(source); });
+	menu.addAction(action);
+
+	action = new QAction(QTStr("Properties"), &menu);
+	connect(action, &QAction::triggered, properties);
+	menu.addAction(action);
+
+	menu.exec(QCursor::pos());
+}
+
+void OBSBasic::on_transitionDuration_valueChanged()
+{
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_DURATION_CHANGED);
+}
+
+QuickTransition *OBSBasic::GetQuickTransition(int id)
+{
+	for (QuickTransition &qt : quickTransitions) {
+		if (qt.id == id)
+			return &qt;
+	}
+
+	return nullptr;
+}
+
+int OBSBasic::GetQuickTransitionIdx(int id)
+{
+	for (int idx = 0; idx < (int)quickTransitions.size(); idx++) {
+		QuickTransition &qt = quickTransitions[idx];
+
+		if (qt.id == id)
+			return idx;
+	}
+
+	return -1;
+}
+
+void OBSBasic::SetCurrentScene(obs_scene_t *scene, bool force)
+{
+	obs_source_t *source = obs_scene_get_source(scene);
+	SetCurrentScene(source, force);
+}
+
+template<typename T> static T GetOBSRef(QListWidgetItem *item)
+{
+	return item->data(static_cast<int>(QtDataRole::OBSRef)).value<T>();
+}
+
+void OBSBasic::SetCurrentScene(OBSSource scene, bool force)
+{
+	if (!IsPreviewProgramMode()) {
+		TransitionToScene(scene, force);
+	} else {
+		OBSSource actualLastScene = OBSGetStrongRef(lastScene);
+		if (actualLastScene != scene) {
+			if (scene)
+				obs_source_inc_showing(scene);
+			if (actualLastScene)
+				obs_source_dec_showing(actualLastScene);
+			lastScene = OBSGetWeakRef(scene);
+		}
+	}
+
+	if (obs_scene_get_source(GetCurrentScene()) != scene) {
+		for (int i = 0; i < ui->scenes->count(); i++) {
+			QListWidgetItem *item = ui->scenes->item(i);
+			OBSScene itemScene = GetOBSRef<OBSScene>(item);
+			obs_source_t *source = obs_scene_get_source(itemScene);
+
+			if (source == scene) {
+				ui->scenes->blockSignals(true);
+				currentScene = itemScene.Get();
+				ui->scenes->setCurrentItem(item);
+				ui->scenes->blockSignals(false);
+
+				if (vcamEnabled && vcamConfig.type == VCamOutputType::PreviewOutput)
+					outputHandler->UpdateVirtualCamOutputSource();
+
+				OnEvent(OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED);
+				break;
+			}
+		}
+	}
+
+	UpdateContextBar(true);
+	UpdatePreviewProgramIndicators();
+
+	if (scene) {
+		bool userSwitched = (!force && !disableSaving);
+		blog(LOG_INFO, "%s to scene '%s'", userSwitched ? "User switched" : "Switched",
+		     obs_source_get_name(scene));
+	}
+}
+
+void OBSBasic::CreateProgramDisplay()
+{
+	program = new OBSQTDisplay();
+
+	program->setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(program.data(), &QWidget::customContextMenuRequested, this, &OBSBasic::ProgramViewContextMenuRequested);
+
+	auto displayResize = [this]() {
+		struct obs_video_info ovi;
+
+		if (obs_get_video_info(&ovi))
+			ResizeProgram(ovi.base_width, ovi.base_height);
+	};
+
+	connect(program.data(), &OBSQTDisplay::DisplayResized, displayResize);
+
+	auto addDisplay = [this](OBSQTDisplay *window) {
+		obs_display_add_draw_callback(window->GetDisplay(), OBSBasic::RenderProgram, this);
+
+		struct obs_video_info ovi;
+		if (obs_get_video_info(&ovi))
+			ResizeProgram(ovi.base_width, ovi.base_height);
+	};
+
+	connect(program.data(), &OBSQTDisplay::DisplayCreated, addDisplay);
+
+	program->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+}
+
+void OBSBasic::TransitionClicked()
+{
+	if (previewProgramMode)
+		TransitionToScene(GetCurrentScene());
+}
+
+#define T_BAR_PRECISION 1024
+#define T_BAR_PRECISION_F ((float)T_BAR_PRECISION)
+#define T_BAR_CLAMP (T_BAR_PRECISION / 10)
+
+void OBSBasic::CreateProgramOptions()
+{
+	programOptions = new QWidget();
+	QVBoxLayout *layout = new QVBoxLayout();
+	layout->setSpacing(4);
+
+	QPushButton *configTransitions = new QPushButton();
+	configTransitions->setProperty("class", "icon-dots-vert");
+
+	QHBoxLayout *mainButtonLayout = new QHBoxLayout();
+	mainButtonLayout->setSpacing(2);
+
+	transitionButton = new QPushButton(QTStr("Transition"));
+	transitionButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	QHBoxLayout *quickTransitions = new QHBoxLayout();
+	quickTransitions->setSpacing(2);
+
+	QPushButton *addQuickTransition = new QPushButton();
+	addQuickTransition->setProperty("class", "icon-plus");
+
+	QLabel *quickTransitionsLabel = new QLabel(QTStr("QuickTransitions"));
+	quickTransitionsLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	quickTransitions->addWidget(quickTransitionsLabel);
+	quickTransitions->addWidget(addQuickTransition);
+
+	mainButtonLayout->addWidget(transitionButton);
+	mainButtonLayout->addWidget(configTransitions);
+
+	tBar = new SliderIgnoreClick(Qt::Horizontal);
+	tBar->setMinimum(0);
+	tBar->setMaximum(T_BAR_PRECISION - 1);
+
+	tBar->setProperty("class", "slider-tbar");
+
+	connect(tBar, &QSlider::valueChanged, this, &OBSBasic::TBarChanged);
+	connect(tBar, &QSlider::sliderReleased, this, &OBSBasic::TBarReleased);
+
+	layout->addStretch(0);
+	layout->addLayout(mainButtonLayout);
+	layout->addLayout(quickTransitions);
+	layout->addWidget(tBar);
+	layout->addStretch(0);
+
+	programOptions->setLayout(layout);
+
+	auto onAdd = [this]() {
+		QScopedPointer<QMenu> menu(CreateTransitionMenu(this, nullptr));
+		menu->exec(QCursor::pos());
+	};
+
+	auto onConfig = [this]() {
+		QMenu menu(this);
+		QAction *action;
+
+		auto toggleEditProperties = [this]() {
+			editPropertiesMode = !editPropertiesMode;
+
+			OBSSource actualScene = OBSGetStrongRef(programScene);
+			if (actualScene)
+				TransitionToScene(actualScene, true);
+		};
+
+		auto toggleSwapScenesMode = [this]() {
+			swapScenesMode = !swapScenesMode;
+		};
+
+		auto toggleSceneDuplication = [this]() {
+			sceneDuplicationMode = !sceneDuplicationMode;
+
+			OBSSource actualScene = OBSGetStrongRef(programScene);
+			if (actualScene)
+				TransitionToScene(actualScene, true);
+		};
+
+		auto showToolTip = [&]() {
+			QAction *act = menu.activeAction();
+			QToolTip::showText(QCursor::pos(), act->toolTip(), &menu, menu.actionGeometry(act));
+		};
+
+		action = menu.addAction(QTStr("QuickTransitions.DuplicateScene"));
+		action->setToolTip(QTStr("QuickTransitions.DuplicateSceneTT"));
+		action->setCheckable(true);
+		action->setChecked(sceneDuplicationMode);
+		connect(action, &QAction::triggered, toggleSceneDuplication);
+		connect(action, &QAction::hovered, showToolTip);
+
+		action = menu.addAction(QTStr("QuickTransitions.EditProperties"));
+		action->setToolTip(QTStr("QuickTransitions.EditPropertiesTT"));
+		action->setCheckable(true);
+		action->setChecked(editPropertiesMode);
+		action->setEnabled(sceneDuplicationMode);
+		connect(action, &QAction::triggered, toggleEditProperties);
+		connect(action, &QAction::hovered, showToolTip);
+
+		action = menu.addAction(QTStr("QuickTransitions.SwapScenes"));
+		action->setToolTip(QTStr("QuickTransitions.SwapScenesTT"));
+		action->setCheckable(true);
+		action->setChecked(swapScenesMode);
+		connect(action, &QAction::triggered, toggleSwapScenesMode);
+		connect(action, &QAction::hovered, showToolTip);
+
+		menu.exec(QCursor::pos());
+	};
+
+	connect(transitionButton.data(), &QAbstractButton::clicked, this, &OBSBasic::TransitionClicked);
+	connect(addQuickTransition, &QAbstractButton::clicked, onAdd);
+	connect(configTransitions, &QAbstractButton::clicked, onConfig);
+}
+
+void OBSBasic::TBarReleased()
+{
+	int val = tBar->value();
+
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+
+	if ((tBar->maximum() - val) <= T_BAR_CLAMP) {
+		obs_transition_set_manual_time(transition, 1.0f);
+		tBar->blockSignals(true);
+		tBar->setValue(0);
+		tBar->blockSignals(false);
+		tBarActive = false;
+		EnableTransitionWidgets(true);
+
+		OBSSourceAutoRelease target = obs_transition_get_active_source(transition);
+		const char *sceneName = obs_source_get_name(target);
+		blog(LOG_INFO, "Manual transition to scene '%s' finished", sceneName);
+	} else if (val <= T_BAR_CLAMP) {
+		obs_transition_set_manual_time(transition, 0.0f);
+		TransitionFullyStopped();
+		tBar->blockSignals(true);
+		tBar->setValue(0);
+		tBar->blockSignals(false);
+		tBarActive = false;
+		EnableTransitionWidgets(true);
+		programScene = lastProgramScene;
+		blog(LOG_INFO, "Manual transition cancelled");
+	}
+
+	tBar->clearFocus();
+}
+
+static bool ValidTBarTransition(OBSSource transition)
+{
+	if (!transition)
+		return false;
+
+	QString id = QT_UTF8(obs_source_get_id(transition));
+
+	if (id == "cut_transition" || id == "obs_stinger_transition")
+		return false;
+
+	return true;
+}
+
+void OBSBasic::TBarChanged(int value)
+{
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+
+	if (!tBarActive) {
+		OBSSource sceneSource = GetCurrentSceneSource();
+		OBSSource tBarTr = GetOverrideTransition(sceneSource);
+
+		if (!ValidTBarTransition(tBarTr)) {
+			tBarTr = GetCurrentTransition();
+
+			if (!ValidTBarTransition(tBarTr))
+				tBarTr = FindTransition(obs_source_get_display_name("fade_transition"));
+
+			OverrideTransition(tBarTr);
+			overridingTransition = true;
+
+			transition = std::move(tBarTr);
+		}
+
+		obs_transition_set_manual_torque(transition, 8.0f, 0.05f);
+		TransitionToScene(sceneSource, false, false, false, 0, true);
+		tBarActive = true;
+	}
+
+	obs_transition_set_manual_time(transition, (float)value / T_BAR_PRECISION_F);
+
+	OnEvent(OBS_FRONTEND_EVENT_TBAR_VALUE_CHANGED);
+}
+
+int OBSBasic::GetTbarPosition()
+{
+	return tBar->value();
+}
+
+void OBSBasic::TogglePreviewProgramMode()
+{
+	SetPreviewProgramMode(!IsPreviewProgramMode());
+}
+
+static inline void ResetQuickTransitionText(QuickTransition *qt)
+{
+	qt->button->setText(MakeQuickTransitionText(qt));
+}
+
+QMenu *OBSBasic::CreatePerSceneTransitionMenu()
+{
+	OBSSource scene = GetCurrentSceneSource();
+	QMenu *menu = new QMenu(QTStr("TransitionOverride"));
+	QAction *action;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+	obs_data_set_default_int(data, "transition_duration", 300);
+
+	const char *curTransition = obs_data_get_string(data, "transition");
+	int curDuration = (int)obs_data_get_int(data, "transition_duration");
+
+	QSpinBox *duration = new QSpinBox(menu);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(curDuration);
+
+	auto setTransition = [this](QAction *action) {
+		int idx = action->property("transition_index").toInt();
+		OBSSource scene = GetCurrentSceneSource();
+		OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+		if (idx == -1) {
+			obs_data_set_string(data, "transition", "");
+			return;
+		}
+
+		OBSSource tr = GetTransitionComboItem(ui->transitions, idx);
+
+		if (tr) {
+			const char *name = obs_source_get_name(tr);
+			obs_data_set_string(data, "transition", name);
+		}
+	};
+
+	auto setDuration = [this](int duration) {
+		OBSSource scene = GetCurrentSceneSource();
+		OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+		obs_data_set_int(data, "transition_duration", duration);
+	};
+
+	connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, setDuration);
+
+	for (int i = -1; i < ui->transitions->count(); i++) {
+		const char *name = "";
+
+		if (i >= 0) {
+			OBSSource tr;
+			tr = GetTransitionComboItem(ui->transitions, i);
+			if (!tr)
+				continue;
+			name = obs_source_get_name(tr);
+		}
+
+		bool match = (name && strcmp(name, curTransition) == 0);
+
+		if (!name || !*name)
+			name = Str("None");
+
+		action = menu->addAction(QT_UTF8(name));
+		action->setProperty("transition_index", i);
+		action->setCheckable(true);
+		action->setChecked(match);
+
+		connect(action, &QAction::triggered, std::bind(setTransition, action));
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	return menu;
+}
+
+void OBSBasic::ShowTransitionProperties()
+{
+	OBSSceneItem item = GetCurrentSceneItem();
+	OBSSource source = obs_sceneitem_get_transition(item, true);
+
+	if (source)
+		CreatePropertiesWindow(source);
+}
+
+void OBSBasic::HideTransitionProperties()
+{
+	OBSSceneItem item = GetCurrentSceneItem();
+	OBSSource source = obs_sceneitem_get_transition(item, false);
+
+	if (source)
+		CreatePropertiesWindow(source);
+}
+
+void OBSBasic::PasteShowHideTransition(obs_sceneitem_t *item, bool show, obs_source_t *tr, int duration)
+{
+	int64_t sceneItemId = obs_sceneitem_get_id(item);
+	std::string sceneUUID = obs_source_get_uuid(obs_scene_get_source(obs_sceneitem_get_scene(item)));
+
+	auto undo_redo = [sceneUUID, sceneItemId, show](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(sceneUUID.c_str());
+		obs_scene_t *scene = obs_scene_from_source(source);
+		obs_sceneitem_t *i = obs_scene_find_sceneitem_by_id(scene, sceneItemId);
+		if (i) {
+			OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str());
+			obs_sceneitem_transition_load(i, dat, show);
+		}
+	};
+
+	OBSDataAutoRelease oldTransitionData = obs_sceneitem_transition_save(item, show);
+
+	OBSSourceAutoRelease dup = obs_source_duplicate(tr, obs_source_get_name(tr), true);
+	obs_sceneitem_set_transition(item, show, dup);
+	obs_sceneitem_set_transition_duration(item, show, duration);
+
+	OBSDataAutoRelease transitionData = obs_sceneitem_transition_save(item, show);
+
+	std::string undo_data(obs_data_get_json(oldTransitionData));
+	std::string redo_data(obs_data_get_json(transitionData));
+	if (undo_data.compare(redo_data) == 0)
+		return;
+
+	QString text = show ? QTStr("Undo.ShowTransition") : QTStr("Undo.HideTransition");
+	const char *name = obs_source_get_name(obs_sceneitem_get_source(item));
+	undo_s.add_action(text.arg(name), undo_redo, undo_redo, undo_data, redo_data);
+}
+
+QMenu *OBSBasic::CreateVisibilityTransitionMenu(bool visible)
+{
+	OBSSceneItem si = GetCurrentSceneItem();
+
+	QMenu *menu = new QMenu(QTStr(visible ? "ShowTransition" : "HideTransition"));
+	QAction *action;
+
+	OBSSource curTransition = obs_sceneitem_get_transition(si, visible);
+	const char *curId = curTransition ? obs_source_get_id(curTransition) : nullptr;
+	int curDuration = (int)obs_sceneitem_get_transition_duration(si, visible);
+
+	if (curDuration <= 0)
+		curDuration = obs_frontend_get_transition_duration();
+
+	QSpinBox *duration = new QSpinBox(menu);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(curDuration);
+
+	auto setTransition = [this](QAction *action, bool visible) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		QString id = action->property("transition_id").toString();
+		OBSSceneItem sceneItem = main->GetCurrentSceneItem();
+		int64_t sceneItemId = obs_sceneitem_get_id(sceneItem);
+		std::string sceneUUID = obs_source_get_uuid(obs_scene_get_source(obs_sceneitem_get_scene(sceneItem)));
+
+		auto undo_redo = [sceneUUID, sceneItemId, visible](const std::string &data) {
+			OBSSourceAutoRelease source = obs_get_source_by_uuid(sceneUUID.c_str());
+			obs_scene_t *scene = obs_scene_from_source(source);
+			obs_sceneitem_t *i = obs_scene_find_sceneitem_by_id(scene, sceneItemId);
+			if (i) {
+				OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str());
+				obs_sceneitem_transition_load(i, dat, visible);
+			}
+		};
+		OBSDataAutoRelease oldTransitionData = obs_sceneitem_transition_save(sceneItem, visible);
+		if (id.isNull() || id.isEmpty()) {
+			obs_sceneitem_set_transition(sceneItem, visible, nullptr);
+			obs_sceneitem_set_transition_duration(sceneItem, visible, 0);
+		} else {
+			OBSSource tr = obs_sceneitem_get_transition(sceneItem, visible);
+
+			if (!tr || strcmp(QT_TO_UTF8(id), obs_source_get_id(tr)) != 0) {
+				QString name = QT_UTF8(obs_source_get_name(obs_sceneitem_get_source(sceneItem)));
+				name += " ";
+				name += QTStr(visible ? "ShowTransition" : "HideTransition");
+				tr = obs_source_create_private(QT_TO_UTF8(id), QT_TO_UTF8(name), nullptr);
+				obs_sceneitem_set_transition(sceneItem, visible, tr);
+				obs_source_release(tr);
+
+				int duration = (int)obs_sceneitem_get_transition_duration(sceneItem, visible);
+				if (duration <= 0) {
+					duration = obs_frontend_get_transition_duration();
+					obs_sceneitem_set_transition_duration(sceneItem, visible, duration);
+				}
+			}
+			if (obs_source_configurable(tr))
+				CreatePropertiesWindow(tr);
+		}
+		OBSDataAutoRelease newTransitionData = obs_sceneitem_transition_save(sceneItem, visible);
+		std::string undo_data(obs_data_get_json(oldTransitionData));
+		std::string redo_data(obs_data_get_json(newTransitionData));
+		if (undo_data.compare(redo_data) != 0)
+			main->undo_s.add_action(QTStr(visible ? "Undo.ShowTransition" : "Undo.HideTransition")
+							.arg(obs_source_get_name(obs_sceneitem_get_source(sceneItem))),
+						undo_redo, undo_redo, undo_data, redo_data);
+	};
+	auto setDuration = [visible](int duration) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		OBSSceneItem item = main->GetCurrentSceneItem();
+		obs_sceneitem_set_transition_duration(item, visible, duration);
+	};
+	connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, setDuration);
+
+	action = menu->addAction(QT_UTF8(Str("None")));
+	action->setProperty("transition_id", QT_UTF8(""));
+	action->setCheckable(true);
+	action->setChecked(!curId);
+	connect(action, &QAction::triggered, std::bind(setTransition, action, visible));
+	size_t idx = 0;
+	const char *id;
+	while (obs_enum_transition_types(idx++, &id)) {
+		const char *name = obs_source_get_display_name(id);
+		const bool match = id && curId && strcmp(id, curId) == 0;
+		action = menu->addAction(QT_UTF8(name));
+		action->setProperty("transition_id", QT_UTF8(id));
+		action->setCheckable(true);
+		action->setChecked(match);
+		connect(action, &QAction::triggered, std::bind(setTransition, action, visible));
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	if (curId && obs_is_source_configurable(curId)) {
+		menu->addSeparator();
+		menu->addAction(QTStr("Properties"), this,
+				visible ? &OBSBasic::ShowTransitionProperties : &OBSBasic::HideTransitionProperties);
+	}
+
+	auto copyTransition = [this](QAction *, bool visible) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSceneItem item = main->GetCurrentSceneItem();
+		obs_source_t *tr = obs_sceneitem_get_transition(item, visible);
+		int trDur = obs_sceneitem_get_transition_duration(item, visible);
+		main->copySourceTransition = obs_source_get_weak_source(tr);
+		main->copySourceTransitionDuration = trDur;
+	};
+	menu->addSeparator();
+	action = menu->addAction(QT_UTF8(Str("Copy")));
+	action->setEnabled(curId != nullptr);
+	connect(action, &QAction::triggered, std::bind(copyTransition, action, visible));
+
+	auto pasteTransition = [this](QAction *, bool show) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSource tr = OBSGetStrongRef(main->copySourceTransition);
+		int trDuration = main->copySourceTransitionDuration;
+		if (!tr)
+			return;
+
+		for (auto &selectedSource : GetAllSelectedSourceItems()) {
+			OBSSceneItem item = main->ui->sources->Get(selectedSource.row());
+			if (!item)
+				continue;
+
+			PasteShowHideTransition(item, show, tr, trDuration);
+		}
+	};
+
+	action = menu->addAction(QT_UTF8(Str("Paste")));
+	action->setEnabled(!!OBSGetStrongRef(copySourceTransition));
+	connect(action, &QAction::triggered, std::bind(pasteTransition, action, visible));
+	return menu;
+}
+
+QMenu *OBSBasic::CreateTransitionMenu(QWidget *parent, QuickTransition *qt)
+{
+	QMenu *menu = new QMenu(parent);
+	QAction *action;
+	OBSSource tr;
+
+	if (qt) {
+		action = menu->addAction(QTStr("Remove"));
+		action->setProperty("id", qt->id);
+		connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionRemoveClicked);
+
+		menu->addSeparator();
+	}
+
+	QSpinBox *duration = new QSpinBox(menu);
+	if (qt)
+		duration->setProperty("id", qt->id);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(qt ? qt->duration : 300);
+
+	if (qt) {
+		connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, this,
+			&OBSBasic::QuickTransitionChangeDuration);
+	}
+
+	tr = fadeTransition;
+
+	action = menu->addAction(QTStr("FadeToBlack"));
+	action->setProperty("fadeToBlack", true);
+
+	if (qt) {
+		action->setProperty("id", qt->id);
+		connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionChange);
+	} else {
+		action->setProperty("duration", QVariant::fromValue<QWidget *>(duration));
+		connect(action, &QAction::triggered, this, &OBSBasic::AddQuickTransition);
+	}
+
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		tr = GetTransitionComboItem(ui->transitions, i);
+
+		if (!tr)
+			continue;
+
+		action = menu->addAction(obs_source_get_name(tr));
+		action->setProperty("transition_index", i);
+
+		if (qt) {
+			action->setProperty("id", qt->id);
+			connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionChange);
+		} else {
+			action->setProperty("duration", QVariant::fromValue<QWidget *>(duration));
+			connect(action, &QAction::triggered, this, &OBSBasic::AddQuickTransition);
+		}
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	return menu;
+}
+
+void OBSBasic::AddQuickTransitionId(int id)
+{
+	QuickTransition *qt = GetQuickTransition(id);
+	if (!qt)
+		return;
+
+	/* --------------------------------- */
+
+	QPushButton *button = new MenuButton();
+	button->setProperty("id", id);
+
+	qt->button = button;
+	ResetQuickTransitionText(qt);
+
+	/* --------------------------------- */
+
+	QMenu *buttonMenu = CreateTransitionMenu(button, qt);
+
+	/* --------------------------------- */
+
+	button->setMenu(buttonMenu);
+	connect(button, &QAbstractButton::clicked, this, &OBSBasic::QuickTransitionClicked);
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	int idx = 3;
+	for (;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget || !widget->property("id").isValid())
+			break;
+	}
+
+	programLayout->insertWidget(idx, button);
+}
+
+void OBSBasic::AddQuickTransition()
+{
+	int trIdx = sender()->property("transition_index").toInt();
+	QSpinBox *duration = sender()->property("duration").value<QSpinBox *>();
+	bool fadeToBlack = sender()->property("fadeToBlack").value<bool>();
+	OBSSource transition = fadeToBlack ? OBSSource(fadeTransition) : GetTransitionComboItem(ui->transitions, trIdx);
+
+	if (!transition)
+		return;
+
+	int id = quickTransitionIdCounter++;
+
+	quickTransitions.emplace_back(transition, duration->value(), id, fadeToBlack);
+	AddQuickTransitionId(id);
+
+	int idx = (int)quickTransitions.size() - 1;
+	AddQuickTransitionHotkey(&quickTransitions[idx]);
+}
+
+void OBSBasic::ClearQuickTransitions()
+{
+	for (QuickTransition &qt : quickTransitions)
+		RemoveQuickTransitionHotkey(&qt);
+	quickTransitions.clear();
+
+	if (!programOptions)
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget)
+			continue;
+
+		int id = widget->property("id").toInt();
+		if (id != 0) {
+			delete widget;
+			idx--;
+		}
+	}
+}
+
+void OBSBasic::QuickTransitionClicked()
+{
+	int id = sender()->property("id").toInt();
+	TriggerQuickTransition(id);
+}
+
+void OBSBasic::QuickTransitionChange()
+{
+	int id = sender()->property("id").toInt();
+	int trIdx = sender()->property("transition_index").toInt();
+	bool fadeToBlack = sender()->property("fadeToBlack").value<bool>();
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt) {
+		OBSSource tr = fadeToBlack ? OBSSource(fadeTransition) : GetTransitionComboItem(ui->transitions, trIdx);
+		if (tr) {
+			qt->source = tr;
+			qt->fadeToBlack = fadeToBlack;
+			ResetQuickTransitionText(qt);
+		}
+	}
+}
+
+void OBSBasic::QuickTransitionChangeDuration(int value)
+{
+	int id = sender()->property("id").toInt();
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt) {
+		qt->duration = value;
+		ResetQuickTransitionText(qt);
+	}
+}
+
+void OBSBasic::QuickTransitionRemoveClicked()
+{
+	int id = sender()->property("id").toInt();
+	int idx = GetQuickTransitionIdx(id);
+	if (idx == -1)
+		return;
+
+	QuickTransition &qt = quickTransitions[idx];
+
+	if (qt.button)
+		qt.button->deleteLater();
+
+	RemoveQuickTransitionHotkey(&qt);
+	quickTransitions.erase(quickTransitions.begin() + idx);
+}
+
+void OBSBasic::ClearQuickTransitionWidgets()
+{
+	if (!IsPreviewProgramMode())
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget)
+			continue;
+
+		int id = widget->property("id").toInt();
+		if (id != 0) {
+			delete widget;
+			idx--;
+		}
+	}
+}
+
+void OBSBasic::RefreshQuickTransitions()
+{
+	if (!IsPreviewProgramMode())
+		return;
+
+	for (QuickTransition &qt : quickTransitions)
+		AddQuickTransitionId(qt.id);
+}
+
+void OBSBasic::EnableTransitionWidgets(bool enable)
+{
+	ui->transitions->setEnabled(enable);
+
+	if (!enable) {
+		ui->transitionProps->setEnabled(false);
+	} else {
+		bool configurable = obs_source_configurable(GetCurrentTransition());
+		ui->transitionProps->setEnabled(configurable);
+	}
+
+	if (!IsPreviewProgramMode())
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QPushButton *button = qobject_cast<QPushButton *>(item->widget());
+		if (!button)
+			continue;
+
+		button->setEnabled(enable);
+	}
+
+	if (transitionButton)
+		transitionButton->setEnabled(enable);
+}
+
+void OBSBasic::SetPreviewProgramMode(bool enabled)
+{
+	if (IsPreviewProgramMode() == enabled)
+		return;
+
+	os_atomic_set_bool(&previewProgramMode, enabled);
+	emit PreviewProgramModeChanged(enabled);
+
+	if (IsPreviewProgramMode()) {
+		if (!previewEnabled)
+			EnablePreviewDisplay(true);
+
+		CreateProgramDisplay();
+		CreateProgramOptions();
+
+		OBSScene curScene = GetCurrentScene();
+
+		OBSSceneAutoRelease dup;
+		if (sceneDuplicationMode) {
+			dup = obs_scene_duplicate(curScene, obs_source_get_name(obs_scene_get_source(curScene)),
+						  editPropertiesMode ? OBS_SCENE_DUP_PRIVATE_COPY
+								     : OBS_SCENE_DUP_PRIVATE_REFS);
+		} else {
+			dup = std::move(OBSScene(curScene));
+		}
+
+		OBSSourceAutoRelease transition = obs_get_output_source(0);
+		obs_source_t *dup_source = obs_scene_get_source(dup);
+		obs_transition_set(transition, dup_source);
+
+		if (curScene) {
+			obs_source_t *source = obs_scene_get_source(curScene);
+			obs_source_inc_showing(source);
+			lastScene = OBSGetWeakRef(source);
+			programScene = OBSGetWeakRef(source);
+		}
+
+		RefreshQuickTransitions();
+
+		programLabel = new QLabel(QTStr("StudioMode.ProgramSceneLabel"), this);
+		programLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+		programLabel->setProperty("class", "label-preview-title");
+
+		programWidget = new QWidget();
+		programLayout = new QVBoxLayout();
+
+		programLayout->setContentsMargins(0, 0, 0, 0);
+		programLayout->setSpacing(0);
+
+		programLayout->addWidget(programLabel);
+		programLayout->addWidget(program);
+
+		programWidget->setLayout(programLayout);
+
+		ui->previewLayout->addWidget(programOptions);
+		ui->previewLayout->addWidget(programWidget);
+		ui->previewLayout->setAlignment(programOptions, Qt::AlignCenter);
+
+		OnEvent(OBS_FRONTEND_EVENT_STUDIO_MODE_ENABLED);
+
+		blog(LOG_INFO, "Switched to Preview/Program mode");
+		blog(LOG_INFO, "-----------------------------"
+			       "-------------------");
+	} else {
+		OBSSource actualProgramScene = OBSGetStrongRef(programScene);
+		if (!actualProgramScene)
+			actualProgramScene = GetCurrentSceneSource();
+		else
+			SetCurrentScene(actualProgramScene, true);
+		TransitionToScene(actualProgramScene, true);
+
+		delete programOptions;
+		delete program;
+		delete programLabel;
+		delete programWidget;
+
+		if (lastScene) {
+			OBSSource actualLastScene = OBSGetStrongRef(lastScene);
+			if (actualLastScene)
+				obs_source_dec_showing(actualLastScene);
+			lastScene = nullptr;
+		}
+
+		programScene = nullptr;
+		swapScene = nullptr;
+		prevFTBSource = nullptr;
+
+		for (QuickTransition &qt : quickTransitions)
+			qt.button = nullptr;
+
+		if (!previewEnabled)
+			EnablePreviewDisplay(false);
+
+		ui->transitions->setEnabled(true);
+		tBarActive = false;
+
+		OnEvent(OBS_FRONTEND_EVENT_STUDIO_MODE_DISABLED);
+
+		blog(LOG_INFO, "Switched to regular Preview mode");
+		blog(LOG_INFO, "-----------------------------"
+			       "-------------------");
+	}
+
+	ResetUI();
+	UpdateTitleBar();
+}
+
+void OBSBasic::RenderProgram(void *data, uint32_t, uint32_t)
+{
+	GS_DEBUG_MARKER_BEGIN(GS_DEBUG_COLOR_DEFAULT, "RenderProgram");
+
+	OBSBasic *window = static_cast<OBSBasic *>(data);
+	obs_video_info ovi;
+
+	obs_get_video_info(&ovi);
+
+	window->programCX = int(window->programScale * float(ovi.base_width));
+	window->programCY = int(window->programScale * float(ovi.base_height));
+
+	gs_viewport_push();
+	gs_projection_push();
+
+	/* --------------------------------------- */
+
+	gs_ortho(0.0f, float(ovi.base_width), 0.0f, float(ovi.base_height), -100.0f, 100.0f);
+	gs_set_viewport(window->programX, window->programY, window->programCX, window->programCY);
+
+	obs_render_main_texture_src_color_only();
+	gs_load_vertexbuffer(nullptr);
+
+	/* --------------------------------------- */
+
+	gs_projection_pop();
+	gs_viewport_pop();
+
+	GS_DEBUG_MARKER_END();
+}
+
+void OBSBasic::ResizeProgram(uint32_t cx, uint32_t cy)
+{
+	QSize targetSize;
+
+	/* resize program panel to fix to the top section of the window */
+	targetSize = GetPixelSize(program);
+	GetScaleAndCenterPos(int(cx), int(cy), targetSize.width() - PREVIEW_EDGE_SIZE * 2,
+			     targetSize.height() - PREVIEW_EDGE_SIZE * 2, programX, programY, programScale);
+
+	programX += float(PREVIEW_EDGE_SIZE);
+	programY += float(PREVIEW_EDGE_SIZE);
+}
+
+obs_data_array_t *OBSBasic::SaveTransitions()
+{
+	obs_data_array_t *transitions = obs_data_array_create();
+
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		OBSSource tr = ui->transitions->itemData(i).value<OBSSource>();
+		if (!tr || !obs_source_configurable(tr))
+			continue;
+
+		OBSDataAutoRelease sourceData = obs_data_create();
+		OBSDataAutoRelease settings = obs_source_get_settings(tr);
+
+		obs_data_set_string(sourceData, "name", obs_source_get_name(tr));
+		obs_data_set_string(sourceData, "id", obs_obj_get_id(tr));
+		obs_data_set_obj(sourceData, "settings", settings);
+
+		obs_data_array_push_back(transitions, sourceData);
+	}
+
+	for (const OBSDataAutoRelease &transition : safeModeTransitions) {
+		obs_data_array_push_back(transitions, transition);
+	}
+
+	return transitions;
+}
+
+void OBSBasic::LoadTransitions(obs_data_array_t *transitions, obs_load_source_cb cb, void *private_data)
+{
+	size_t count = obs_data_array_count(transitions);
+
+	safeModeTransitions.clear();
+	for (size_t i = 0; i < count; i++) {
+		OBSDataAutoRelease item = obs_data_array_item(transitions, i);
+		const char *name = obs_data_get_string(item, "name");
+		const char *id = obs_data_get_string(item, "id");
+		OBSDataAutoRelease settings = obs_data_get_obj(item, "settings");
+
+		OBSSourceAutoRelease source = obs_source_create_private(id, name, settings);
+		if (!obs_obj_invalid(source)) {
+			InitTransition(source);
+
+			ui->transitions->addItem(QT_UTF8(name), QVariant::fromValue(OBSSource(source)));
+			ui->transitions->setCurrentIndex(ui->transitions->count() - 1);
+			if (cb)
+				cb(private_data, source);
+		} else if (safe_mode || disable_3p_plugins) {
+			safeModeTransitions.push_back(std::move(item));
+		}
+	}
+}
+
+OBSSource OBSBasic::GetOverrideTransition(OBSSource source)
+{
+	if (!source)
+		return nullptr;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(source);
+
+	const char *trOverrideName = obs_data_get_string(data, "transition");
+
+	OBSSource trOverride = nullptr;
+
+	if (trOverrideName && *trOverrideName)
+		trOverride = FindTransition(trOverrideName);
+
+	return trOverride;
+}
+
+int OBSBasic::GetOverrideTransitionDuration(OBSSource source)
+{
+	if (!source)
+		return 300;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(source);
+	obs_data_set_default_int(data, "transition_duration", 300);
+
+	return (int)obs_data_get_int(data, "transition_duration");
+}
+
+void OBSBasic::UpdatePreviewProgramIndicators()
+{
+	bool labels = previewProgramMode ? config_get_bool(App()->GetUserConfig(), "BasicWindow", "StudioModeLabels")
+					 : false;
+
+	ui->previewLabel->setVisible(labels);
+
+	if (programLabel)
+		programLabel->setVisible(labels);
+
+	if (!labels)
+		return;
+
+	QString preview =
+		QTStr("StudioMode.PreviewSceneName").arg(QT_UTF8(obs_source_get_name(GetCurrentSceneSource())));
+
+	QString program = QTStr("StudioMode.ProgramSceneName").arg(QT_UTF8(obs_source_get_name(GetProgramSource())));
+
+	if (ui->previewLabel->text() != preview)
+		ui->previewLabel->setText(preview);
+
+	if (programLabel && programLabel->text() != program)
+		programLabel->setText(program);
+}
 /******************************************************************************
     Copyright (C) 2023 by Lain Bailey <[email protected]>
                           Zachary Lund <[email protected]>

+ 1699 - 0
frontend/widgets/OBSBasic_Transitions.cpp

@@ -1,3 +1,1702 @@
+/******************************************************************************
+    Copyright (C) 2023 by Lain Bailey <[email protected]>
+
+    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 <QSpinBox>
+#include <QWidgetAction>
+#include <QToolTip>
+#include <QMessageBox>
+#include <util/dstr.hpp>
+#include <qt-wrappers.hpp>
+#include <slider-ignorewheel.hpp>
+#include "window-basic-main.hpp"
+#include "window-basic-main-outputs.hpp"
+#include "window-basic-vcam-config.hpp"
+#include "display-helpers.hpp"
+#include "window-namedialog.hpp"
+#include "menu-button.hpp"
+
+#include "obs-hotkey.h"
+
+using namespace std;
+
+Q_DECLARE_METATYPE(OBSScene);
+Q_DECLARE_METATYPE(OBSSource);
+Q_DECLARE_METATYPE(QuickTransition);
+
+static inline QString MakeQuickTransitionText(QuickTransition *qt)
+{
+	QString name;
+
+	if (!qt->fadeToBlack)
+		name = QT_UTF8(obs_source_get_name(qt->source));
+	else
+		name = QTStr("FadeToBlack");
+
+	if (!obs_transition_fixed(qt->source))
+		name += QString(" (%1ms)").arg(QString::number(qt->duration));
+	return name;
+}
+
+void OBSBasic::InitDefaultTransitions()
+{
+	std::vector<OBSSource> transitions;
+	size_t idx = 0;
+	const char *id;
+
+	/* automatically add transitions that have no configuration (things
+	 * such as cut/fade/etc) */
+	while (obs_enum_transition_types(idx++, &id)) {
+		if (!obs_is_source_configurable(id)) {
+			const char *name = obs_source_get_display_name(id);
+
+			OBSSourceAutoRelease tr = obs_source_create_private(id, name, NULL);
+			InitTransition(tr);
+			transitions.emplace_back(tr);
+
+			if (strcmp(id, "fade_transition") == 0)
+				fadeTransition = tr;
+			else if (strcmp(id, "cut_transition") == 0)
+				cutTransition = tr;
+		}
+	}
+
+	for (OBSSource &tr : transitions) {
+		ui->transitions->addItem(QT_UTF8(obs_source_get_name(tr)), QVariant::fromValue(OBSSource(tr)));
+	}
+}
+
+void OBSBasic::AddQuickTransitionHotkey(QuickTransition *qt)
+{
+	DStr hotkeyId;
+	QString hotkeyName;
+
+	dstr_printf(hotkeyId, "OBSBasic.QuickTransition.%d", qt->id);
+	hotkeyName = QTStr("QuickTransitions.HotkeyName").arg(MakeQuickTransitionText(qt));
+
+	auto quickTransition = [](void *data, obs_hotkey_id, obs_hotkey_t *, bool pressed) {
+		int id = (int)(uintptr_t)data;
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		if (pressed)
+			QMetaObject::invokeMethod(main, "TriggerQuickTransition", Qt::QueuedConnection, Q_ARG(int, id));
+	};
+
+	qt->hotkey = obs_hotkey_register_frontend(hotkeyId->array, QT_TO_UTF8(hotkeyName), quickTransition,
+						  (void *)(uintptr_t)qt->id);
+}
+
+void QuickTransition::SourceRenamed(void *param, calldata_t *)
+{
+	QuickTransition *qt = reinterpret_cast<QuickTransition *>(param);
+
+	QString hotkeyName = QTStr("QuickTransitions.HotkeyName").arg(MakeQuickTransitionText(qt));
+
+	obs_hotkey_set_description(qt->hotkey, QT_TO_UTF8(hotkeyName));
+}
+
+void OBSBasic::TriggerQuickTransition(int id)
+{
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt && previewProgramMode) {
+		OBSScene scene = GetCurrentScene();
+		obs_source_t *source = obs_scene_get_source(scene);
+
+		if (GetCurrentTransition() != qt->source) {
+			OverrideTransition(qt->source);
+			overridingTransition = true;
+		}
+
+		TransitionToScene(source, false, true, qt->duration, qt->fadeToBlack);
+	}
+}
+
+void OBSBasic::RemoveQuickTransitionHotkey(QuickTransition *qt)
+{
+	obs_hotkey_unregister(qt->hotkey);
+}
+
+void OBSBasic::InitTransition(obs_source_t *transition)
+{
+	auto onTransitionStop = [](void *data, calldata_t *) {
+		OBSBasic *window = (OBSBasic *)data;
+		QMetaObject::invokeMethod(window, "TransitionStopped", Qt::QueuedConnection);
+	};
+
+	auto onTransitionFullStop = [](void *data, calldata_t *) {
+		OBSBasic *window = (OBSBasic *)data;
+		QMetaObject::invokeMethod(window, "TransitionFullyStopped", Qt::QueuedConnection);
+	};
+
+	signal_handler_t *handler = obs_source_get_signal_handler(transition);
+	signal_handler_connect(handler, "transition_video_stop", onTransitionStop, this);
+	signal_handler_connect(handler, "transition_stop", onTransitionFullStop, this);
+}
+
+static inline OBSSource GetTransitionComboItem(QComboBox *combo, int idx)
+{
+	return combo->itemData(idx).value<OBSSource>();
+}
+
+void OBSBasic::CreateDefaultQuickTransitions()
+{
+	/* non-configurable transitions are always available, so add them
+	 * to the "default quick transitions" list */
+	quickTransitions.emplace_back(cutTransition, 300, quickTransitionIdCounter++);
+	quickTransitions.emplace_back(fadeTransition, 300, quickTransitionIdCounter++);
+	quickTransitions.emplace_back(fadeTransition, 300, quickTransitionIdCounter++, true);
+}
+
+void OBSBasic::LoadQuickTransitions(obs_data_array_t *array)
+{
+	size_t count = obs_data_array_count(array);
+
+	quickTransitionIdCounter = 1;
+
+	for (size_t i = 0; i < count; i++) {
+		OBSDataAutoRelease data = obs_data_array_item(array, i);
+		OBSDataArrayAutoRelease hotkeys = obs_data_get_array(data, "hotkeys");
+		const char *name = obs_data_get_string(data, "name");
+		int duration = obs_data_get_int(data, "duration");
+		int id = obs_data_get_int(data, "id");
+		bool toBlack = obs_data_get_bool(data, "fade_to_black");
+
+		if (id) {
+			obs_source_t *source = FindTransition(name);
+			if (source) {
+				quickTransitions.emplace_back(source, duration, id, toBlack);
+
+				if (quickTransitionIdCounter <= id)
+					quickTransitionIdCounter = id + 1;
+
+				int idx = (int)quickTransitions.size() - 1;
+				AddQuickTransitionHotkey(&quickTransitions[idx]);
+				obs_hotkey_load(quickTransitions[idx].hotkey, hotkeys);
+			}
+		}
+	}
+}
+
+obs_data_array_t *OBSBasic::SaveQuickTransitions()
+{
+	obs_data_array_t *array = obs_data_array_create();
+
+	for (QuickTransition &qt : quickTransitions) {
+		OBSDataAutoRelease data = obs_data_create();
+		OBSDataArrayAutoRelease hotkeys = obs_hotkey_save(qt.hotkey);
+
+		obs_data_set_string(data, "name", obs_source_get_name(qt.source));
+		obs_data_set_int(data, "duration", qt.duration);
+		obs_data_set_array(data, "hotkeys", hotkeys);
+		obs_data_set_int(data, "id", qt.id);
+		obs_data_set_bool(data, "fade_to_black", qt.fadeToBlack);
+
+		obs_data_array_push_back(array, data);
+	}
+
+	return array;
+}
+
+obs_source_t *OBSBasic::FindTransition(const char *name)
+{
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		OBSSource tr = ui->transitions->itemData(i).value<OBSSource>();
+		if (!tr)
+			continue;
+
+		const char *trName = obs_source_get_name(tr);
+		if (strcmp(trName, name) == 0)
+			return tr;
+	}
+
+	return nullptr;
+}
+
+void OBSBasic::TransitionToScene(OBSScene scene, bool force)
+{
+	obs_source_t *source = obs_scene_get_source(scene);
+	TransitionToScene(source, force);
+}
+
+void OBSBasic::TransitionStopped()
+{
+	if (swapScenesMode) {
+		OBSSource scene = OBSGetStrongRef(swapScene);
+		if (scene)
+			SetCurrentScene(scene);
+	}
+
+	EnableTransitionWidgets(true);
+	UpdatePreviewProgramIndicators();
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_STOPPED);
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_CHANGED);
+
+	swapScene = nullptr;
+}
+
+void OBSBasic::OverrideTransition(OBSSource transition)
+{
+	OBSSourceAutoRelease oldTransition = obs_get_output_source(0);
+
+	if (transition != oldTransition) {
+		obs_transition_swap_begin(transition, oldTransition);
+		obs_set_output_source(0, transition);
+		obs_transition_swap_end(transition, oldTransition);
+	}
+}
+
+void OBSBasic::TransitionFullyStopped()
+{
+	if (overridingTransition) {
+		OverrideTransition(GetCurrentTransition());
+		overridingTransition = false;
+	}
+}
+
+void OBSBasic::TransitionToScene(OBSSource source, bool force, bool quickTransition, int quickDuration, bool black,
+				 bool manual)
+{
+	obs_scene_t *scene = obs_scene_from_source(source);
+	bool usingPreviewProgram = IsPreviewProgramMode();
+	if (!scene)
+		return;
+
+	if (usingPreviewProgram) {
+		if (!tBarActive)
+			lastProgramScene = programScene;
+		programScene = OBSGetWeakRef(source);
+
+		if (!force && !black) {
+			OBSSource lastScene = OBSGetStrongRef(lastProgramScene);
+
+			if (!sceneDuplicationMode && lastScene == source)
+				return;
+
+			if (swapScenesMode && lastScene && lastScene != GetCurrentSceneSource())
+				swapScene = lastProgramScene;
+		}
+	}
+
+	if (usingPreviewProgram && sceneDuplicationMode) {
+		scene = obs_scene_duplicate(scene, obs_source_get_name(obs_scene_get_source(scene)),
+					    editPropertiesMode ? OBS_SCENE_DUP_PRIVATE_COPY
+							       : OBS_SCENE_DUP_PRIVATE_REFS);
+		source = obs_scene_get_source(scene);
+	}
+
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+	if (!transition) {
+		if (usingPreviewProgram && sceneDuplicationMode)
+			obs_scene_release(scene);
+		return;
+	}
+
+	float t = obs_transition_get_time(transition);
+	bool stillTransitioning = t < 1.0f && t > 0.0f;
+
+	// If actively transitioning, block new transitions from starting
+	if (usingPreviewProgram && stillTransitioning)
+		goto cleanup;
+
+	if (usingPreviewProgram) {
+		if (!black && !manual) {
+			const char *sceneName = obs_source_get_name(source);
+			blog(LOG_INFO, "User switched Program to scene '%s'", sceneName);
+
+		} else if (black && !prevFTBSource) {
+			OBSSourceAutoRelease target = obs_transition_get_active_source(transition);
+			const char *sceneName = obs_source_get_name(target);
+			blog(LOG_INFO, "User faded from scene '%s' to black", sceneName);
+
+		} else if (black && prevFTBSource) {
+			const char *sceneName = obs_source_get_name(prevFTBSource);
+			blog(LOG_INFO, "User faded from black to scene '%s'", sceneName);
+
+		} else if (manual) {
+			const char *sceneName = obs_source_get_name(source);
+			blog(LOG_INFO, "User started manual transition to scene '%s'", sceneName);
+		}
+	}
+
+	if (force) {
+		obs_transition_set(transition, source);
+		OnEvent(OBS_FRONTEND_EVENT_SCENE_CHANGED);
+	} else {
+		int duration = ui->transitionDuration->value();
+
+		/* check for scene override */
+		OBSSource trOverride = GetOverrideTransition(source);
+
+		if (trOverride && !overridingTransition && !quickTransition) {
+			transition = std::move(trOverride);
+			duration = GetOverrideTransitionDuration(source);
+			OverrideTransition(transition.Get());
+			overridingTransition = true;
+		}
+
+		if (black && !prevFTBSource) {
+			prevFTBSource = source;
+			source = nullptr;
+		} else if (black && prevFTBSource) {
+			source = prevFTBSource;
+			prevFTBSource = nullptr;
+		} else if (!black) {
+			prevFTBSource = nullptr;
+		}
+
+		if (quickTransition)
+			duration = quickDuration;
+
+		enum obs_transition_mode mode = manual ? OBS_TRANSITION_MODE_MANUAL : OBS_TRANSITION_MODE_AUTO;
+
+		EnableTransitionWidgets(false);
+
+		bool success = obs_transition_start(transition, mode, duration, source);
+
+		if (!success)
+			TransitionFullyStopped();
+	}
+
+cleanup:
+	if (usingPreviewProgram && sceneDuplicationMode)
+		obs_scene_release(scene);
+}
+
+static inline void SetComboTransition(QComboBox *combo, obs_source_t *tr)
+{
+	int idx = combo->findData(QVariant::fromValue<OBSSource>(tr));
+	if (idx != -1) {
+		combo->blockSignals(true);
+		combo->setCurrentIndex(idx);
+		combo->blockSignals(false);
+	}
+}
+
+void OBSBasic::SetTransition(OBSSource transition)
+{
+	OBSSourceAutoRelease oldTransition = obs_get_output_source(0);
+
+	if (oldTransition && transition) {
+		obs_transition_swap_begin(transition, oldTransition);
+		if (transition != GetCurrentTransition())
+			SetComboTransition(ui->transitions, transition);
+		obs_set_output_source(0, transition);
+		obs_transition_swap_end(transition, oldTransition);
+	} else {
+		obs_set_output_source(0, transition);
+	}
+
+	bool fixed = transition ? obs_transition_fixed(transition) : false;
+	ui->transitionDurationLabel->setVisible(!fixed);
+	ui->transitionDuration->setVisible(!fixed);
+
+	bool configurable = transition ? obs_source_configurable(transition) : false;
+	ui->transitionRemove->setEnabled(configurable);
+	ui->transitionProps->setEnabled(configurable);
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_CHANGED);
+}
+
+OBSSource OBSBasic::GetCurrentTransition()
+{
+	return ui->transitions->currentData().value<OBSSource>();
+}
+
+void OBSBasic::on_transitions_currentIndexChanged(int)
+{
+	OBSSource transition = GetCurrentTransition();
+	SetTransition(transition);
+}
+
+void OBSBasic::AddTransition(const char *id)
+{
+	string name;
+	QString placeHolderText = QT_UTF8(obs_source_get_display_name(id));
+	QString format = placeHolderText + " (%1)";
+	obs_source_t *source = nullptr;
+	int i = 1;
+
+	while ((FindTransition(QT_TO_UTF8(placeHolderText)))) {
+		placeHolderText = format.arg(++i);
+	}
+
+	bool accepted = NameDialog::AskForName(this, QTStr("TransitionNameDlg.Title"), QTStr("TransitionNameDlg.Text"),
+					       name, placeHolderText);
+
+	if (accepted) {
+		if (name.empty()) {
+			OBSMessageBox::warning(this, QTStr("NoNameEntered.Title"), QTStr("NoNameEntered.Text"));
+			AddTransition(id);
+			return;
+		}
+
+		source = FindTransition(name.c_str());
+		if (source) {
+			OBSMessageBox::warning(this, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+
+			AddTransition(id);
+			return;
+		}
+
+		source = obs_source_create_private(id, name.c_str(), NULL);
+		InitTransition(source);
+		ui->transitions->addItem(QT_UTF8(name.c_str()), QVariant::fromValue(OBSSource(source)));
+		ui->transitions->setCurrentIndex(ui->transitions->count() - 1);
+		CreatePropertiesWindow(source);
+		obs_source_release(source);
+
+		OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+		ClearQuickTransitionWidgets();
+		RefreshQuickTransitions();
+	}
+}
+
+void OBSBasic::on_transitionAdd_clicked()
+{
+	bool foundConfigurableTransitions = false;
+	QMenu menu(this);
+	size_t idx = 0;
+	const char *id;
+
+	while (obs_enum_transition_types(idx++, &id)) {
+		if (obs_is_source_configurable(id)) {
+			const char *name = obs_source_get_display_name(id);
+			QAction *action = new QAction(name, this);
+
+			connect(action, &QAction::triggered, [this, id]() { AddTransition(id); });
+
+			menu.addAction(action);
+			foundConfigurableTransitions = true;
+		}
+	}
+
+	if (foundConfigurableTransitions)
+		menu.exec(QCursor::pos());
+}
+
+void OBSBasic::on_transitionRemove_clicked()
+{
+	OBSSource tr = GetCurrentTransition();
+
+	if (!tr || !obs_source_configurable(tr) || !QueryRemoveSource(tr))
+		return;
+
+	int idx = ui->transitions->findData(QVariant::fromValue<OBSSource>(tr));
+	if (idx == -1)
+		return;
+
+	for (size_t i = quickTransitions.size(); i > 0; i--) {
+		QuickTransition &qt = quickTransitions[i - 1];
+		if (qt.source == tr) {
+			if (qt.button)
+				qt.button->deleteLater();
+			RemoveQuickTransitionHotkey(&qt);
+			quickTransitions.erase(quickTransitions.begin() + i - 1);
+		}
+	}
+
+	ui->transitions->removeItem(idx);
+
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+	ClearQuickTransitionWidgets();
+	RefreshQuickTransitions();
+}
+
+void OBSBasic::RenameTransition(OBSSource transition)
+{
+	string name;
+	QString placeHolderText = QT_UTF8(obs_source_get_name(transition));
+	obs_source_t *source = nullptr;
+
+	bool accepted = NameDialog::AskForName(this, QTStr("TransitionNameDlg.Title"), QTStr("TransitionNameDlg.Text"),
+					       name, placeHolderText);
+
+	if (!accepted)
+		return;
+	if (name.empty()) {
+		OBSMessageBox::warning(this, QTStr("NoNameEntered.Title"), QTStr("NoNameEntered.Text"));
+		RenameTransition(transition);
+		return;
+	}
+
+	source = FindTransition(name.c_str());
+	if (source) {
+		OBSMessageBox::warning(this, QTStr("NameExists.Title"), QTStr("NameExists.Text"));
+
+		RenameTransition(transition);
+		return;
+	}
+
+	obs_source_set_name(transition, name.c_str());
+	int idx = ui->transitions->findData(QVariant::fromValue(transition));
+	if (idx != -1) {
+		ui->transitions->setItemText(idx, QT_UTF8(name.c_str()));
+
+		OnEvent(OBS_FRONTEND_EVENT_TRANSITION_LIST_CHANGED);
+
+		ClearQuickTransitionWidgets();
+		RefreshQuickTransitions();
+	}
+}
+
+void OBSBasic::on_transitionProps_clicked()
+{
+	OBSSource source = GetCurrentTransition();
+
+	if (!obs_source_configurable(source))
+		return;
+
+	auto properties = [&]() {
+		CreatePropertiesWindow(source);
+	};
+
+	QMenu menu(this);
+
+	QAction *action = new QAction(QTStr("Rename"), &menu);
+	connect(action, &QAction::triggered, [this, source]() { RenameTransition(source); });
+	menu.addAction(action);
+
+	action = new QAction(QTStr("Properties"), &menu);
+	connect(action, &QAction::triggered, properties);
+	menu.addAction(action);
+
+	menu.exec(QCursor::pos());
+}
+
+void OBSBasic::on_transitionDuration_valueChanged()
+{
+	OnEvent(OBS_FRONTEND_EVENT_TRANSITION_DURATION_CHANGED);
+}
+
+QuickTransition *OBSBasic::GetQuickTransition(int id)
+{
+	for (QuickTransition &qt : quickTransitions) {
+		if (qt.id == id)
+			return &qt;
+	}
+
+	return nullptr;
+}
+
+int OBSBasic::GetQuickTransitionIdx(int id)
+{
+	for (int idx = 0; idx < (int)quickTransitions.size(); idx++) {
+		QuickTransition &qt = quickTransitions[idx];
+
+		if (qt.id == id)
+			return idx;
+	}
+
+	return -1;
+}
+
+void OBSBasic::SetCurrentScene(obs_scene_t *scene, bool force)
+{
+	obs_source_t *source = obs_scene_get_source(scene);
+	SetCurrentScene(source, force);
+}
+
+template<typename T> static T GetOBSRef(QListWidgetItem *item)
+{
+	return item->data(static_cast<int>(QtDataRole::OBSRef)).value<T>();
+}
+
+void OBSBasic::SetCurrentScene(OBSSource scene, bool force)
+{
+	if (!IsPreviewProgramMode()) {
+		TransitionToScene(scene, force);
+	} else {
+		OBSSource actualLastScene = OBSGetStrongRef(lastScene);
+		if (actualLastScene != scene) {
+			if (scene)
+				obs_source_inc_showing(scene);
+			if (actualLastScene)
+				obs_source_dec_showing(actualLastScene);
+			lastScene = OBSGetWeakRef(scene);
+		}
+	}
+
+	if (obs_scene_get_source(GetCurrentScene()) != scene) {
+		for (int i = 0; i < ui->scenes->count(); i++) {
+			QListWidgetItem *item = ui->scenes->item(i);
+			OBSScene itemScene = GetOBSRef<OBSScene>(item);
+			obs_source_t *source = obs_scene_get_source(itemScene);
+
+			if (source == scene) {
+				ui->scenes->blockSignals(true);
+				currentScene = itemScene.Get();
+				ui->scenes->setCurrentItem(item);
+				ui->scenes->blockSignals(false);
+
+				if (vcamEnabled && vcamConfig.type == VCamOutputType::PreviewOutput)
+					outputHandler->UpdateVirtualCamOutputSource();
+
+				OnEvent(OBS_FRONTEND_EVENT_PREVIEW_SCENE_CHANGED);
+				break;
+			}
+		}
+	}
+
+	UpdateContextBar(true);
+	UpdatePreviewProgramIndicators();
+
+	if (scene) {
+		bool userSwitched = (!force && !disableSaving);
+		blog(LOG_INFO, "%s to scene '%s'", userSwitched ? "User switched" : "Switched",
+		     obs_source_get_name(scene));
+	}
+}
+
+void OBSBasic::CreateProgramDisplay()
+{
+	program = new OBSQTDisplay();
+
+	program->setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(program.data(), &QWidget::customContextMenuRequested, this, &OBSBasic::ProgramViewContextMenuRequested);
+
+	auto displayResize = [this]() {
+		struct obs_video_info ovi;
+
+		if (obs_get_video_info(&ovi))
+			ResizeProgram(ovi.base_width, ovi.base_height);
+	};
+
+	connect(program.data(), &OBSQTDisplay::DisplayResized, displayResize);
+
+	auto addDisplay = [this](OBSQTDisplay *window) {
+		obs_display_add_draw_callback(window->GetDisplay(), OBSBasic::RenderProgram, this);
+
+		struct obs_video_info ovi;
+		if (obs_get_video_info(&ovi))
+			ResizeProgram(ovi.base_width, ovi.base_height);
+	};
+
+	connect(program.data(), &OBSQTDisplay::DisplayCreated, addDisplay);
+
+	program->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+}
+
+void OBSBasic::TransitionClicked()
+{
+	if (previewProgramMode)
+		TransitionToScene(GetCurrentScene());
+}
+
+#define T_BAR_PRECISION 1024
+#define T_BAR_PRECISION_F ((float)T_BAR_PRECISION)
+#define T_BAR_CLAMP (T_BAR_PRECISION / 10)
+
+void OBSBasic::CreateProgramOptions()
+{
+	programOptions = new QWidget();
+	QVBoxLayout *layout = new QVBoxLayout();
+	layout->setSpacing(4);
+
+	QPushButton *configTransitions = new QPushButton();
+	configTransitions->setProperty("class", "icon-dots-vert");
+
+	QHBoxLayout *mainButtonLayout = new QHBoxLayout();
+	mainButtonLayout->setSpacing(2);
+
+	transitionButton = new QPushButton(QTStr("Transition"));
+	transitionButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	QHBoxLayout *quickTransitions = new QHBoxLayout();
+	quickTransitions->setSpacing(2);
+
+	QPushButton *addQuickTransition = new QPushButton();
+	addQuickTransition->setProperty("class", "icon-plus");
+
+	QLabel *quickTransitionsLabel = new QLabel(QTStr("QuickTransitions"));
+	quickTransitionsLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+
+	quickTransitions->addWidget(quickTransitionsLabel);
+	quickTransitions->addWidget(addQuickTransition);
+
+	mainButtonLayout->addWidget(transitionButton);
+	mainButtonLayout->addWidget(configTransitions);
+
+	tBar = new SliderIgnoreClick(Qt::Horizontal);
+	tBar->setMinimum(0);
+	tBar->setMaximum(T_BAR_PRECISION - 1);
+
+	tBar->setProperty("class", "slider-tbar");
+
+	connect(tBar, &QSlider::valueChanged, this, &OBSBasic::TBarChanged);
+	connect(tBar, &QSlider::sliderReleased, this, &OBSBasic::TBarReleased);
+
+	layout->addStretch(0);
+	layout->addLayout(mainButtonLayout);
+	layout->addLayout(quickTransitions);
+	layout->addWidget(tBar);
+	layout->addStretch(0);
+
+	programOptions->setLayout(layout);
+
+	auto onAdd = [this]() {
+		QScopedPointer<QMenu> menu(CreateTransitionMenu(this, nullptr));
+		menu->exec(QCursor::pos());
+	};
+
+	auto onConfig = [this]() {
+		QMenu menu(this);
+		QAction *action;
+
+		auto toggleEditProperties = [this]() {
+			editPropertiesMode = !editPropertiesMode;
+
+			OBSSource actualScene = OBSGetStrongRef(programScene);
+			if (actualScene)
+				TransitionToScene(actualScene, true);
+		};
+
+		auto toggleSwapScenesMode = [this]() {
+			swapScenesMode = !swapScenesMode;
+		};
+
+		auto toggleSceneDuplication = [this]() {
+			sceneDuplicationMode = !sceneDuplicationMode;
+
+			OBSSource actualScene = OBSGetStrongRef(programScene);
+			if (actualScene)
+				TransitionToScene(actualScene, true);
+		};
+
+		auto showToolTip = [&]() {
+			QAction *act = menu.activeAction();
+			QToolTip::showText(QCursor::pos(), act->toolTip(), &menu, menu.actionGeometry(act));
+		};
+
+		action = menu.addAction(QTStr("QuickTransitions.DuplicateScene"));
+		action->setToolTip(QTStr("QuickTransitions.DuplicateSceneTT"));
+		action->setCheckable(true);
+		action->setChecked(sceneDuplicationMode);
+		connect(action, &QAction::triggered, toggleSceneDuplication);
+		connect(action, &QAction::hovered, showToolTip);
+
+		action = menu.addAction(QTStr("QuickTransitions.EditProperties"));
+		action->setToolTip(QTStr("QuickTransitions.EditPropertiesTT"));
+		action->setCheckable(true);
+		action->setChecked(editPropertiesMode);
+		action->setEnabled(sceneDuplicationMode);
+		connect(action, &QAction::triggered, toggleEditProperties);
+		connect(action, &QAction::hovered, showToolTip);
+
+		action = menu.addAction(QTStr("QuickTransitions.SwapScenes"));
+		action->setToolTip(QTStr("QuickTransitions.SwapScenesTT"));
+		action->setCheckable(true);
+		action->setChecked(swapScenesMode);
+		connect(action, &QAction::triggered, toggleSwapScenesMode);
+		connect(action, &QAction::hovered, showToolTip);
+
+		menu.exec(QCursor::pos());
+	};
+
+	connect(transitionButton.data(), &QAbstractButton::clicked, this, &OBSBasic::TransitionClicked);
+	connect(addQuickTransition, &QAbstractButton::clicked, onAdd);
+	connect(configTransitions, &QAbstractButton::clicked, onConfig);
+}
+
+void OBSBasic::TBarReleased()
+{
+	int val = tBar->value();
+
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+
+	if ((tBar->maximum() - val) <= T_BAR_CLAMP) {
+		obs_transition_set_manual_time(transition, 1.0f);
+		tBar->blockSignals(true);
+		tBar->setValue(0);
+		tBar->blockSignals(false);
+		tBarActive = false;
+		EnableTransitionWidgets(true);
+
+		OBSSourceAutoRelease target = obs_transition_get_active_source(transition);
+		const char *sceneName = obs_source_get_name(target);
+		blog(LOG_INFO, "Manual transition to scene '%s' finished", sceneName);
+	} else if (val <= T_BAR_CLAMP) {
+		obs_transition_set_manual_time(transition, 0.0f);
+		TransitionFullyStopped();
+		tBar->blockSignals(true);
+		tBar->setValue(0);
+		tBar->blockSignals(false);
+		tBarActive = false;
+		EnableTransitionWidgets(true);
+		programScene = lastProgramScene;
+		blog(LOG_INFO, "Manual transition cancelled");
+	}
+
+	tBar->clearFocus();
+}
+
+static bool ValidTBarTransition(OBSSource transition)
+{
+	if (!transition)
+		return false;
+
+	QString id = QT_UTF8(obs_source_get_id(transition));
+
+	if (id == "cut_transition" || id == "obs_stinger_transition")
+		return false;
+
+	return true;
+}
+
+void OBSBasic::TBarChanged(int value)
+{
+	OBSSourceAutoRelease transition = obs_get_output_source(0);
+
+	if (!tBarActive) {
+		OBSSource sceneSource = GetCurrentSceneSource();
+		OBSSource tBarTr = GetOverrideTransition(sceneSource);
+
+		if (!ValidTBarTransition(tBarTr)) {
+			tBarTr = GetCurrentTransition();
+
+			if (!ValidTBarTransition(tBarTr))
+				tBarTr = FindTransition(obs_source_get_display_name("fade_transition"));
+
+			OverrideTransition(tBarTr);
+			overridingTransition = true;
+
+			transition = std::move(tBarTr);
+		}
+
+		obs_transition_set_manual_torque(transition, 8.0f, 0.05f);
+		TransitionToScene(sceneSource, false, false, false, 0, true);
+		tBarActive = true;
+	}
+
+	obs_transition_set_manual_time(transition, (float)value / T_BAR_PRECISION_F);
+
+	OnEvent(OBS_FRONTEND_EVENT_TBAR_VALUE_CHANGED);
+}
+
+int OBSBasic::GetTbarPosition()
+{
+	return tBar->value();
+}
+
+void OBSBasic::TogglePreviewProgramMode()
+{
+	SetPreviewProgramMode(!IsPreviewProgramMode());
+}
+
+static inline void ResetQuickTransitionText(QuickTransition *qt)
+{
+	qt->button->setText(MakeQuickTransitionText(qt));
+}
+
+QMenu *OBSBasic::CreatePerSceneTransitionMenu()
+{
+	OBSSource scene = GetCurrentSceneSource();
+	QMenu *menu = new QMenu(QTStr("TransitionOverride"));
+	QAction *action;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+	obs_data_set_default_int(data, "transition_duration", 300);
+
+	const char *curTransition = obs_data_get_string(data, "transition");
+	int curDuration = (int)obs_data_get_int(data, "transition_duration");
+
+	QSpinBox *duration = new QSpinBox(menu);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(curDuration);
+
+	auto setTransition = [this](QAction *action) {
+		int idx = action->property("transition_index").toInt();
+		OBSSource scene = GetCurrentSceneSource();
+		OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+		if (idx == -1) {
+			obs_data_set_string(data, "transition", "");
+			return;
+		}
+
+		OBSSource tr = GetTransitionComboItem(ui->transitions, idx);
+
+		if (tr) {
+			const char *name = obs_source_get_name(tr);
+			obs_data_set_string(data, "transition", name);
+		}
+	};
+
+	auto setDuration = [this](int duration) {
+		OBSSource scene = GetCurrentSceneSource();
+		OBSDataAutoRelease data = obs_source_get_private_settings(scene);
+
+		obs_data_set_int(data, "transition_duration", duration);
+	};
+
+	connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, setDuration);
+
+	for (int i = -1; i < ui->transitions->count(); i++) {
+		const char *name = "";
+
+		if (i >= 0) {
+			OBSSource tr;
+			tr = GetTransitionComboItem(ui->transitions, i);
+			if (!tr)
+				continue;
+			name = obs_source_get_name(tr);
+		}
+
+		bool match = (name && strcmp(name, curTransition) == 0);
+
+		if (!name || !*name)
+			name = Str("None");
+
+		action = menu->addAction(QT_UTF8(name));
+		action->setProperty("transition_index", i);
+		action->setCheckable(true);
+		action->setChecked(match);
+
+		connect(action, &QAction::triggered, std::bind(setTransition, action));
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	return menu;
+}
+
+void OBSBasic::ShowTransitionProperties()
+{
+	OBSSceneItem item = GetCurrentSceneItem();
+	OBSSource source = obs_sceneitem_get_transition(item, true);
+
+	if (source)
+		CreatePropertiesWindow(source);
+}
+
+void OBSBasic::HideTransitionProperties()
+{
+	OBSSceneItem item = GetCurrentSceneItem();
+	OBSSource source = obs_sceneitem_get_transition(item, false);
+
+	if (source)
+		CreatePropertiesWindow(source);
+}
+
+void OBSBasic::PasteShowHideTransition(obs_sceneitem_t *item, bool show, obs_source_t *tr, int duration)
+{
+	int64_t sceneItemId = obs_sceneitem_get_id(item);
+	std::string sceneUUID = obs_source_get_uuid(obs_scene_get_source(obs_sceneitem_get_scene(item)));
+
+	auto undo_redo = [sceneUUID, sceneItemId, show](const std::string &data) {
+		OBSSourceAutoRelease source = obs_get_source_by_uuid(sceneUUID.c_str());
+		obs_scene_t *scene = obs_scene_from_source(source);
+		obs_sceneitem_t *i = obs_scene_find_sceneitem_by_id(scene, sceneItemId);
+		if (i) {
+			OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str());
+			obs_sceneitem_transition_load(i, dat, show);
+		}
+	};
+
+	OBSDataAutoRelease oldTransitionData = obs_sceneitem_transition_save(item, show);
+
+	OBSSourceAutoRelease dup = obs_source_duplicate(tr, obs_source_get_name(tr), true);
+	obs_sceneitem_set_transition(item, show, dup);
+	obs_sceneitem_set_transition_duration(item, show, duration);
+
+	OBSDataAutoRelease transitionData = obs_sceneitem_transition_save(item, show);
+
+	std::string undo_data(obs_data_get_json(oldTransitionData));
+	std::string redo_data(obs_data_get_json(transitionData));
+	if (undo_data.compare(redo_data) == 0)
+		return;
+
+	QString text = show ? QTStr("Undo.ShowTransition") : QTStr("Undo.HideTransition");
+	const char *name = obs_source_get_name(obs_sceneitem_get_source(item));
+	undo_s.add_action(text.arg(name), undo_redo, undo_redo, undo_data, redo_data);
+}
+
+QMenu *OBSBasic::CreateVisibilityTransitionMenu(bool visible)
+{
+	OBSSceneItem si = GetCurrentSceneItem();
+
+	QMenu *menu = new QMenu(QTStr(visible ? "ShowTransition" : "HideTransition"));
+	QAction *action;
+
+	OBSSource curTransition = obs_sceneitem_get_transition(si, visible);
+	const char *curId = curTransition ? obs_source_get_id(curTransition) : nullptr;
+	int curDuration = (int)obs_sceneitem_get_transition_duration(si, visible);
+
+	if (curDuration <= 0)
+		curDuration = obs_frontend_get_transition_duration();
+
+	QSpinBox *duration = new QSpinBox(menu);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(curDuration);
+
+	auto setTransition = [this](QAction *action, bool visible) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		QString id = action->property("transition_id").toString();
+		OBSSceneItem sceneItem = main->GetCurrentSceneItem();
+		int64_t sceneItemId = obs_sceneitem_get_id(sceneItem);
+		std::string sceneUUID = obs_source_get_uuid(obs_scene_get_source(obs_sceneitem_get_scene(sceneItem)));
+
+		auto undo_redo = [sceneUUID, sceneItemId, visible](const std::string &data) {
+			OBSSourceAutoRelease source = obs_get_source_by_uuid(sceneUUID.c_str());
+			obs_scene_t *scene = obs_scene_from_source(source);
+			obs_sceneitem_t *i = obs_scene_find_sceneitem_by_id(scene, sceneItemId);
+			if (i) {
+				OBSDataAutoRelease dat = obs_data_create_from_json(data.c_str());
+				obs_sceneitem_transition_load(i, dat, visible);
+			}
+		};
+		OBSDataAutoRelease oldTransitionData = obs_sceneitem_transition_save(sceneItem, visible);
+		if (id.isNull() || id.isEmpty()) {
+			obs_sceneitem_set_transition(sceneItem, visible, nullptr);
+			obs_sceneitem_set_transition_duration(sceneItem, visible, 0);
+		} else {
+			OBSSource tr = obs_sceneitem_get_transition(sceneItem, visible);
+
+			if (!tr || strcmp(QT_TO_UTF8(id), obs_source_get_id(tr)) != 0) {
+				QString name = QT_UTF8(obs_source_get_name(obs_sceneitem_get_source(sceneItem)));
+				name += " ";
+				name += QTStr(visible ? "ShowTransition" : "HideTransition");
+				tr = obs_source_create_private(QT_TO_UTF8(id), QT_TO_UTF8(name), nullptr);
+				obs_sceneitem_set_transition(sceneItem, visible, tr);
+				obs_source_release(tr);
+
+				int duration = (int)obs_sceneitem_get_transition_duration(sceneItem, visible);
+				if (duration <= 0) {
+					duration = obs_frontend_get_transition_duration();
+					obs_sceneitem_set_transition_duration(sceneItem, visible, duration);
+				}
+			}
+			if (obs_source_configurable(tr))
+				CreatePropertiesWindow(tr);
+		}
+		OBSDataAutoRelease newTransitionData = obs_sceneitem_transition_save(sceneItem, visible);
+		std::string undo_data(obs_data_get_json(oldTransitionData));
+		std::string redo_data(obs_data_get_json(newTransitionData));
+		if (undo_data.compare(redo_data) != 0)
+			main->undo_s.add_action(QTStr(visible ? "Undo.ShowTransition" : "Undo.HideTransition")
+							.arg(obs_source_get_name(obs_sceneitem_get_source(sceneItem))),
+						undo_redo, undo_redo, undo_data, redo_data);
+	};
+	auto setDuration = [visible](int duration) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+		OBSSceneItem item = main->GetCurrentSceneItem();
+		obs_sceneitem_set_transition_duration(item, visible, duration);
+	};
+	connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, setDuration);
+
+	action = menu->addAction(QT_UTF8(Str("None")));
+	action->setProperty("transition_id", QT_UTF8(""));
+	action->setCheckable(true);
+	action->setChecked(!curId);
+	connect(action, &QAction::triggered, std::bind(setTransition, action, visible));
+	size_t idx = 0;
+	const char *id;
+	while (obs_enum_transition_types(idx++, &id)) {
+		const char *name = obs_source_get_display_name(id);
+		const bool match = id && curId && strcmp(id, curId) == 0;
+		action = menu->addAction(QT_UTF8(name));
+		action->setProperty("transition_id", QT_UTF8(id));
+		action->setCheckable(true);
+		action->setChecked(match);
+		connect(action, &QAction::triggered, std::bind(setTransition, action, visible));
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	if (curId && obs_is_source_configurable(curId)) {
+		menu->addSeparator();
+		menu->addAction(QTStr("Properties"), this,
+				visible ? &OBSBasic::ShowTransitionProperties : &OBSBasic::HideTransitionProperties);
+	}
+
+	auto copyTransition = [this](QAction *, bool visible) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSceneItem item = main->GetCurrentSceneItem();
+		obs_source_t *tr = obs_sceneitem_get_transition(item, visible);
+		int trDur = obs_sceneitem_get_transition_duration(item, visible);
+		main->copySourceTransition = obs_source_get_weak_source(tr);
+		main->copySourceTransitionDuration = trDur;
+	};
+	menu->addSeparator();
+	action = menu->addAction(QT_UTF8(Str("Copy")));
+	action->setEnabled(curId != nullptr);
+	connect(action, &QAction::triggered, std::bind(copyTransition, action, visible));
+
+	auto pasteTransition = [this](QAction *, bool show) {
+		OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+		OBSSource tr = OBSGetStrongRef(main->copySourceTransition);
+		int trDuration = main->copySourceTransitionDuration;
+		if (!tr)
+			return;
+
+		for (auto &selectedSource : GetAllSelectedSourceItems()) {
+			OBSSceneItem item = main->ui->sources->Get(selectedSource.row());
+			if (!item)
+				continue;
+
+			PasteShowHideTransition(item, show, tr, trDuration);
+		}
+	};
+
+	action = menu->addAction(QT_UTF8(Str("Paste")));
+	action->setEnabled(!!OBSGetStrongRef(copySourceTransition));
+	connect(action, &QAction::triggered, std::bind(pasteTransition, action, visible));
+	return menu;
+}
+
+QMenu *OBSBasic::CreateTransitionMenu(QWidget *parent, QuickTransition *qt)
+{
+	QMenu *menu = new QMenu(parent);
+	QAction *action;
+	OBSSource tr;
+
+	if (qt) {
+		action = menu->addAction(QTStr("Remove"));
+		action->setProperty("id", qt->id);
+		connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionRemoveClicked);
+
+		menu->addSeparator();
+	}
+
+	QSpinBox *duration = new QSpinBox(menu);
+	if (qt)
+		duration->setProperty("id", qt->id);
+	duration->setMinimum(50);
+	duration->setSuffix(" ms");
+	duration->setMaximum(20000);
+	duration->setSingleStep(50);
+	duration->setValue(qt ? qt->duration : 300);
+
+	if (qt) {
+		connect(duration, (void(QSpinBox::*)(int)) & QSpinBox::valueChanged, this,
+			&OBSBasic::QuickTransitionChangeDuration);
+	}
+
+	tr = fadeTransition;
+
+	action = menu->addAction(QTStr("FadeToBlack"));
+	action->setProperty("fadeToBlack", true);
+
+	if (qt) {
+		action->setProperty("id", qt->id);
+		connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionChange);
+	} else {
+		action->setProperty("duration", QVariant::fromValue<QWidget *>(duration));
+		connect(action, &QAction::triggered, this, &OBSBasic::AddQuickTransition);
+	}
+
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		tr = GetTransitionComboItem(ui->transitions, i);
+
+		if (!tr)
+			continue;
+
+		action = menu->addAction(obs_source_get_name(tr));
+		action->setProperty("transition_index", i);
+
+		if (qt) {
+			action->setProperty("id", qt->id);
+			connect(action, &QAction::triggered, this, &OBSBasic::QuickTransitionChange);
+		} else {
+			action->setProperty("duration", QVariant::fromValue<QWidget *>(duration));
+			connect(action, &QAction::triggered, this, &OBSBasic::AddQuickTransition);
+		}
+	}
+
+	QWidgetAction *durationAction = new QWidgetAction(menu);
+	durationAction->setDefaultWidget(duration);
+
+	menu->addSeparator();
+	menu->addAction(durationAction);
+	return menu;
+}
+
+void OBSBasic::AddQuickTransitionId(int id)
+{
+	QuickTransition *qt = GetQuickTransition(id);
+	if (!qt)
+		return;
+
+	/* --------------------------------- */
+
+	QPushButton *button = new MenuButton();
+	button->setProperty("id", id);
+
+	qt->button = button;
+	ResetQuickTransitionText(qt);
+
+	/* --------------------------------- */
+
+	QMenu *buttonMenu = CreateTransitionMenu(button, qt);
+
+	/* --------------------------------- */
+
+	button->setMenu(buttonMenu);
+	connect(button, &QAbstractButton::clicked, this, &OBSBasic::QuickTransitionClicked);
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	int idx = 3;
+	for (;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget || !widget->property("id").isValid())
+			break;
+	}
+
+	programLayout->insertWidget(idx, button);
+}
+
+void OBSBasic::AddQuickTransition()
+{
+	int trIdx = sender()->property("transition_index").toInt();
+	QSpinBox *duration = sender()->property("duration").value<QSpinBox *>();
+	bool fadeToBlack = sender()->property("fadeToBlack").value<bool>();
+	OBSSource transition = fadeToBlack ? OBSSource(fadeTransition) : GetTransitionComboItem(ui->transitions, trIdx);
+
+	if (!transition)
+		return;
+
+	int id = quickTransitionIdCounter++;
+
+	quickTransitions.emplace_back(transition, duration->value(), id, fadeToBlack);
+	AddQuickTransitionId(id);
+
+	int idx = (int)quickTransitions.size() - 1;
+	AddQuickTransitionHotkey(&quickTransitions[idx]);
+}
+
+void OBSBasic::ClearQuickTransitions()
+{
+	for (QuickTransition &qt : quickTransitions)
+		RemoveQuickTransitionHotkey(&qt);
+	quickTransitions.clear();
+
+	if (!programOptions)
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget)
+			continue;
+
+		int id = widget->property("id").toInt();
+		if (id != 0) {
+			delete widget;
+			idx--;
+		}
+	}
+}
+
+void OBSBasic::QuickTransitionClicked()
+{
+	int id = sender()->property("id").toInt();
+	TriggerQuickTransition(id);
+}
+
+void OBSBasic::QuickTransitionChange()
+{
+	int id = sender()->property("id").toInt();
+	int trIdx = sender()->property("transition_index").toInt();
+	bool fadeToBlack = sender()->property("fadeToBlack").value<bool>();
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt) {
+		OBSSource tr = fadeToBlack ? OBSSource(fadeTransition) : GetTransitionComboItem(ui->transitions, trIdx);
+		if (tr) {
+			qt->source = tr;
+			qt->fadeToBlack = fadeToBlack;
+			ResetQuickTransitionText(qt);
+		}
+	}
+}
+
+void OBSBasic::QuickTransitionChangeDuration(int value)
+{
+	int id = sender()->property("id").toInt();
+	QuickTransition *qt = GetQuickTransition(id);
+
+	if (qt) {
+		qt->duration = value;
+		ResetQuickTransitionText(qt);
+	}
+}
+
+void OBSBasic::QuickTransitionRemoveClicked()
+{
+	int id = sender()->property("id").toInt();
+	int idx = GetQuickTransitionIdx(id);
+	if (idx == -1)
+		return;
+
+	QuickTransition &qt = quickTransitions[idx];
+
+	if (qt.button)
+		qt.button->deleteLater();
+
+	RemoveQuickTransitionHotkey(&qt);
+	quickTransitions.erase(quickTransitions.begin() + idx);
+}
+
+void OBSBasic::ClearQuickTransitionWidgets()
+{
+	if (!IsPreviewProgramMode())
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QWidget *widget = item->widget();
+		if (!widget)
+			continue;
+
+		int id = widget->property("id").toInt();
+		if (id != 0) {
+			delete widget;
+			idx--;
+		}
+	}
+}
+
+void OBSBasic::RefreshQuickTransitions()
+{
+	if (!IsPreviewProgramMode())
+		return;
+
+	for (QuickTransition &qt : quickTransitions)
+		AddQuickTransitionId(qt.id);
+}
+
+void OBSBasic::EnableTransitionWidgets(bool enable)
+{
+	ui->transitions->setEnabled(enable);
+
+	if (!enable) {
+		ui->transitionProps->setEnabled(false);
+	} else {
+		bool configurable = obs_source_configurable(GetCurrentTransition());
+		ui->transitionProps->setEnabled(configurable);
+	}
+
+	if (!IsPreviewProgramMode())
+		return;
+
+	QVBoxLayout *programLayout = reinterpret_cast<QVBoxLayout *>(programOptions->layout());
+
+	for (int idx = 0;; idx++) {
+		QLayoutItem *item = programLayout->itemAt(idx);
+		if (!item)
+			break;
+
+		QPushButton *button = qobject_cast<QPushButton *>(item->widget());
+		if (!button)
+			continue;
+
+		button->setEnabled(enable);
+	}
+
+	if (transitionButton)
+		transitionButton->setEnabled(enable);
+}
+
+void OBSBasic::SetPreviewProgramMode(bool enabled)
+{
+	if (IsPreviewProgramMode() == enabled)
+		return;
+
+	os_atomic_set_bool(&previewProgramMode, enabled);
+	emit PreviewProgramModeChanged(enabled);
+
+	if (IsPreviewProgramMode()) {
+		if (!previewEnabled)
+			EnablePreviewDisplay(true);
+
+		CreateProgramDisplay();
+		CreateProgramOptions();
+
+		OBSScene curScene = GetCurrentScene();
+
+		OBSSceneAutoRelease dup;
+		if (sceneDuplicationMode) {
+			dup = obs_scene_duplicate(curScene, obs_source_get_name(obs_scene_get_source(curScene)),
+						  editPropertiesMode ? OBS_SCENE_DUP_PRIVATE_COPY
+								     : OBS_SCENE_DUP_PRIVATE_REFS);
+		} else {
+			dup = std::move(OBSScene(curScene));
+		}
+
+		OBSSourceAutoRelease transition = obs_get_output_source(0);
+		obs_source_t *dup_source = obs_scene_get_source(dup);
+		obs_transition_set(transition, dup_source);
+
+		if (curScene) {
+			obs_source_t *source = obs_scene_get_source(curScene);
+			obs_source_inc_showing(source);
+			lastScene = OBSGetWeakRef(source);
+			programScene = OBSGetWeakRef(source);
+		}
+
+		RefreshQuickTransitions();
+
+		programLabel = new QLabel(QTStr("StudioMode.ProgramSceneLabel"), this);
+		programLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+		programLabel->setProperty("class", "label-preview-title");
+
+		programWidget = new QWidget();
+		programLayout = new QVBoxLayout();
+
+		programLayout->setContentsMargins(0, 0, 0, 0);
+		programLayout->setSpacing(0);
+
+		programLayout->addWidget(programLabel);
+		programLayout->addWidget(program);
+
+		programWidget->setLayout(programLayout);
+
+		ui->previewLayout->addWidget(programOptions);
+		ui->previewLayout->addWidget(programWidget);
+		ui->previewLayout->setAlignment(programOptions, Qt::AlignCenter);
+
+		OnEvent(OBS_FRONTEND_EVENT_STUDIO_MODE_ENABLED);
+
+		blog(LOG_INFO, "Switched to Preview/Program mode");
+		blog(LOG_INFO, "-----------------------------"
+			       "-------------------");
+	} else {
+		OBSSource actualProgramScene = OBSGetStrongRef(programScene);
+		if (!actualProgramScene)
+			actualProgramScene = GetCurrentSceneSource();
+		else
+			SetCurrentScene(actualProgramScene, true);
+		TransitionToScene(actualProgramScene, true);
+
+		delete programOptions;
+		delete program;
+		delete programLabel;
+		delete programWidget;
+
+		if (lastScene) {
+			OBSSource actualLastScene = OBSGetStrongRef(lastScene);
+			if (actualLastScene)
+				obs_source_dec_showing(actualLastScene);
+			lastScene = nullptr;
+		}
+
+		programScene = nullptr;
+		swapScene = nullptr;
+		prevFTBSource = nullptr;
+
+		for (QuickTransition &qt : quickTransitions)
+			qt.button = nullptr;
+
+		if (!previewEnabled)
+			EnablePreviewDisplay(false);
+
+		ui->transitions->setEnabled(true);
+		tBarActive = false;
+
+		OnEvent(OBS_FRONTEND_EVENT_STUDIO_MODE_DISABLED);
+
+		blog(LOG_INFO, "Switched to regular Preview mode");
+		blog(LOG_INFO, "-----------------------------"
+			       "-------------------");
+	}
+
+	ResetUI();
+	UpdateTitleBar();
+}
+
+void OBSBasic::RenderProgram(void *data, uint32_t, uint32_t)
+{
+	GS_DEBUG_MARKER_BEGIN(GS_DEBUG_COLOR_DEFAULT, "RenderProgram");
+
+	OBSBasic *window = static_cast<OBSBasic *>(data);
+	obs_video_info ovi;
+
+	obs_get_video_info(&ovi);
+
+	window->programCX = int(window->programScale * float(ovi.base_width));
+	window->programCY = int(window->programScale * float(ovi.base_height));
+
+	gs_viewport_push();
+	gs_projection_push();
+
+	/* --------------------------------------- */
+
+	gs_ortho(0.0f, float(ovi.base_width), 0.0f, float(ovi.base_height), -100.0f, 100.0f);
+	gs_set_viewport(window->programX, window->programY, window->programCX, window->programCY);
+
+	obs_render_main_texture_src_color_only();
+	gs_load_vertexbuffer(nullptr);
+
+	/* --------------------------------------- */
+
+	gs_projection_pop();
+	gs_viewport_pop();
+
+	GS_DEBUG_MARKER_END();
+}
+
+void OBSBasic::ResizeProgram(uint32_t cx, uint32_t cy)
+{
+	QSize targetSize;
+
+	/* resize program panel to fix to the top section of the window */
+	targetSize = GetPixelSize(program);
+	GetScaleAndCenterPos(int(cx), int(cy), targetSize.width() - PREVIEW_EDGE_SIZE * 2,
+			     targetSize.height() - PREVIEW_EDGE_SIZE * 2, programX, programY, programScale);
+
+	programX += float(PREVIEW_EDGE_SIZE);
+	programY += float(PREVIEW_EDGE_SIZE);
+}
+
+obs_data_array_t *OBSBasic::SaveTransitions()
+{
+	obs_data_array_t *transitions = obs_data_array_create();
+
+	for (int i = 0; i < ui->transitions->count(); i++) {
+		OBSSource tr = ui->transitions->itemData(i).value<OBSSource>();
+		if (!tr || !obs_source_configurable(tr))
+			continue;
+
+		OBSDataAutoRelease sourceData = obs_data_create();
+		OBSDataAutoRelease settings = obs_source_get_settings(tr);
+
+		obs_data_set_string(sourceData, "name", obs_source_get_name(tr));
+		obs_data_set_string(sourceData, "id", obs_obj_get_id(tr));
+		obs_data_set_obj(sourceData, "settings", settings);
+
+		obs_data_array_push_back(transitions, sourceData);
+	}
+
+	for (const OBSDataAutoRelease &transition : safeModeTransitions) {
+		obs_data_array_push_back(transitions, transition);
+	}
+
+	return transitions;
+}
+
+void OBSBasic::LoadTransitions(obs_data_array_t *transitions, obs_load_source_cb cb, void *private_data)
+{
+	size_t count = obs_data_array_count(transitions);
+
+	safeModeTransitions.clear();
+	for (size_t i = 0; i < count; i++) {
+		OBSDataAutoRelease item = obs_data_array_item(transitions, i);
+		const char *name = obs_data_get_string(item, "name");
+		const char *id = obs_data_get_string(item, "id");
+		OBSDataAutoRelease settings = obs_data_get_obj(item, "settings");
+
+		OBSSourceAutoRelease source = obs_source_create_private(id, name, settings);
+		if (!obs_obj_invalid(source)) {
+			InitTransition(source);
+
+			ui->transitions->addItem(QT_UTF8(name), QVariant::fromValue(OBSSource(source)));
+			ui->transitions->setCurrentIndex(ui->transitions->count() - 1);
+			if (cb)
+				cb(private_data, source);
+		} else if (safe_mode || disable_3p_plugins) {
+			safeModeTransitions.push_back(std::move(item));
+		}
+	}
+}
+
+OBSSource OBSBasic::GetOverrideTransition(OBSSource source)
+{
+	if (!source)
+		return nullptr;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(source);
+
+	const char *trOverrideName = obs_data_get_string(data, "transition");
+
+	OBSSource trOverride = nullptr;
+
+	if (trOverrideName && *trOverrideName)
+		trOverride = FindTransition(trOverrideName);
+
+	return trOverride;
+}
+
+int OBSBasic::GetOverrideTransitionDuration(OBSSource source)
+{
+	if (!source)
+		return 300;
+
+	OBSDataAutoRelease data = obs_source_get_private_settings(source);
+	obs_data_set_default_int(data, "transition_duration", 300);
+
+	return (int)obs_data_get_int(data, "transition_duration");
+}
+
+void OBSBasic::UpdatePreviewProgramIndicators()
+{
+	bool labels = previewProgramMode ? config_get_bool(App()->GetUserConfig(), "BasicWindow", "StudioModeLabels")
+					 : false;
+
+	ui->previewLabel->setVisible(labels);
+
+	if (programLabel)
+		programLabel->setVisible(labels);
+
+	if (!labels)
+		return;
+
+	QString preview =
+		QTStr("StudioMode.PreviewSceneName").arg(QT_UTF8(obs_source_get_name(GetCurrentSceneSource())));
+
+	QString program = QTStr("StudioMode.ProgramSceneName").arg(QT_UTF8(obs_source_get_name(GetProgramSource())));
+
+	if (ui->previewLabel->text() != preview)
+		ui->previewLabel->setText(preview);
+
+	if (programLabel && programLabel->text() != program)
+		programLabel->setText(program);
+}
 /******************************************************************************
     Copyright (C) 2023 by Lain Bailey <[email protected]>
                           Zachary Lund <[email protected]>