chore: baseline usable version

This commit is contained in:
dela
2026-06-10 11:17:51 +08:00
commit a2f82a86b6
932 changed files with 172551 additions and 0 deletions

183
src/qEmbyApp/CMakeLists.txt Normal file
View File

@@ -0,0 +1,183 @@
# src/qEmbyApp/CMakeLists.txt
set(TS_FILES
resources/i18n/qEmby_zh_CN.ts
resources/i18n/qEmby_fr_FR.ts
)
# set(QSS_QWK_FILES ../../libs/qwindowkit/examples/mainwindow/mainwindow.qrc)
# set(QRC_QWK_FILES ../../libs/qwindowkit/examples/shared/resources/shared.qrc)
set(APP_QRC_FILES resources/resources.qrc)
file(GLOB_RECURSE APP_HEADERS CONFIGURE_DEPENDS "*.h")
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "*.cpp")
# ==========================================
# 平台原生资源配置 (如 Windows .exe 图标)
# ==========================================
if(WIN32)
set(APP_NATIVE_RESOURCES qEmbyApp.rc)
endif()
qt_add_executable(qEmbyApp
MANUAL_FINALIZATION
${APP_HEADERS}
${APP_SOURCES}
${TS_FILES}
${APP_QRC_FILES}
${APP_NATIVE_RESOURCES} # <-- 将 .rc 资源文件加入编译列表
)
# ==========================================
# 集成 libmpv 播放器核心
# Windows 使用仓库内置 libmpv
# Linux / macOS 优先使用系统安装的 libmpv
# ==========================================
qemby_link_libmpv(qEmbyApp)
# ==========================================
# 集成 MDK 播放器核心
# ==========================================
# 1. 动态定位根目录下的 libs/mdk-sdk 文件夹
# 如果 CMAKE_SOURCE_DIR 指向了 src/qEmbyApp这里的路径可能会错
# 但既然你之前 libmpv 能用,说明 CMAKE_SOURCE_DIR 是 qEmby 根目录。
# set(MDK_DIR "${CMAKE_SOURCE_DIR}/libs/mdk-sdk")
# set(MDK_DLL_FILE "${MDK_DIR}/bin/x64/mdk.dll")
# 2. 引入头文件目录
# target_include_directories(qEmbyApp PRIVATE "${MDK_DIR}/include")
# 3. 终极解法:直接指定 mdk.lib 的绝对物理路径
# set(MDK_LIB_FILE "${MDK_DIR}/lib/x64/mdk.lib")
# 如果你想加个保险,可以取消下面三行的注释来让 CMake 在生成期帮你检查路径是否正确
# if(NOT EXISTS "${MDK_LIB_FILE}")
# message(FATAL_ERROR "绝对路径错误!找不到文件: ${MDK_LIB_FILE}")
# endif()
# add_custom_command(TARGET qEmbyApp POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E copy_if_different
# "${MDK_DLL_FILE}"
# $<TARGET_FILE_DIR:qEmbyApp>
# COMMENT "正在自动拷贝 mdk.dll 到输出目录..."
# )
target_compile_definitions(qEmbyApp PRIVATE
APP_NAME="${PROJECT_NAME}"
APP_VERSION="${PROJECT_VERSION}"
)
target_link_libraries(qEmbyApp PRIVATE
Qt${QT_VERSION_MAJOR}::Gui
Qt${QT_VERSION_MAJOR}::Widgets
Qt${QT_VERSION_MAJOR}::Network
Qt${QT_VERSION_MAJOR}::OpenGLWidgets
Qt${QT_VERSION_MAJOR}::Svg
Qt${QT_VERSION_MAJOR}::Concurrent
qEmbyCore
QWKWidgets
WidgetFrame
QCoro6::Core
QCoro6::Network
spdlog::spdlog
)
target_link_libraries(qEmbyApp PRIVATE Qt${QT_VERSION_MAJOR}::Core)
if(APPLE)
target_link_libraries(qEmbyApp PRIVATE
"-framework CoreFoundation"
"-framework IOKit"
)
endif()
if(TARGET Qt${QT_VERSION_MAJOR}::DBus)
target_link_libraries(qEmbyApp PRIVATE Qt${QT_VERSION_MAJOR}::DBus)
target_compile_definitions(qEmbyApp PRIVATE QEMBY_HAS_QT_DBUS=1)
endif()
# # 3. 处理翻译
# if(${QT_VERSION_MAJOR} GREATER_EQUAL 6)
# # 注意路径从 ${CMAKE_SOURCE_DIR} 改为 ${CMAKE_CURRENT_SOURCE_DIR}
# qt_create_translation(QM_FILES ${CMAKE_CURRENT_SOURCE_DIR} ${TS_FILES})
# else()
# qt5_create_translation(QM_FILES ${CMAKE_CURRENT_SOURCE_DIR} ${TS_FILES})
# endif()
if(QT_VERSION_MAJOR EQUAL 6)
# 自动编译 TS 为 QM并将生成的 QM 虚拟嵌入到 :/i18n/ 资源路径下
qt_add_translations(qEmbyApp
TS_FILES ${TS_FILES}
RESOURCE_PREFIX "/i18n"
LUPDATE_OPTIONS -no-obsolete
)
else()
# 兼容 Qt 5.x 保留的后备方案
qt5_create_translation(QM_FILES ${CMAKE_CURRENT_SOURCE_DIR} ${TS_FILES})
target_sources(qEmbyApp PRIVATE ${QM_FILES})
endif()
# 4. 设置平台属性 (Windows 和 macOS)
if(${QT_VERSION} VERSION_LESS 6.1.0)
set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.hl.qEmby)
endif()
# 注意:目标从 qEmby 改为 qEmbyApp
set_target_properties(qEmbyApp PROPERTIES
${BUNDLE_ID_OPTION}
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE
# MACOSX_BUNDLE_ICON_FILE app.icns # 如果未来需要适配 macOS 图标,取消此注释
)
# 5. 设置安装规则
include(GNUInstallDirs)
set(_qemby_app_runtime_install_dir "${CMAKE_INSTALL_BINDIR}")
if(UNIX AND NOT APPLE AND QEMBY_RUNTIME_LIB_SUBDIR)
set(_qemby_app_runtime_install_dir
"${CMAKE_INSTALL_LIBDIR}/${QEMBY_RUNTIME_LIB_SUBDIR}")
endif()
# 注意:目标从 qEmby 改为 qEmbyApp
install(TARGETS qEmbyApp
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${_qemby_app_runtime_install_dir}
)
# 6. 最终确定 (必须在 install() 之后)
if(QT_VERSION_MAJOR EQUAL 6)
# 注意:目标从 qEmby 改为 qEmbyApp
qt_finalize_executable(qEmbyApp)
endif()
# ==========================================
# 终极防弹方案:调用 windeployqt 自动补全所有 Qt 依赖
# ==========================================
if(WIN32)
# 找到 Qt 官方的部署工具
set(_windeployqt_hints)
if(QEMBY_QT_ROOT)
list(APPEND _windeployqt_hints "${QEMBY_QT_ROOT}/bin")
endif()
if(DEFINED ENV{QTDIR} AND NOT "$ENV{QTDIR}" STREQUAL "")
list(APPEND _windeployqt_hints "$ENV{QTDIR}/bin")
endif()
list(APPEND _windeployqt_hints "E:/Qt6/6.9.2/msvc2022_64/bin")
list(REMOVE_DUPLICATES _windeployqt_hints)
find_program(WINDEPLOYQT_EXECUTABLE windeployqt PATHS ${_windeployqt_hints})
if(WINDEPLOYQT_EXECUTABLE)
# 在 qEmbyApp 编译完成后的那一刻,自动执行该工具
add_custom_command(TARGET qEmbyApp POST_BUILD
COMMAND ${WINDEPLOYQT_EXECUTABLE} $<TARGET_FILE:qEmbyApp>
COMMENT "正在召唤 windeployqt 自动补全所有的 Qt DLL 和底层插件..."
)
else()
message(WARNING "未找到 windeployqt自动部署失败")
endif()
endif()

View File

@@ -0,0 +1,709 @@
#include "addtoplaylistdialog.h"
#include "loadingoverlay.h"
#include "moderntoast.h"
#include "playlistcreateeditorrow.h"
#include "../managers/thememanager.h"
#include "../utils/playlistutils.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/media/mediaservice.h>
#include <services/manager/servermanager.h>
#include <models/profile/serverprofile.h>
#include <QAbstractItemView>
#include <QAction>
#include <QDebug>
#include <QHash>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPointer>
#include <QPushButton>
#include <QResizeEvent>
#include <QShowEvent>
#include <QSignalBlocker>
#include <QVBoxLayout>
#include <stdexcept>
#include <utility>
namespace {
QHash<QString, QString> s_playlistViewIdCache;
constexpr int kCreatePlaylistItemRole = Qt::UserRole + 2;
}
AddToPlaylistDialog::AddToPlaylistDialog(QEmbyCore* core,
QString targetPlaylistMediaType,
QWidget* parent)
: ModernDialogBase(parent),
m_core(core),
m_targetPlaylistMediaType(
PlaylistUtils::normalizePlaylistMediaType(targetPlaylistMediaType))
{
setMinimumWidth(400);
setTitle(tr("Add to Playlist"));
contentLayout()->setSpacing(0);
m_promptLabel = new QLabel(tr("Select a playlist"), this);
m_promptLabel->setObjectName("dialog-text");
contentLayout()->addWidget(m_promptLabel);
contentLayout()->addSpacing(8);
m_searchEdit = new QLineEdit(this);
m_searchEdit->setObjectName("PlaylistSearchEdit");
m_searchEdit->setPlaceholderText(tr("Search playlists..."));
m_searchEdit->setClearButtonEnabled(true);
m_searchEdit->addAction(
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/search.svg")),
QLineEdit::LeadingPosition);
contentLayout()->addWidget(m_searchEdit);
contentLayout()->addSpacing(10);
m_playlistListContainer = new QWidget(this);
auto* listLayout = new QVBoxLayout(m_playlistListContainer);
listLayout->setContentsMargins(0, 0, 0, 0);
listLayout->setSpacing(0);
m_playlistList = new QListWidget(m_playlistListContainer);
m_playlistList->setObjectName("ManageLibPathList");
m_playlistList->setSelectionMode(QAbstractItemView::SingleSelection);
m_playlistList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_playlistList->setMinimumHeight(260);
m_playlistList->setAlternatingRowColors(false);
m_playlistList->setUniformItemSizes(false);
listLayout->addWidget(m_playlistList);
m_loadingOverlay = new LoadingOverlay(m_playlistListContainer);
m_loadingOverlay->setHudPanelVisible(false);
m_loadingOverlay->setSubtleOverlay(true);
contentLayout()->addWidget(m_playlistListContainer);
contentLayout()->addSpacing(24);
auto* buttonLayout = new QHBoxLayout();
buttonLayout->setSpacing(12);
buttonLayout->addStretch();
auto* cancelButton = new QPushButton(tr("Cancel"), this);
cancelButton->setObjectName("dialog-btn-cancel");
cancelButton->setCursor(Qt::PointingHandCursor);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
buttonLayout->addWidget(cancelButton);
m_addButton = new QPushButton(tr("Add"), this);
m_addButton->setObjectName("dialog-btn-primary");
m_addButton->setCursor(Qt::PointingHandCursor);
m_addButton->setEnabled(false);
buttonLayout->addWidget(m_addButton);
contentLayout()->addLayout(buttonLayout);
connect(m_searchEdit, &QLineEdit::textChanged, this,
[this]() { rebuildPlaylistList(); });
connect(m_searchEdit, &QLineEdit::returnPressed, this, [this]() {
if (!m_playlistList->currentItem() && m_playlistList->count() == 1) {
m_playlistList->setCurrentRow(0);
}
acceptSelectedPlaylist();
});
connect(m_playlistList, &QListWidget::itemSelectionChanged, this,
[this]() { updateAddButtonState(); });
connect(m_playlistList, &QListWidget::itemDoubleClicked, this,
[this](QListWidgetItem*) { acceptSelectedPlaylist(); });
connect(m_addButton, &QPushButton::clicked, this,
[this]() { acceptSelectedPlaylist(); });
updateUiState();
updateLoadingOverlayGeometry();
}
QString AddToPlaylistDialog::selectedPlaylistId() const
{
const QListWidgetItem* item = m_playlistList ? m_playlistList->currentItem()
: nullptr;
if (!item || item->data(kCreatePlaylistItemRole).toBool()) {
return {};
}
return item->data(Qt::UserRole).toString();
}
QString AddToPlaylistDialog::selectedPlaylistName() const
{
const QListWidgetItem* item = m_playlistList ? m_playlistList->currentItem()
: nullptr;
if (!item || item->data(kCreatePlaylistItemRole).toBool()) {
return {};
}
return item->data(Qt::UserRole + 1).toString();
}
void AddToPlaylistDialog::showEvent(QShowEvent* event)
{
ModernDialogBase::showEvent(event);
updateLoadingOverlayGeometry();
m_searchEdit->setFocus();
if (!m_loaded) {
m_loaded = true;
m_pendingTask = loadPlaylists();
}
}
void AddToPlaylistDialog::resizeEvent(QResizeEvent* event)
{
ModernDialogBase::resizeEvent(event);
updateLoadingOverlayGeometry();
}
QString AddToPlaylistDialog::playlistViewCacheKey() const
{
if (!m_core || !m_core->serverManager()) {
return {};
}
const ServerProfile profile = m_core->serverManager()->activeProfile();
if (!profile.isValid()) {
return {};
}
return QStringLiteral("%1::%2").arg(profile.id, profile.userId);
}
QCoro::Task<QString> AddToPlaylistDialog::resolvePlaylistViewId(bool forceRefresh)
{
QPointer<QEmbyCore> safeCore(m_core);
QPointer<MediaService> mediaService(m_core ? m_core->mediaService() : nullptr);
if (!safeCore || !mediaService) {
co_return {};
}
auto findPlaylistViewId = [](const QList<MediaItem>& views) -> QString {
for (const MediaItem& view : views) {
if (view.collectionType.compare(QStringLiteral("playlists"),
Qt::CaseInsensitive) == 0 &&
!view.id.isEmpty()) {
return view.id;
}
}
return {};
};
const QString cacheKey = playlistViewCacheKey();
if (!forceRefresh && !cacheKey.isEmpty()) {
const auto it = s_playlistViewIdCache.constFind(cacheKey);
if (it != s_playlistViewIdCache.constEnd() && !it.value().isEmpty()) {
co_return it.value();
}
}
if (forceRefresh) {
mediaService->clearUserViewsCache();
}
const QList<MediaItem> views = co_await mediaService->getUserViews();
if (!safeCore || !mediaService) {
co_return {};
}
QString playlistViewId = findPlaylistViewId(views);
if (playlistViewId.isEmpty()) {
const QList<MediaItem> hiddenViews =
co_await mediaService->getUserViews(true);
if (!safeCore || !mediaService) {
co_return {};
}
playlistViewId = findPlaylistViewId(hiddenViews);
}
if (!cacheKey.isEmpty()) {
if (playlistViewId.isEmpty()) {
s_playlistViewIdCache.remove(cacheKey);
} else {
s_playlistViewIdCache.insert(cacheKey, playlistViewId);
}
}
co_return playlistViewId;
}
QCoro::Task<void> AddToPlaylistDialog::loadPlaylists(QString preferredPlaylistId)
{
QPointer<AddToPlaylistDialog> safeThis(this);
QPointer<QEmbyCore> safeCore(m_core);
QPointer<MediaService> mediaService(m_core ? m_core->mediaService() : nullptr);
QPointer<AdminService> adminService(m_core ? m_core->adminService() : nullptr);
if (!safeThis || !safeCore || !mediaService) {
co_return;
}
m_isLoading = true;
updateUiState();
qDebug() << "[AddToPlaylistDialog] Loading playlists"
<< "| targetMediaType=" << m_targetPlaylistMediaType
<< "| preferredPlaylistId=" << preferredPlaylistId;
try {
if (!safeThis) {
co_return;
}
QList<PlaylistEntry> loadedPlaylists;
bool usedLegacyFallback = false;
QString playlistViewId = co_await safeThis->resolvePlaylistViewId();
if (!safeThis || !safeCore || !mediaService) {
co_return;
}
if (!playlistViewId.isEmpty()) {
for (int attempt = 0; attempt < 2; ++attempt) {
bool retryWithRefreshedViewId = false;
QString failedViewId;
QString failedErrorMessage;
try {
qDebug() << "[AddToPlaylistDialog] Loading playlists via media view"
<< "| playlistViewId=" << playlistViewId
<< "| attempt=" << (attempt + 1)
<< "| targetMediaType=" << safeThis->m_targetPlaylistMediaType;
const QList<MediaItem> playlistItems =
co_await mediaService->getLibraryItems(
playlistViewId, QStringLiteral("DateCreated"),
QStringLiteral("Descending"), QString(), QString(), 0, 0,
false, false);
if (!safeThis || !safeCore || !mediaService) {
co_return;
}
loadedPlaylists.clear();
loadedPlaylists.reserve(playlistItems.size());
for (const MediaItem& item : playlistItems) {
PlaylistEntry entry;
entry.id = item.id;
entry.name = item.name;
entry.mediaType =
PlaylistUtils::normalizePlaylistMediaType(item.mediaType);
entry.childCount = item.childCount > 0 ? item.childCount
: item.recursiveItemCount;
if (entry.id.isEmpty() || entry.name.trimmed().isEmpty()) {
continue;
}
if (!PlaylistUtils::isPlaylistCompatible(
entry.mediaType,
safeThis->m_targetPlaylistMediaType)) {
continue;
}
loadedPlaylists.append(entry);
}
break;
} catch (const std::exception& e) {
if (!safeThis || !safeCore || !mediaService) {
co_return;
}
failedViewId = playlistViewId;
failedErrorMessage = QString::fromUtf8(e.what());
const QString cacheKey = safeThis->playlistViewCacheKey();
if (!cacheKey.isEmpty()) {
s_playlistViewIdCache.remove(cacheKey);
}
if (attempt == 0) {
retryWithRefreshedViewId = true;
} else {
usedLegacyFallback = true;
qWarning() << "[AddToPlaylistDialog] Media-view playlist load failed,"
<< "falling back to admin query"
<< "| playlistViewId=" << failedViewId
<< "| error=" << failedErrorMessage;
break;
}
}
if (!retryWithRefreshedViewId) {
continue;
}
playlistViewId = co_await safeThis->resolvePlaylistViewId(true);
if (!safeThis || !safeCore || !mediaService) {
co_return;
}
if (!playlistViewId.isEmpty() &&
playlistViewId != failedViewId) {
qWarning()
<< "[AddToPlaylistDialog] Cached playlist view load failed,"
<< "retrying with refreshed playlist view id"
<< "| cachedPlaylistViewId=" << failedViewId
<< "| refreshedPlaylistViewId=" << playlistViewId
<< "| error=" << failedErrorMessage;
continue;
}
usedLegacyFallback = true;
qWarning() << "[AddToPlaylistDialog] Media-view playlist load failed,"
<< "falling back to admin query"
<< "| playlistViewId=" << failedViewId
<< "| error=" << failedErrorMessage;
break;
}
} else {
usedLegacyFallback = true;
qWarning() << "[AddToPlaylistDialog] Playlist view missing,"
<< "falling back to admin query";
}
if (usedLegacyFallback) {
if (!adminService) {
throw std::runtime_error("Admin service is unavailable");
}
const QJsonArray playlists =
co_await adminService->getUserPlaylists();
if (!safeThis || !safeCore || !adminService) {
co_return;
}
loadedPlaylists.clear();
loadedPlaylists.reserve(playlists.size());
for (const QJsonValue& value : playlists) {
const QJsonObject obj = value.toObject();
PlaylistEntry entry;
entry.id = obj.value(QStringLiteral("Id")).toString();
entry.name = obj.value(QStringLiteral("Name")).toString();
entry.mediaType = PlaylistUtils::normalizePlaylistMediaType(
obj.value(QStringLiteral("MediaType")).toString());
entry.childCount = obj.value(QStringLiteral("ChildCount")).toInt();
if (entry.id.isEmpty() || entry.name.trimmed().isEmpty()) {
continue;
}
if (!PlaylistUtils::isPlaylistCompatible(
entry.mediaType,
safeThis->m_targetPlaylistMediaType)) {
continue;
}
loadedPlaylists.append(entry);
}
}
safeThis->m_playlists = loadedPlaylists;
safeThis->m_isLoading = false;
safeThis->rebuildPlaylistList(preferredPlaylistId);
safeThis->updateUiState();
qDebug() << "[AddToPlaylistDialog] Playlists loaded"
<< "| compatibleCount=" << loadedPlaylists.size()
<< "| fallback=" << usedLegacyFallback
<< "| targetMediaType=" << safeThis->m_targetPlaylistMediaType;
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
safeThis->m_playlists.clear();
safeThis->m_isLoading = false;
safeThis->rebuildPlaylistList();
safeThis->updateUiState();
qWarning() << "[AddToPlaylistDialog] Failed to load playlists"
<< "| targetMediaType=" << safeThis->m_targetPlaylistMediaType
<< "| error=" << e.what();
ModernToast::showMessage(
tr("Failed to load playlists: %1").arg(QString::fromUtf8(e.what())),
3000);
}
}
QCoro::Task<void> AddToPlaylistDialog::createPlaylist(QString playlistName)
{
QPointer<AddToPlaylistDialog> safeThis(this);
QPointer<QEmbyCore> safeCore(m_core);
QPointer<AdminService> adminService(m_core ? m_core->adminService() : nullptr);
if (!safeThis || !safeCore || !adminService) {
co_return;
}
const QString trimmedName = playlistName.trimmed();
if (trimmedName.isEmpty()) {
co_return;
}
const QString mediaType =
m_targetPlaylistMediaType.isEmpty() ? QStringLiteral("Video")
: m_targetPlaylistMediaType;
m_isLoading = true;
updateUiState();
qDebug() << "[AddToPlaylistDialog] Creating playlist"
<< "| name=" << trimmedName
<< "| mediaType=" << mediaType;
try {
const QString playlistId =
co_await adminService->createPlaylist(trimmedName, mediaType);
if (!safeThis || !safeCore || !adminService) {
co_return;
}
qDebug() << "[AddToPlaylistDialog] Playlist created"
<< "| id=" << playlistId
<< "| name=" << trimmedName
<< "| mediaType=" << mediaType;
ModernToast::showMessage(tr("Playlist created"), 2000);
safeThis->m_isCreateItemEditing = false;
safeThis->m_createPlaylistDraft.clear();
safeThis->m_createPlaylistEditor = nullptr;
safeThis->m_searchEdit->clear();
co_await safeThis->loadPlaylists(playlistId);
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
safeThis->m_isLoading = false;
safeThis->m_isCreateItemEditing = true;
safeThis->m_createPlaylistDraft = trimmedName;
safeThis->syncCreatePlaylistEditor();
safeThis->updateUiState();
qWarning() << "[AddToPlaylistDialog] Failed to create playlist"
<< "| name=" << trimmedName
<< "| mediaType=" << mediaType
<< "| error=" << e.what();
ModernToast::showMessage(
tr("Failed to create playlist: %1").arg(QString::fromUtf8(e.what())),
3000);
}
}
void AddToPlaylistDialog::beginCreatePlaylistEditing()
{
if (m_isLoading || m_isCreateItemEditing || !m_playlistList) {
return;
}
m_isCreateItemEditing = true;
if (m_playlistList->count() > 0) {
m_playlistList->setCurrentRow(0);
}
syncCreatePlaylistEditor();
updateAddButtonState();
}
void AddToPlaylistDialog::cancelCreatePlaylistEditing()
{
if (!m_isCreateItemEditing) {
return;
}
m_isCreateItemEditing = false;
m_createPlaylistDraft.clear();
m_createPlaylistEditor = nullptr;
rebuildPlaylistList();
if (m_playlistList && m_playlistList->count() > 0) {
m_playlistList->setCurrentRow(0);
}
updateAddButtonState();
}
void AddToPlaylistDialog::rebuildPlaylistList(QString preferredPlaylistId)
{
const QString filter = m_searchEdit ? m_searchEdit->text().trimmed() : QString();
const QListWidgetItem* currentItem =
m_playlistList ? m_playlistList->currentItem() : nullptr;
const bool hadCreateItemSelected =
currentItem && currentItem->data(kCreatePlaylistItemRole).toBool();
const QString selectedId =
preferredPlaylistId.isEmpty() ? selectedPlaylistId() : preferredPlaylistId;
QSignalBlocker blocker(m_playlistList);
m_playlistList->clear();
m_createPlaylistEditor = nullptr;
auto* createItem = new QListWidgetItem(
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/add.svg")),
tr("New Playlist"), m_playlistList);
createItem->setData(kCreatePlaylistItemRole, true);
createItem->setToolTip(tr("New Playlist"));
int selectedRow = (m_isCreateItemEditing || hadCreateItemSelected) ? 0 : -1;
for (const PlaylistEntry& playlist : std::as_const(m_playlists)) {
if (!filter.isEmpty() &&
!playlist.name.contains(filter, Qt::CaseInsensitive)) {
continue;
}
auto* item = new QListWidgetItem(playlist.name, m_playlistList);
item->setData(Qt::UserRole, playlist.id);
item->setData(Qt::UserRole + 1, playlist.name);
item->setToolTip(playlist.name);
if (!selectedId.isEmpty() && playlist.id == selectedId) {
selectedRow = m_playlistList->count() - 1;
}
}
if (selectedRow >= 0) {
m_playlistList->setCurrentRow(selectedRow);
m_playlistList->scrollToItem(m_playlistList->item(selectedRow));
}
syncCreatePlaylistEditor();
updateUiState();
updateAddButtonState();
}
void AddToPlaylistDialog::syncCreatePlaylistEditor()
{
if (!m_playlistList || m_playlistList->count() == 0) {
m_createPlaylistEditor = nullptr;
return;
}
QListWidgetItem* createItem = m_playlistList->item(0);
if (!createItem || !createItem->data(kCreatePlaylistItemRole).toBool()) {
m_createPlaylistEditor = nullptr;
return;
}
if (!m_isCreateItemEditing) {
m_playlistList->removeItemWidget(createItem);
m_createPlaylistEditor = nullptr;
return;
}
createItem->setText(QString());
createItem->setIcon(QIcon());
auto* editor =
qobject_cast<PlaylistCreateEditorRow*>(m_playlistList->itemWidget(createItem));
if (!editor) {
editor = new PlaylistCreateEditorRow(m_playlistList);
editor->setPlaceholderText(tr("Enter playlist name"));
editor->setText(m_createPlaylistDraft);
createItem->setSizeHint(
QSize(0, qMax(editor->sizeHint().height(), 32)));
m_playlistList->setItemWidget(createItem, editor);
m_createPlaylistEditor = editor;
connect(editor, &PlaylistCreateEditorRow::textChanged, this,
[this](QString text) { m_createPlaylistDraft = std::move(text); });
connect(editor, &PlaylistCreateEditorRow::confirmed, this,
[this](QString text) {
m_createPlaylistDraft = text;
m_pendingTask = createPlaylist(text);
});
connect(editor, &PlaylistCreateEditorRow::cancelled, this,
[this]() { cancelCreatePlaylistEditing(); });
} else {
editor->setPlaceholderText(tr("Enter playlist name"));
editor->setText(m_createPlaylistDraft);
createItem->setSizeHint(
QSize(0, qMax(editor->sizeHint().height(), 32)));
m_createPlaylistEditor = editor;
}
if (m_playlistList->currentRow() != 0) {
m_playlistList->setCurrentRow(0);
}
if (m_createPlaylistEditor) {
m_createPlaylistEditor->focusEditor();
}
}
void AddToPlaylistDialog::updateLoadingOverlayGeometry()
{
if (!m_loadingOverlay || !m_playlistListContainer) {
return;
}
m_loadingOverlay->setGeometry(m_playlistListContainer->rect());
}
void AddToPlaylistDialog::updateUiState()
{
if (m_searchEdit) {
m_searchEdit->setEnabled(!m_isLoading);
}
if (m_playlistList) {
m_playlistList->setEnabled(!m_isLoading);
}
if (m_createPlaylistEditor) {
m_createPlaylistEditor->setEnabled(!m_isLoading);
}
if (m_loadingOverlay) {
updateLoadingOverlayGeometry();
if (m_isLoading) {
m_loadingOverlay->start();
} else {
m_loadingOverlay->stop();
}
}
updateAddButtonState();
}
void AddToPlaylistDialog::updateAddButtonState()
{
if (!m_addButton) {
return;
}
const QListWidgetItem* currentItem =
m_playlistList ? m_playlistList->currentItem() : nullptr;
const bool hasSelection = currentItem != nullptr;
const bool isCreateAction =
currentItem && currentItem->data(kCreatePlaylistItemRole).toBool();
const bool canTriggerPrimary =
hasSelection && !(isCreateAction && m_isCreateItemEditing);
m_addButton->setText(isCreateAction ? tr("Create") : tr("Add"));
m_addButton->setEnabled(!m_isLoading && canTriggerPrimary);
}
void AddToPlaylistDialog::acceptSelectedPlaylist()
{
if (!m_playlistList || !m_playlistList->currentItem() || m_isLoading) {
return;
}
if (m_playlistList->currentItem()->data(kCreatePlaylistItemRole).toBool()) {
if (!m_isCreateItemEditing) {
beginCreatePlaylistEditing();
}
return;
}
qDebug() << "[AddToPlaylistDialog] Playlist selected"
<< "| playlistId=" << selectedPlaylistId()
<< "| playlistName=" << selectedPlaylistName();
accept();
}

View File

@@ -0,0 +1,80 @@
#ifndef ADDTOPLAYLISTDIALOG_H
#define ADDTOPLAYLISTDIALOG_H
#include "moderndialogbase.h"
#include <QList>
#include <QPointer>
#include <QString>
#include <optional>
#include <qcorotask.h>
class QLabel;
class QLineEdit;
class QListWidget;
class QResizeEvent;
class QShowEvent;
class QEmbyCore;
class QWidget;
class LoadingOverlay;
class PlaylistCreateEditorRow;
class QPushButton;
class AddToPlaylistDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit AddToPlaylistDialog(QEmbyCore* core,
QString targetPlaylistMediaType,
QWidget* parent = nullptr);
QString selectedPlaylistId() const;
QString selectedPlaylistName() const;
protected:
void showEvent(QShowEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
struct PlaylistEntry {
QString id;
QString name;
QString mediaType;
int childCount = 0;
};
QString playlistViewCacheKey() const;
QCoro::Task<QString> resolvePlaylistViewId(bool forceRefresh = false);
QCoro::Task<void> loadPlaylists(QString preferredPlaylistId = QString());
QCoro::Task<void> createPlaylist(QString playlistName);
void beginCreatePlaylistEditing();
void cancelCreatePlaylistEditing();
void rebuildPlaylistList(QString preferredPlaylistId = QString());
void syncCreatePlaylistEditor();
void updateLoadingOverlayGeometry();
void updateUiState();
void updateAddButtonState();
void acceptSelectedPlaylist();
QEmbyCore* m_core = nullptr;
QString m_targetPlaylistMediaType;
QLabel* m_promptLabel = nullptr;
QLineEdit* m_searchEdit = nullptr;
QWidget* m_playlistListContainer = nullptr;
QListWidget* m_playlistList = nullptr;
LoadingOverlay* m_loadingOverlay = nullptr;
QPushButton* m_addButton = nullptr;
bool m_isCreateItemEditing = false;
QString m_createPlaylistDraft;
QPointer<PlaylistCreateEditorRow> m_createPlaylistEditor;
bool m_loaded = false;
bool m_isLoading = false;
QList<PlaylistEntry> m_playlists;
std::optional<QCoro::Task<void>> m_pendingTask;
};
#endif

View File

@@ -0,0 +1,558 @@
#include "adduserdialog.h"
#include "collapsiblesection.h"
#include "moderncombobox.h"
#include "modernswitch.h"
#include "moderntoast.h"
#include "../utils/layoututils.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QCheckBox>
#include <QComboBox>
#include <QDebug>
#include <QFrame>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
namespace {
QWidget *createScrollablePage(QVBoxLayout *&innerLayout, QWidget *parent) {
auto *scroll = new QScrollArea(parent);
scroll->setObjectName("LibCreateScrollArea");
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll->viewport()->setAutoFillBackground(false);
auto *container = new QWidget(scroll);
container->setAttribute(Qt::WA_StyledBackground, true);
innerLayout = new QVBoxLayout(container);
innerLayout->setContentsMargins(0, 0, 12, 0);
innerLayout->setSpacing(8);
innerLayout->setAlignment(Qt::AlignTop);
scroll->setWidget(container);
return scroll;
}
QWidget *createPanelCard(QWidget *parent, QVBoxLayout *&bodyLayout) {
auto *card = new QWidget(parent);
card->setObjectName("LibAdvancedPanel");
card->setAttribute(Qt::WA_StyledBackground, true);
bodyLayout = new QVBoxLayout(card);
bodyLayout->setContentsMargins(12, 10, 12, 10);
bodyLayout->setSpacing(10);
return card;
}
CollapsibleSection *createSectionCard(const QString &title, QWidget *parent,
QVBoxLayout *&bodyLayout) {
auto *section = new CollapsibleSection(title, parent);
section->setExpanded(true);
bodyLayout = section->contentLayout();
bodyLayout->setSpacing(10);
return section;
}
QLabel *createFieldLabel(const QString &text, QWidget *parent) {
auto *label = new QLabel(text, parent);
label->setObjectName("ManageInfoKey");
label->setWordWrap(true);
return label;
}
QLabel *createDescriptionLabel(const QString &text, QWidget *parent) {
auto *label = new QLabel(text, parent);
label->setObjectName("ManageInfoKey");
label->setWordWrap(true);
return label;
}
QWidget *createSwitchRow(const QString &label, ModernSwitch *&sw,
QWidget *parent) {
auto *rowWidget = new QWidget(parent);
auto *rowLayout = new QHBoxLayout(rowWidget);
rowLayout->setContentsMargins(0, 0, 6, 0);
rowLayout->setSpacing(12);
auto *lbl = new QLabel(label, rowWidget);
lbl->setObjectName("ManageInfoKey");
lbl->setWordWrap(true);
rowLayout->addWidget(lbl, 1);
sw = new ModernSwitch(rowWidget);
rowLayout->addWidget(sw, 0, Qt::AlignVCenter);
return rowWidget;
}
}
AddUserDialog::AddUserDialog(QEmbyCore *core, QWidget *parent)
: ModernDialogBase(parent), m_core(core) {
setTitle(tr("Add User"));
setMinimumWidth(500);
setMaximumHeight(720);
resize(520, 580);
setupUi();
updateCopyModeState();
updatePrimaryActionState();
m_pendingTask = loadInitialData();
}
void AddUserDialog::setupUi() {
contentLayout()->setContentsMargins(20, 10, 0, 20);
contentLayout()->setSpacing(12);
QVBoxLayout *scrollLayout = nullptr;
QWidget *scrollPage = createScrollablePage(scrollLayout, this);
QVBoxLayout *basicBody = nullptr;
auto *basicCard = createPanelCard(scrollPage, basicBody);
QWidget *basicContent = basicBody->parentWidget();
auto *nameTitle = new QLabel(tr("Name"), basicContent);
nameTitle->setObjectName("ManageCardTitle");
basicBody->addWidget(nameTitle);
basicBody->addWidget(createDescriptionLabel(
tr("This will be the name displayed for the user on the server."),
basicContent));
m_editUserName = new QLineEdit(basicContent);
m_editUserName->setObjectName("ManageLibInput");
m_editUserName->setPlaceholderText(tr("Enter user name"));
m_editUserName->setClearButtonEnabled(true);
basicBody->addWidget(m_editUserName);
auto *copyRow = new QHBoxLayout();
copyRow->setContentsMargins(0, 0, 0, 0);
copyRow->setSpacing(12);
copyRow->addWidget(createFieldLabel(tr("Copy from user"), basicContent));
copyRow->addStretch(1);
m_comboCopyFromUser = LayoutUtils::createStyledCombo(basicContent);
m_comboCopyFromUser->setMinimumWidth(220);
m_comboCopyFromUser->addItem(tr("Do not copy"), QString());
copyRow->addWidget(m_comboCopyFromUser);
basicBody->addLayout(copyRow);
basicBody->addWidget(createDescriptionLabel(
tr("Optionally copy settings from an existing user."),
basicContent));
basicBody->addWidget(createSwitchRow(tr("Allow user to manage server"),
m_swAdmin, basicContent));
scrollLayout->addWidget(basicCard);
QVBoxLayout *copyOptionsBody = nullptr;
m_copyOptionsSection =
createSectionCard(tr("Copy Options"), scrollPage, copyOptionsBody);
QWidget *copyOptionsContent = copyOptionsBody->parentWidget();
m_chkCopyUserPolicy = new QCheckBox(tr("User policy"), copyOptionsContent);
m_chkCopyUserPolicy->setObjectName("ManageLibCheckBox");
m_chkCopyUserPolicy->setChecked(true);
copyOptionsBody->addWidget(m_chkCopyUserPolicy);
m_chkCopyUserConfiguration =
new QCheckBox(tr("User configuration"), copyOptionsContent);
m_chkCopyUserConfiguration->setObjectName("ManageLibCheckBox");
m_chkCopyUserConfiguration->setChecked(true);
copyOptionsBody->addWidget(m_chkCopyUserConfiguration);
copyOptionsBody->addWidget(createDescriptionLabel(
tr("Choose which settings to copy from the selected user."),
copyOptionsContent));
scrollLayout->addWidget(m_copyOptionsSection);
QVBoxLayout *libraryAccessBody = nullptr;
m_libraryAccessSection =
createSectionCard(tr("Library Access"), scrollPage, libraryAccessBody);
QWidget *libraryAccessContent = libraryAccessBody->parentWidget();
libraryAccessBody->addWidget(createSwitchRow(
tr("Allow access to all libraries"), m_swAllFolders,
libraryAccessContent));
m_swAllFolders->setChecked(true);
m_folderListContainer = new QWidget(libraryAccessContent);
m_folderListContainer->setObjectName("LibAdvancedPanel");
m_folderListContainer->setAttribute(Qt::WA_StyledBackground, true);
auto *folderOuterLayout = new QVBoxLayout(m_folderListContainer);
folderOuterLayout->setContentsMargins(12, 10, 12, 10);
folderOuterLayout->setSpacing(8);
m_folderListLayout = new QVBoxLayout();
m_folderListLayout->setContentsMargins(0, 0, 0, 0);
m_folderListLayout->setSpacing(8);
folderOuterLayout->addLayout(m_folderListLayout);
m_folderListContainer->hide();
libraryAccessBody->addWidget(m_folderListContainer);
libraryAccessBody->addWidget(createDescriptionLabel(
tr("Choose which libraries this user can access."),
libraryAccessContent));
scrollLayout->addWidget(m_libraryAccessSection);
QVBoxLayout *channelAccessBody = nullptr;
m_channelAccessSection =
createSectionCard(tr("Channel Access"), scrollPage, channelAccessBody);
QWidget *channelAccessContent = channelAccessBody->parentWidget();
channelAccessBody->addWidget(createSwitchRow(
tr("Allow access to all channels"), m_swAllChannels,
channelAccessContent));
m_swAllChannels->setChecked(true);
m_channelListContainer = new QWidget(channelAccessContent);
m_channelListContainer->setObjectName("LibAdvancedPanel");
m_channelListContainer->setAttribute(Qt::WA_StyledBackground, true);
auto *channelOuterLayout = new QVBoxLayout(m_channelListContainer);
channelOuterLayout->setContentsMargins(12, 10, 12, 10);
channelOuterLayout->setSpacing(8);
m_channelListLayout = new QVBoxLayout();
m_channelListLayout->setContentsMargins(0, 0, 0, 0);
m_channelListLayout->setSpacing(8);
channelOuterLayout->addLayout(m_channelListLayout);
m_channelListContainer->hide();
channelAccessBody->addWidget(m_channelListContainer);
channelAccessBody->addWidget(createDescriptionLabel(
tr("Choose which channels this user can access."),
channelAccessContent));
scrollLayout->addWidget(m_channelAccessSection);
scrollLayout->addStretch(1);
contentLayout()->addWidget(scrollPage, 1);
auto *bottomRow = new QHBoxLayout();
bottomRow->setContentsMargins(0, 0, 12, 0);
bottomRow->setSpacing(8);
bottomRow->addStretch(1);
m_btnCancel = new QPushButton(tr("Cancel"), this);
m_btnCancel->setObjectName("SettingsCardButton");
m_btnCancel->setCursor(Qt::PointingHandCursor);
bottomRow->addWidget(m_btnCancel);
m_btnCreate = new QPushButton(tr("Create"), this);
m_btnCreate->setObjectName("SettingsCardButton");
m_btnCreate->setCursor(Qt::PointingHandCursor);
bottomRow->addWidget(m_btnCreate);
contentLayout()->addLayout(bottomRow);
connect(m_btnCancel, &QPushButton::clicked, this, &QDialog::reject);
connect(m_btnCreate, &QPushButton::clicked, this,
[this]() { m_pendingTask = createUser(); });
connect(m_editUserName, &QLineEdit::textChanged, this,
[this]() { updatePrimaryActionState(); });
connect(m_comboCopyFromUser, qOverload<int>(&QComboBox::currentIndexChanged),
this, [this](int) { updateCopyModeState(); });
connect(m_swAllFolders, &ModernSwitch::toggled, this,
[this](bool checked) {
m_folderListContainer->setVisible(!checked);
});
connect(m_swAllChannels, &ModernSwitch::toggled, this,
[this](bool checked) {
m_channelListContainer->setVisible(!checked);
});
}
void AddUserDialog::updateCopyModeState() {
const bool hasCopySource =
m_comboCopyFromUser &&
!m_comboCopyFromUser->currentData().toString().trimmed().isEmpty();
if (m_copyOptionsSection) {
m_copyOptionsSection->setVisible(hasCopySource);
}
if (m_libraryAccessSection) {
m_libraryAccessSection->setVisible(!hasCopySource);
}
if (m_channelAccessSection) {
m_channelAccessSection->setVisible(!hasCopySource && m_hasChannelOptions);
}
if (m_folderListContainer && m_swAllFolders) {
m_folderListContainer->setVisible(!hasCopySource &&
!m_swAllFolders->isChecked());
}
if (m_channelListContainer && m_swAllChannels) {
m_channelListContainer->setVisible(!hasCopySource &&
!m_swAllChannels->isChecked());
}
}
void AddUserDialog::updatePrimaryActionState() {
if (!m_btnCreate) {
return;
}
const bool hasName =
m_editUserName && !m_editUserName->text().trimmed().isEmpty();
m_btnCreate->setEnabled(m_isDataLoaded && !m_isSubmitting && hasName);
}
void AddUserDialog::populateCopyUserOptions(const QList<UserInfo> &users) {
if (!m_comboCopyFromUser) {
return;
}
m_comboCopyFromUser->clear();
m_comboCopyFromUser->addItem(tr("Do not copy"), QString());
for (const UserInfo &user : users) {
m_comboCopyFromUser->addItem(user.name, user.id);
}
}
void AddUserDialog::populateSelectableFolders(
const QList<SelectableMediaFolderInfo> &folders) {
while (QLayoutItem *child = m_folderListLayout->takeAt(0)) {
if (child->widget()) {
child->widget()->deleteLater();
}
delete child;
}
m_folderCheckboxes.clear();
m_fixedEnabledFolderIds.clear();
for (const SelectableMediaFolderInfo &folder : folders) {
if (!folder.isUserAccessConfigurable) {
if (!folder.id.isEmpty() &&
!m_fixedEnabledFolderIds.contains(folder.id)) {
m_fixedEnabledFolderIds.append(folder.id);
}
continue;
}
auto *cb = new QCheckBox(folder.name, m_folderListContainer);
cb->setObjectName("ManageLibCheckBox");
cb->setProperty("folderId", folder.id);
cb->setChecked(true);
m_folderListLayout->addWidget(cb);
m_folderCheckboxes.append(cb);
}
}
void AddUserDialog::populateChannels(const QList<AdminChannelInfo> &channels) {
while (QLayoutItem *child = m_channelListLayout->takeAt(0)) {
if (child->widget()) {
child->widget()->deleteLater();
}
delete child;
}
m_channelCheckboxes.clear();
m_hasChannelOptions = !channels.isEmpty();
for (const AdminChannelInfo &channel : channels) {
auto *cb = new QCheckBox(channel.name, m_channelListContainer);
cb->setObjectName("ManageLibCheckBox");
cb->setProperty("channelId", channel.id);
cb->setChecked(true);
m_channelListLayout->addWidget(cb);
m_channelCheckboxes.append(cb);
}
}
QCoro::Task<void> AddUserDialog::loadInitialData() {
QPointer<AddUserDialog> safeThis(this);
QList<UserInfo> users;
try {
users = co_await m_core->adminService()->getUsers();
if (!safeThis) {
co_return;
}
populateCopyUserOptions(users);
} catch (const std::exception &e) {
if (!safeThis) {
co_return;
}
qDebug() << "[AddUserDialog] Failed to load users for copy options"
<< "| error=" << e.what();
}
try {
const QList<SelectableMediaFolderInfo> folders =
co_await m_core->adminService()->getSelectableMediaFolders(false);
if (!safeThis) {
co_return;
}
populateSelectableFolders(folders);
} catch (const std::exception &e) {
if (!safeThis) {
co_return;
}
qDebug() << "[AddUserDialog] Failed to load selectable folders"
<< "| error=" << e.what();
}
try {
const QList<AdminChannelInfo> channels =
co_await m_core->adminService()->getChannels();
if (!safeThis) {
co_return;
}
populateChannels(channels);
} catch (const std::exception &e) {
if (!safeThis) {
co_return;
}
qDebug() << "[AddUserDialog] Failed to load channels"
<< "| error=" << e.what();
}
if (!safeThis) {
co_return;
}
m_isDataLoaded = true;
updateCopyModeState();
updatePrimaryActionState();
m_editUserName->setFocus();
}
QCoro::Task<void> AddUserDialog::createUser() {
QPointer<AddUserDialog> safeThis(this);
const QString trimmedName = m_editUserName->text().trimmed();
if (trimmedName.isEmpty()) {
m_editUserName->setFocus();
ModernToast::showMessage(tr("User name cannot be empty"), 2000);
co_return;
}
const QString copyFromUserId =
m_comboCopyFromUser->currentData().toString().trimmed();
QStringList userCopyOptions;
if (!copyFromUserId.isEmpty()) {
if (m_chkCopyUserPolicy->isChecked()) {
userCopyOptions.append(QStringLiteral("UserPolicy"));
}
if (m_chkCopyUserConfiguration->isChecked()) {
userCopyOptions.append(QStringLiteral("UserConfiguration"));
}
}
m_isSubmitting = true;
updatePrimaryActionState();
QString createdUserId;
QString createdUserName = trimmedName;
try {
qDebug() << "[AddUserDialog] Creating user"
<< "| name=" << trimmedName
<< "| copyFromUserId=" << copyFromUserId
<< "| copyOptions=" << userCopyOptions;
QJsonObject createdUser = co_await m_core->adminService()->createUser(
trimmedName, copyFromUserId, userCopyOptions);
if (!safeThis) {
co_return;
}
createdUserId = createdUser["Id"].toString();
const QString returnedName = createdUser["Name"].toString().trimmed();
if (!returnedName.isEmpty()) {
createdUserName = returnedName;
}
QJsonObject policy = createdUser["Policy"].toObject();
policy["IsAdministrator"] = m_swAdmin->isChecked();
if (copyFromUserId.isEmpty()) {
const bool isAdmin = m_swAdmin->isChecked();
policy["EnableSubtitleDownloading"] = isAdmin;
policy["EnableSubtitleManagement"] = isAdmin;
policy["EnableContentDeletion"] = isAdmin;
policy["EnableAllFolders"] = m_swAllFolders->isChecked();
QJsonArray enabledFolders;
if (!m_swAllFolders->isChecked()) {
QStringList addedFolderIds;
for (const QString &folderId : m_fixedEnabledFolderIds) {
if (!folderId.isEmpty() && !addedFolderIds.contains(folderId)) {
enabledFolders.append(folderId);
addedFolderIds.append(folderId);
}
}
for (QCheckBox *cb : m_folderCheckboxes) {
const QString folderId = cb->property("folderId").toString();
if (cb->isChecked() && !folderId.isEmpty() &&
!addedFolderIds.contains(folderId)) {
enabledFolders.append(folderId);
addedFolderIds.append(folderId);
}
}
}
policy["EnabledFolders"] = enabledFolders;
policy["EnableAllChannels"] = m_swAllChannels->isChecked();
QJsonArray enabledChannels;
if (!m_swAllChannels->isChecked()) {
QStringList addedChannelIds;
for (QCheckBox *cb : m_channelCheckboxes) {
const QString channelId =
cb->property("channelId").toString();
if (cb->isChecked() && !channelId.isEmpty() &&
!addedChannelIds.contains(channelId)) {
enabledChannels.append(channelId);
addedChannelIds.append(channelId);
}
}
}
policy["EnabledChannels"] = enabledChannels;
}
if (!createdUserId.isEmpty()) {
co_await m_core->adminService()->setUserPolicy(createdUserId, policy);
if (!safeThis) {
co_return;
}
}
ModernToast::showMessage(
tr("User \"%1\" created").arg(createdUserName), 2000);
emit userCreated();
accept();
co_return;
} catch (const std::exception &e) {
if (!safeThis) {
co_return;
}
if (!createdUserId.isEmpty()) {
ModernToast::showMessage(
tr("User \"%1\" created, but failed to apply permissions: %2")
.arg(createdUserName, QString::fromUtf8(e.what())),
3500);
emit userCreated();
accept();
co_return;
}
ModernToast::showMessage(
tr("Failed to create user: %1")
.arg(QString::fromUtf8(e.what())),
3000);
}
if (safeThis) {
m_isSubmitting = false;
updatePrimaryActionState();
}
}

View File

@@ -0,0 +1,68 @@
#ifndef ADDUSERDIALOG_H
#define ADDUSERDIALOG_H
#include "moderndialogbase.h"
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
#include <optional>
class QEmbyCore;
class QCheckBox;
class QLineEdit;
class QPushButton;
class QVBoxLayout;
class QWidget;
class ModernComboBox;
class ModernSwitch;
class CollapsibleSection;
class AddUserDialog : public ModernDialogBase {
Q_OBJECT
public:
explicit AddUserDialog(QEmbyCore* core, QWidget* parent = nullptr);
signals:
void userCreated();
private:
void setupUi();
void updateCopyModeState();
void updatePrimaryActionState();
void populateCopyUserOptions(const QList<UserInfo>& users);
void populateSelectableFolders(
const QList<SelectableMediaFolderInfo>& folders);
void populateChannels(const QList<AdminChannelInfo>& channels);
QCoro::Task<void> loadInitialData();
QCoro::Task<void> createUser();
QEmbyCore* m_core;
QLineEdit* m_editUserName = nullptr;
ModernComboBox* m_comboCopyFromUser = nullptr;
QCheckBox* m_chkCopyUserPolicy = nullptr;
QCheckBox* m_chkCopyUserConfiguration = nullptr;
ModernSwitch* m_swAdmin = nullptr;
ModernSwitch* m_swAllFolders = nullptr;
ModernSwitch* m_swAllChannels = nullptr;
QWidget* m_copyOptionsSection = nullptr;
CollapsibleSection* m_libraryAccessSection = nullptr;
CollapsibleSection* m_channelAccessSection = nullptr;
QWidget* m_folderListContainer = nullptr;
QWidget* m_channelListContainer = nullptr;
QVBoxLayout* m_folderListLayout = nullptr;
QVBoxLayout* m_channelListLayout = nullptr;
QPushButton* m_btnCancel = nullptr;
QPushButton* m_btnCreate = nullptr;
QList<QCheckBox*> m_folderCheckboxes;
QList<QCheckBox*> m_channelCheckboxes;
QStringList m_fixedEnabledFolderIds;
bool m_hasChannelOptions = false;
bool m_isDataLoaded = false;
bool m_isSubmitting = false;
std::optional<QCoro::Task<void>> m_pendingTask;
};
#endif

View File

@@ -0,0 +1,202 @@
#include "adminusercard.h"
#include "../utils/layoututils.h"
#include "elidedlabel.h"
#include "modernswitch.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QTimeZone>
#include <QVBoxLayout>
namespace
{
QString formatIsoDateTime(QString isoDate)
{
if (isoDate.isEmpty())
{
return {};
}
QDateTime dateTime = QDateTime::fromString(isoDate, Qt::ISODate);
if (!dateTime.isValid())
{
dateTime = QDateTime::fromString(isoDate, Qt::ISODateWithMs);
}
if (!dateTime.isValid())
{
return isoDate;
}
dateTime.setTimeZone(QTimeZone::UTC);
return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
}
QString displayNameForUser(const UserInfo &user)
{
const QString trimmedName = user.name.trimmed();
if (!trimmedName.isEmpty())
{
return trimmedName;
}
return user.id;
}
QString pageUsersText(const char *sourceText)
{
return QCoreApplication::translate("PageUsers", sourceText);
}
void setPassiveClickThrough(QWidget *widget)
{
if (!widget)
{
return;
}
widget->setAttribute(Qt::WA_TransparentForMouseEvents, true);
}
}
AdminUserCard::AdminUserCard(UserInfo userInfo, QString currentUserId, QWidget *parent)
: QFrame(parent), m_user(userInfo), m_currentUserId(currentUserId), m_displayName(displayNameForUser(m_user))
{
static constexpr int CardWidth = 268;
static constexpr int CardHeight = 196;
setObjectName("ManageUserCard");
setAttribute(Qt::WA_StyledBackground, true);
setAttribute(Qt::WA_Hover, true);
setMouseTracking(true);
setCursor(Qt::PointingHandCursor);
setToolTip(m_displayName);
setFixedSize(CardWidth, CardHeight);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
auto *cardLayout = new QVBoxLayout(this);
cardLayout->setContentsMargins(14, 12, 14, 12);
cardLayout->setSpacing(8);
auto *topRow = new QHBoxLayout();
topRow->setContentsMargins(0, 0, 0, 0);
topRow->setSpacing(10);
auto *avatarLabel = new QLabel(this);
avatarLabel->setObjectName("ManageUserAvatar");
avatarLabel->setFixedSize(48, 48);
avatarLabel->setAlignment(Qt::AlignCenter);
avatarLabel->setText(m_displayName.isEmpty() ? QStringLiteral("?") : m_displayName.left(1).toUpper());
setPassiveClickThrough(avatarLabel);
topRow->addWidget(avatarLabel, 0, Qt::AlignTop);
auto *titleColumn = new QVBoxLayout();
titleColumn->setContentsMargins(0, 0, 0, 0);
titleColumn->setSpacing(4);
auto *nameLabel = new ElidedLabel(this);
nameLabel->setObjectName("ManageCardTitle");
nameLabel->setFullText(m_displayName);
setPassiveClickThrough(nameLabel);
titleColumn->addWidget(nameLabel);
auto *badgeRow = new QHBoxLayout();
badgeRow->setContentsMargins(0, 0, 0, 0);
badgeRow->setSpacing(6);
if (m_user.isAdministrator)
{
auto *adminBadge = new QLabel(pageUsersText("Admin"), this);
adminBadge->setObjectName("ManageUserBadgeAdmin");
setPassiveClickThrough(adminBadge);
badgeRow->addWidget(adminBadge);
}
if (m_user.isDisabled)
{
auto *disabledBadge = new QLabel(pageUsersText("Disabled"), this);
disabledBadge->setObjectName("ManageUserBadgeDisabled");
setPassiveClickThrough(disabledBadge);
badgeRow->addWidget(disabledBadge);
}
badgeRow->addStretch(1);
titleColumn->addLayout(badgeRow);
topRow->addLayout(titleColumn, 1);
cardLayout->addLayout(topRow);
auto *separator = LayoutUtils::createSeparator(this);
setPassiveClickThrough(separator);
cardLayout->addWidget(separator);
const QString lastLoginText = formatIsoDateTime(m_user.lastLoginDate);
auto *lastLoginLabel = new QLabel(
pageUsersText("Last login: %1").arg(lastLoginText.isEmpty() ? QStringLiteral("-") : lastLoginText), this);
lastLoginLabel->setObjectName("ManageSessionClient");
lastLoginLabel->setWordWrap(true);
setPassiveClickThrough(lastLoginLabel);
cardLayout->addWidget(lastLoginLabel);
const QString lastActivityText = formatIsoDateTime(m_user.lastActivityDate);
auto *lastActivityLabel = new QLabel(
pageUsersText("Last active: %1").arg(lastActivityText.isEmpty() ? QStringLiteral("-") : lastActivityText),
this);
lastActivityLabel->setObjectName("ManageSessionTime");
lastActivityLabel->setWordWrap(true);
setPassiveClickThrough(lastActivityLabel);
cardLayout->addWidget(lastActivityLabel);
cardLayout->addStretch(1);
auto *footerRow = new QHBoxLayout();
footerRow->setContentsMargins(0, 0, 0, 0);
footerRow->setSpacing(8);
footerRow->addStretch(1);
auto *switchWidget = new ModernSwitch(this);
switchWidget->setChecked(!m_user.isDisabled);
switchWidget->setToolTip(m_user.isDisabled ? pageUsersText("Enable user") : pageUsersText("Disable user"));
if (m_user.id == m_currentUserId)
{
switchWidget->setEnabled(false);
switchWidget->setToolTip(pageUsersText("Cannot disable yourself"));
}
connect(switchWidget, &ModernSwitch::toggled, this,
[this, switchWidget](bool checked)
{
switchWidget->setToolTip(checked ? pageUsersText("Disable user") : pageUsersText("Enable user"));
emit toggleRequested(m_user.id, m_displayName, checked);
});
footerRow->addWidget(switchWidget, 0, Qt::AlignVCenter);
auto *deleteBtn = new QPushButton(this);
deleteBtn->setObjectName("CollCardDeleteBtn");
deleteBtn->setFixedSize(24, 24);
deleteBtn->setCursor(Qt::PointingHandCursor);
deleteBtn->setToolTip(pageUsersText("Delete user"));
if (m_user.id == m_currentUserId)
{
deleteBtn->setEnabled(false);
deleteBtn->setToolTip(pageUsersText("Cannot delete yourself"));
}
connect(deleteBtn, &QPushButton::clicked, this, [this]() { emit deleteRequested(m_user.id, m_displayName); });
footerRow->addWidget(deleteBtn, 0, Qt::AlignVCenter);
cardLayout->addLayout(footerRow);
}
void AdminUserCard::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
{
emit clicked(m_user.id);
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}

View File

@@ -0,0 +1,32 @@
#ifndef ADMINUSERCARD_H
#define ADMINUSERCARD_H
#include <QFrame>
#include <QString>
#include <models/admin/adminmodels.h>
class QMouseEvent;
class AdminUserCard : public QFrame {
Q_OBJECT
public:
explicit AdminUserCard(UserInfo userInfo,
QString currentUserId,
QWidget* parent = nullptr);
signals:
void clicked(QString userId);
void toggleRequested(QString userId, QString userName, bool enable);
void deleteRequested(QString userId, QString userName);
protected:
void mouseReleaseEvent(QMouseEvent* event) override;
private:
UserInfo m_user;
QString m_currentUserId;
QString m_displayName;
};
#endif

View File

@@ -0,0 +1,40 @@
#ifndef CARDCONTEXTMENUREQUEST_H
#define CARDCONTEXTMENUREQUEST_H
#include <QMetaType>
#include <QString>
enum class CardContextMenuAction {
None,
Play,
ExternalPlay,
ViewDetails,
Download,
EditMetadata,
EditImages,
Identify,
RefreshMetadata,
ScanLibraryFiles,
ToggleFavorite,
AddToPlaylist,
RemoveFromPlaylist,
DeletePlaylist,
RemoveMedia,
MarkPlayed,
MarkUnplayed,
RemoveFromResume
};
struct CardContextMenuRequest {
CardContextMenuAction action = CardContextMenuAction::None;
QString stringValue;
bool isValid() const
{
return action != CardContextMenuAction::None;
}
};
Q_DECLARE_METATYPE(CardContextMenuRequest)
#endif

View File

@@ -0,0 +1,113 @@
#include "collapsiblesection.h"
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QStyle>
#include <QStyleOptionButton>
#include <QVBoxLayout>
namespace {
class SectionToggleButton : public QPushButton {
public:
explicit SectionToggleButton(QWidget *parent = nullptr)
: QPushButton(parent) {}
bool expanded = false;
protected:
void paintEvent(QPaintEvent *) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
QStyleOptionButton opt;
opt.initFrom(this);
style()->drawControl(QStyle::CE_PushButton, &opt, &p, this);
QColor arrowColor = palette().color(QPalette::ButtonText);
arrowColor.setAlphaF(0.7f);
p.setPen(Qt::NoPen);
p.setBrush(arrowColor);
const int arrowSize = 8;
const int leftMargin = 2;
const int cy = height() / 2;
QPainterPath path;
if (expanded) {
const qreal ax = leftMargin;
const qreal ay = cy - arrowSize / 3.0;
path.moveTo(ax, ay);
path.lineTo(ax + arrowSize, ay);
path.lineTo(ax + arrowSize / 2.0, ay + arrowSize * 2.0 / 3.0);
} else {
const qreal ax = leftMargin;
const qreal ay = cy - arrowSize / 2.0;
path.moveTo(ax, ay);
path.lineTo(ax + arrowSize * 2.0 / 3.0, ay + arrowSize / 2.0);
path.lineTo(ax, ay + arrowSize);
}
path.closeSubpath();
p.drawPath(path);
QFont f = font();
f.setBold(true);
p.setFont(f);
p.setPen(palette().color(QPalette::ButtonText));
const int textLeft = leftMargin + arrowSize + 6;
const QRect textRect = rect().adjusted(textLeft, 0, -8, 0);
const QFontMetrics fm(f);
const int textWidth = fm.horizontalAdvance(text());
p.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text());
QColor lineColor = arrowColor;
lineColor.setAlphaF(0.25f);
p.setPen(QPen(lineColor, 1));
const int lineX = textLeft + textWidth + 10;
const int lineRight = width() - 4;
if (lineX < lineRight) {
p.drawLine(lineX, cy, lineRight, cy);
}
}
};
}
CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent)
: QWidget(parent), m_title(title) {
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_toggle = new SectionToggleButton(this);
m_toggle->setObjectName("LibAdvancedToggle");
m_toggle->setCursor(Qt::PointingHandCursor);
m_toggle->setFlat(true);
m_toggle->setMinimumHeight(32);
mainLayout->addWidget(m_toggle);
m_content = new QWidget(this);
m_content->setObjectName("LibAdvancedPanel");
m_content->setVisible(false);
m_contentLayout = new QVBoxLayout(m_content);
m_contentLayout->setContentsMargins(12, 10, 12, 10);
m_contentLayout->setSpacing(8);
mainLayout->addWidget(m_content);
updateToggleText();
connect(m_toggle, &QPushButton::clicked, this,
[this]() { setExpanded(!m_expanded); });
}
void CollapsibleSection::setExpanded(bool expanded) {
m_expanded = expanded;
m_content->setVisible(expanded);
updateToggleText();
}
void CollapsibleSection::updateToggleText() {
m_toggle->setText(m_title);
static_cast<SectionToggleButton *>(m_toggle)->expanded = m_expanded;
m_toggle->update();
}

View File

@@ -0,0 +1,29 @@
#ifndef COLLAPSIBLESECTION_H
#define COLLAPSIBLESECTION_H
#include <QString>
#include <QWidget>
class QPushButton;
class QVBoxLayout;
class CollapsibleSection : public QWidget {
Q_OBJECT
public:
explicit CollapsibleSection(const QString& title, QWidget* parent = nullptr);
QVBoxLayout* contentLayout() const { return m_contentLayout; }
void setExpanded(bool expanded);
bool isExpanded() const { return m_expanded; }
private:
void updateToggleText();
QPushButton* m_toggle;
QWidget* m_content;
QVBoxLayout* m_contentLayout;
bool m_expanded = false;
QString m_title;
};
#endif

View File

@@ -0,0 +1,306 @@
#include "collectioncard.h"
#include "collectiongrid.h"
#include "../managers/thememanager.h"
#include <QApplication>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
CollectionCard::CollectionCard(const QString& itemId,
const QString& name,
int childCount,
const QString& type,
QWidget* parent)
: QFrame(parent)
, m_itemId(itemId)
, m_name(name)
, m_childCount(childCount)
, m_type(type)
{
setFixedSize(CollectionGrid::CardWidth, CollectionGrid::CardHeight);
setMouseTracking(true);
setCursor(Qt::OpenHandCursor);
auto makeBtn = [this](const QString& objName) {
auto* btn = new QPushButton(this);
btn->setObjectName(objName);
btn->setFixedSize(24, 24);
btn->setCursor(Qt::PointingHandCursor);
return btn;
};
m_btnRename = makeBtn("CollCardRenameBtn");
m_btnDelete = makeBtn("CollCardDeleteBtn");
m_btnRename->setToolTip(tr("Rename"));
m_btnDelete->setToolTip(tr("Delete"));
updateButtonLayout();
connect(m_btnRename, &QPushButton::clicked, this,
[this]() { emit renameClicked(m_itemId, m_name); });
connect(m_btnDelete, &QPushButton::clicked, this,
[this]() { emit deleteClicked(m_itemId, m_name); });
}
void CollectionCard::updateButtonLayout()
{
const int buttonSize = 24;
const int buttonSpacing = 4;
const int totalWidth = 2 * buttonSize + buttonSpacing;
const int startX = width() - totalWidth - 8;
const int buttonY = height() - buttonSize - 6;
m_btnRename->move(startX, buttonY);
m_btnDelete->move(startX + buttonSize + buttonSpacing, buttonY);
}
void CollectionCard::setImage(const QPixmap& pixmap)
{
m_image = pixmap;
update();
}
void CollectionCard::setDisplayData(const QString& name, int childCount,
const QString& type)
{
if (m_name == name && m_childCount == childCount && m_type == type) {
return;
}
m_name = name;
m_childCount = childCount;
m_type = type;
update();
}
void CollectionCard::setSelected(bool selected)
{
if (m_selected == selected) {
return;
}
m_selected = selected;
update();
}
void CollectionCard::setDragging(bool dragging)
{
if (m_dragging == dragging) {
return;
}
m_dragging = dragging;
update();
}
void CollectionCard::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event)
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
bool isDark = ThemeManager::instance()->isDarkMode();
int w = width(), h = height();
QColor cardBg = isDark ? QColor(51, 65, 85, 140) : QColor(255, 255, 255, 245);
QColor border = isDark ? QColor(71, 85, 105, 100) : QColor(226, 232, 240);
QColor nameFg = isDark ? QColor("#F1F5F9") : QColor("#0F172A");
QColor typeFg = isDark ? QColor("#94A3B8") : QColor("#64748B");
const QColor accent("#3B82F6");
if (m_selected) {
cardBg = isDark ? QColor(30, 41, 59, 220) : QColor(239, 246, 255, 245);
border = isDark ? QColor(96, 165, 250, 220) : QColor(59, 130, 246, 220);
}
if (m_dragging) {
cardBg = isDark ? QColor(15, 23, 42, 235) : QColor(248, 250, 252, 252);
border = isDark ? QColor(59, 130, 246, 235) : QColor(37, 99, 235, 230);
}
p.setPen(QPen(border, m_selected ? 2 : 1));
p.setBrush(cardBg);
p.drawRoundedRect(QRectF(0.5, 0.5, w - 1, h - 1), 12, 12);
const QRectF badgeRect(w - 28, 10, 18, 18);
p.setPen(QPen(m_selected ? accent
: (isDark ? QColor(100, 116, 139, 160)
: QColor(148, 163, 184, 180)),
1.5));
p.setBrush(m_selected ? accent
: (isDark ? QColor(15, 23, 42, 180)
: QColor(255, 255, 255, 220)));
p.drawEllipse(badgeRect);
if (m_selected) {
QPainterPath checkPath;
checkPath.moveTo(badgeRect.left() + 4.5, badgeRect.center().y());
checkPath.lineTo(badgeRect.left() + 7.5, badgeRect.bottom() - 4.5);
checkPath.lineTo(badgeRect.right() - 4.0, badgeRect.top() + 4.5);
p.setPen(QPen(Qt::white, 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
p.drawPath(checkPath);
}
const int imagePadding = 4;
const int defaultImgAreaH = 120;
const bool useSquareThumb = (m_type == "Playlist");
const int imgSide = qMax(0, w - imagePadding * 2);
QRectF imgRect(imagePadding, imagePadding,
w - imagePadding * 2,
useSquareThumb ? imgSide : defaultImgAreaH);
QLinearGradient grad(imgRect.topLeft(), imgRect.bottomRight());
grad.setColorAt(0, isDark ? QColor(30, 41, 59) : QColor(241, 245, 249));
grad.setColorAt(1, isDark ? QColor(51, 65, 85) : QColor(226, 232, 240));
QPainterPath clipPath;
clipPath.addRoundedRect(imgRect, 9, 9);
p.save();
p.setClipPath(clipPath);
p.fillRect(imgRect, grad);
if (!m_image.isNull()) {
const Qt::AspectRatioMode aspectMode =
useSquareThumb ? Qt::KeepAspectRatio : Qt::KeepAspectRatioByExpanding;
QPixmap scaled = m_image.scaled(imgRect.size().toSize(), aspectMode,
Qt::SmoothTransformation);
const qreal scaledDpr = scaled.devicePixelRatio();
QSize scaledSize(qRound(scaled.width() / scaledDpr),
qRound(scaled.height() / scaledDpr));
QRect drawRect(0, 0, scaledSize.width(), scaledSize.height());
drawRect.moveLeft(qRound(imgRect.x() + (imgRect.width() - drawRect.width()) / 2.0));
drawRect.moveTop(qRound(imgRect.y() + (imgRect.height() - drawRect.height()) / 2.0));
p.drawPixmap(drawRect.topLeft(), scaled);
} else {
QFont iconFont = font();
iconFont.setPixelSize(32);
p.setFont(iconFont);
p.setPen(isDark ? QColor(148, 163, 184, 80) : QColor(100, 116, 139, 80));
QString placeholder = (m_type == "Playlist") ? QString::fromUtf8("🎵") : QString::fromUtf8("📂");
p.drawText(imgRect, Qt::AlignCenter, placeholder);
}
p.restore();
int textTop = qRound(imgRect.bottom()) + 8;
int textLeft = 10;
int textWidth = w - 20;
QFont nameFont = font();
nameFont.setPixelSize(13);
nameFont.setWeight(QFont::DemiBold);
nameFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"});
QFontMetrics nameFm(nameFont);
QString elidedName = nameFm.elidedText(m_name, Qt::ElideRight, textWidth);
p.setFont(nameFont);
p.setPen(nameFg);
p.drawText(QRect(textLeft, textTop, textWidth, 18), Qt::AlignLeft | Qt::AlignVCenter, elidedName);
QFont typeFont = font();
typeFont.setPixelSize(11);
typeFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"});
QString typeStr;
if (m_type == "Playlist") {
typeStr = tr("Playlist");
} else {
typeStr = tr("Collection");
}
typeStr += QString(" · %1 %2").arg(m_childCount)
.arg(m_childCount == 1 ? tr("item") : tr("items"));
p.setFont(typeFont);
p.setPen(typeFg);
p.drawText(QRect(textLeft, textTop + 20, textWidth, 16), Qt::AlignLeft | Qt::AlignVCenter, typeStr);
}
void CollectionCard::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton) {
if (!qobject_cast<QPushButton*>(childAt(event->position().toPoint()))) {
m_pressCandidate = true;
m_dragStartPos = event->globalPosition().toPoint();
setCursor(Qt::OpenHandCursor);
event->accept();
return;
}
}
QFrame::mousePressEvent(event);
}
void CollectionCard::mouseMoveEvent(QMouseEvent* event)
{
if ((event->buttons() & Qt::LeftButton) && m_pressCandidate) {
const QPoint globalPos = event->globalPosition().toPoint();
if (!m_dragging) {
const int distance = (globalPos - m_dragStartPos).manhattanLength();
if (distance >= QApplication::startDragDistance()) {
setDragging(true);
setCursor(Qt::ClosedHandCursor);
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragStart(this, globalPos);
}
}
}
if (m_dragging) {
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragMove(this, globalPos);
}
event->accept();
return;
}
}
QFrame::mouseMoveEvent(event);
}
void CollectionCard::mouseReleaseEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton && m_pressCandidate) {
const bool wasDragging = m_dragging;
m_pressCandidate = false;
if (wasDragging) {
setDragging(false);
setCursor(Qt::OpenHandCursor);
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragEnd(this);
}
event->accept();
return;
}
toggleSelected();
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}
void CollectionCard::resizeEvent(QResizeEvent* event)
{
QFrame::resizeEvent(event);
updateButtonLayout();
}
void CollectionCard::toggleSelected()
{
m_selected = !m_selected;
update();
emit selectionChanged(m_itemId, m_selected);
}

View File

@@ -0,0 +1,66 @@
#ifndef COLLECTIONCARD_H
#define COLLECTIONCARD_H
#include <QFrame>
#include <QPoint>
#include <QPixmap>
#include <QPushButton>
class CollectionGrid;
class QMouseEvent;
class QResizeEvent;
class CollectionCard : public QFrame {
Q_OBJECT
public:
explicit CollectionCard(const QString& itemId,
const QString& name,
int childCount,
const QString& type,
QWidget* parent = nullptr);
QString itemId() const { return m_itemId; }
QString itemName() const { return m_name; }
bool hasImage() const { return !m_image.isNull(); }
bool isSelected() const { return m_selected; }
bool isDragging() const { return m_dragging; }
void setImage(const QPixmap& pixmap);
void setDisplayData(const QString& name, int childCount, const QString& type);
void setSelected(bool selected);
void setDragging(bool dragging);
signals:
void renameClicked(const QString& id, const QString& name);
void deleteClicked(const QString& id, const QString& name);
void selectionChanged(const QString& id, bool selected);
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
void updateButtonLayout();
void toggleSelected();
QString m_itemId;
QString m_name;
int m_childCount;
QString m_type;
QPixmap m_image;
bool m_selected = false;
bool m_dragging = false;
bool m_pressCandidate = false;
QPoint m_dragStartPos;
QPushButton* m_btnRename;
QPushButton* m_btnDelete;
};
#endif

View File

@@ -0,0 +1,335 @@
#include "collectiongrid.h"
#include "collectioncard.h"
#include <QPropertyAnimation>
#include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QSet>
#include <QTimer>
namespace {
void stopPosAnimations(CollectionCard* card)
{
const auto children = card->children();
for (auto* child : children) {
if (auto* anim = qobject_cast<QPropertyAnimation*>(child)) {
if (anim->propertyName() == "pos") {
anim->stop();
anim->deleteLater();
}
}
}
}
}
CollectionGrid::CollectionGrid(QWidget* parent)
: QWidget(parent)
{
setMinimumHeight(100);
m_autoScrollTimer = new QTimer(this);
m_autoScrollTimer->setInterval(16);
connect(m_autoScrollTimer, &QTimer::timeout, this, [this]() {
if (!m_dragCard) {
m_autoScrollTimer->stop();
return;
}
auto* scrollArea = parentScrollArea();
if (!scrollArea) {
return;
}
auto* scrollBar = scrollArea->verticalScrollBar();
const int delta = autoScrollDelta(m_lastDragGlobalPos);
if (delta == 0) {
return;
}
const int oldValue = scrollBar->value();
scrollBar->setValue(
qBound(scrollBar->minimum(), oldValue + delta, scrollBar->maximum()));
if (scrollBar->value() != oldValue) {
updateDragPosition(m_dragCard, m_lastDragGlobalPos);
}
});
}
int CollectionGrid::cardHeightForWidth(int width) const
{
const int imageSide = qMax(0, width - 8);
const int footerHeight = 84;
return qMax(CardHeight, imageSide + footerHeight);
}
void CollectionGrid::setCards(const QList<CollectionCard*>& cards)
{
m_dragCard = nullptr;
m_dragOriginalIndex = -1;
m_dragCurrentIndex = -1;
if (m_autoScrollTimer) {
m_autoScrollTimer->stop();
}
QSet<CollectionCard*> retainedCards;
retainedCards.reserve(cards.size());
for (auto* card : cards) {
retainedCards.insert(card);
}
for (auto* card : m_cards) {
if (!retainedCards.contains(card)) {
card->hide();
card->deleteLater();
}
}
m_cards = cards;
for (auto* card : m_cards) {
card->setParent(this);
card->show();
}
relayout();
}
int CollectionGrid::columnsForWidth(int w) const
{
if (w <= 0) return 1;
const int cw = qMax(0, w - LeftPadding - RightPadding);
int cols = (cw + CardSpacing) / (CardWidth + CardSpacing);
return qMax(1, cols);
}
int CollectionGrid::contentWidth() const
{
return qMax(0, width() - LeftPadding - RightPadding);
}
int CollectionGrid::cardWidthForColumn(int column, int columns) const
{
if (columns <= 0) return CardWidth;
const int availableWidth =
qMax(0, contentWidth() - (columns - 1) * CardSpacing);
const int baseWidth = qMax(1, availableWidth / columns);
const int extraPixels = qMax(0, availableWidth % columns);
return baseWidth + (column < extraPixels ? 1 : 0);
}
int CollectionGrid::columnX(int column, int columns) const
{
int x = LeftPadding;
for (int i = 0; i < column; ++i) {
x += cardWidthForColumn(i, columns) + CardSpacing;
}
return x;
}
int CollectionGrid::columnForX(int x, int columns) const
{
if (columns <= 0) return 0;
for (int col = 0; col < columns; ++col) {
const int rightEdge = columnX(col, columns) + cardWidthForColumn(col, columns);
if (col == columns - 1 || x < rightEdge + CardSpacing / 2) {
return col;
}
}
return columns - 1;
}
QPoint CollectionGrid::posForIndex(int index, int columns) const
{
if (columns <= 0) columns = 1;
const int col = index % columns;
const int row = index / columns;
return QPoint(columnX(col, columns), row * (m_rowHeight + CardSpacing));
}
int CollectionGrid::indexForPos(const QPoint& pos, int columns) const
{
if (m_cards.isEmpty()) return 0;
const int col = columnForX(pos.x(), columns);
const int row = qMax(0, pos.y() / (m_rowHeight + CardSpacing));
const int idx = row * columns + col;
return qBound(0, idx, m_cards.size() - 1);
}
QScrollArea* CollectionGrid::parentScrollArea() const
{
QWidget* current = parentWidget();
while (current) {
if (auto* scrollArea = qobject_cast<QScrollArea*>(current)) {
return scrollArea;
}
current = current->parentWidget();
}
return nullptr;
}
int CollectionGrid::autoScrollDelta(const QPoint& globalPos) const
{
auto* scrollArea = parentScrollArea();
if (!scrollArea) return 0;
auto* scrollBar = scrollArea->verticalScrollBar();
if (!scrollBar || scrollBar->maximum() <= scrollBar->minimum()) return 0;
const QRect viewportRect(scrollArea->viewport()->mapToGlobal(QPoint(0, 0)),
scrollArea->viewport()->size());
static constexpr int EdgeThreshold = 56;
static constexpr int MaxScrollStep = 22;
const int topHotZone = viewportRect.top() + EdgeThreshold;
if (globalPos.y() < topHotZone) {
const int distance = topHotZone - globalPos.y();
return -qBound(4, distance / 2, MaxScrollStep);
}
const int bottomHotZone = viewportRect.bottom() - EdgeThreshold;
if (globalPos.y() > bottomHotZone) {
const int distance = globalPos.y() - bottomHotZone;
return qBound(4, distance / 2, MaxScrollStep);
}
return 0;
}
void CollectionGrid::updateDragPosition(CollectionCard* card, const QPoint& globalPos)
{
QPoint localPos =
mapFromGlobal(globalPos) - QPoint(card->width() / 2, card->height() / 2);
card->move(localPos);
const QPoint center = mapFromGlobal(globalPos);
int targetIndex = indexForPos(center, m_columns);
if (targetIndex != m_dragCurrentIndex &&
targetIndex >= 0 && targetIndex < m_cards.size()) {
m_cards.removeOne(card);
targetIndex = qBound(0, targetIndex, m_cards.size());
m_cards.insert(targetIndex, card);
m_dragCurrentIndex = targetIndex;
relayout(true);
}
}
void CollectionGrid::relayout(bool animate)
{
m_columns = columnsForWidth(width());
const int availableWidth =
qMax(0, contentWidth() - (m_columns - 1) * CardSpacing);
const int baseWidth = qMax(1, availableWidth / m_columns);
const int extraPixels = qMax(0, availableWidth % m_columns);
const int maxCardWidth = baseWidth + (extraPixels > 0 ? 1 : 0);
m_rowHeight = cardHeightForWidth(maxCardWidth);
int rows = m_cards.isEmpty() ? 0 : (m_cards.size() + m_columns - 1) / m_columns;
int totalH = rows * (m_rowHeight + CardSpacing) - (rows > 0 ? CardSpacing : 0);
setMinimumHeight(qMax(totalH, 100));
for (int i = 0; i < m_cards.size(); ++i) {
auto* card = m_cards[i];
const int col = i % m_columns;
const int cardW = baseWidth + (col < extraPixels ? 1 : 0);
card->setFixedSize(cardW, m_rowHeight);
if (card == m_dragCard) {
continue;
}
QPoint target = posForIndex(i, m_columns);
if (animate && card->pos() != target) {
stopPosAnimations(card);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(220);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
stopPosAnimations(card);
card->move(target);
}
}
}
void CollectionGrid::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
relayout(false);
}
void CollectionGrid::onCardDragStart(CollectionCard* card, const QPoint& globalPos)
{
if (!card) {
return;
}
m_dragCard = card;
m_dragOriginalIndex = m_cards.indexOf(card);
m_dragCurrentIndex = m_dragOriginalIndex;
m_lastDragGlobalPos = globalPos;
for (auto* currentCard : m_cards) {
stopPosAnimations(currentCard);
}
card->setDragging(true);
card->raise();
m_autoScrollTimer->start();
}
void CollectionGrid::onCardDragMove(CollectionCard* card, const QPoint& globalPos)
{
if (!card || card != m_dragCard) {
return;
}
m_lastDragGlobalPos = globalPos;
updateDragPosition(card, globalPos);
}
void CollectionGrid::onCardDragEnd(CollectionCard* card)
{
if (!card) {
return;
}
card->setDragging(false);
m_autoScrollTimer->stop();
CollectionCard* dragCard = m_dragCard;
const int finalIndex = qMax(0, m_dragCurrentIndex);
const bool changed = (m_dragOriginalIndex != m_dragCurrentIndex);
m_dragCard = nullptr;
m_dragOriginalIndex = -1;
m_dragCurrentIndex = -1;
stopPosAnimations(card);
const QPoint target = posForIndex(finalIndex, m_columns);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(180);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
if (changed && dragCard) {
QStringList newOrderIds;
for (auto* currentCard : m_cards) {
newOrderIds.append(currentCard->itemId());
}
emit orderChanged(newOrderIds);
}
}

View File

@@ -0,0 +1,67 @@
#ifndef COLLECTIONGRID_H
#define COLLECTIONGRID_H
#include <QList>
#include <QPoint>
#include <QStringList>
#include <QWidget>
class CollectionCard;
class QResizeEvent;
class QScrollArea;
class QTimer;
class CollectionGrid : public QWidget {
Q_OBJECT
public:
explicit CollectionGrid(QWidget* parent = nullptr);
void setCards(const QList<CollectionCard*>& cards);
QList<CollectionCard*> cards() const { return m_cards; }
void relayout(bool animate = false);
static constexpr int CardWidth = 170;
static constexpr int CardHeight = 246;
static constexpr int CardSpacing = 12;
static constexpr int LeftPadding = 4;
static constexpr int RightPadding = 20;
signals:
void orderChanged(const QStringList& newOrderIds);
public slots:
void onCardDragStart(CollectionCard* card, const QPoint& globalPos);
void onCardDragMove(CollectionCard* card, const QPoint& globalPos);
void onCardDragEnd(CollectionCard* card);
protected:
void resizeEvent(QResizeEvent* event) override;
private:
int cardHeightForWidth(int width) const;
int columnsForWidth(int width) const;
int contentWidth() const;
int cardWidthForColumn(int column, int columns) const;
int columnX(int column, int columns) const;
int columnForX(int x, int columns) const;
QPoint posForIndex(int index, int columns) const;
int indexForPos(const QPoint& pos, int columns) const;
QScrollArea* parentScrollArea() const;
int autoScrollDelta(const QPoint& globalPos) const;
void updateDragPosition(CollectionCard* card, const QPoint& globalPos);
QList<CollectionCard*> m_cards;
CollectionCard* m_dragCard = nullptr;
int m_dragOriginalIndex = -1;
int m_dragCurrentIndex = -1;
int m_columns = 3;
int m_rowHeight = CardHeight;
QPoint m_lastDragGlobalPos;
QTimer* m_autoScrollTimer = nullptr;
};
#endif

View File

@@ -0,0 +1,462 @@
#include "danmakuoptionslider.h"
#include "editablevaluelabel.h"
#include "modernslider.h"
#include <config/configstore.h>
#include <QResizeEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QSignalBlocker>
#include <QVariant>
#include <QVBoxLayout>
#include <algorithm>
#include <cmath>
namespace {
QLabel *createHintLabel(QWidget *parent, Qt::Alignment alignment)
{
auto *label = new QLabel(parent);
label->setObjectName("DanmakuOptionSliderHint");
label->setAlignment(alignment);
label->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
return label;
}
QString stripTrailingZeros(QString text)
{
if (!text.contains(QLatin1Char('.'))) {
return text;
}
while (text.endsWith(QLatin1Char('0'))) {
text.chop(1);
}
if (text.endsWith(QLatin1Char('.'))) {
text.chop(1);
}
return text;
}
}
DanmakuOptionSlider::DanmakuOptionSlider(DanmakuOptionUtils::SliderKind kind,
QString configKey,
QWidget *parent)
: QWidget(parent), m_kind(kind), m_configKey(configKey)
{
setObjectName("DanmakuOptionSlider");
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
setMinimumWidth(240);
setMaximumWidth(320);
m_mainLayout = new QVBoxLayout(this);
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(4);
m_topRow = new QWidget(this);
m_topLayout = new QHBoxLayout(m_topRow);
m_topLayout->setContentsMargins(0, 0, 0, 0);
m_topLayout->setSpacing(8);
m_topMinLabel = createHintLabel(m_topRow, Qt::AlignLeft | Qt::AlignVCenter);
m_topLayout->addWidget(m_topMinLabel, 1);
m_valueLabel = new EditableValueLabel(QStringLiteral("DanmakuOptionSliderValue"),
m_topRow);
m_valueLabel->setAlignment(Qt::AlignCenter);
m_valueLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_topLayout->addWidget(m_valueLabel, 1);
m_topMaxLabel = createHintLabel(m_topRow, Qt::AlignRight | Qt::AlignVCenter);
m_topLayout->addWidget(m_topMaxLabel, 1);
m_mainLayout->addWidget(m_topRow);
m_middleRow = new QWidget(this);
m_middleLayout = new QHBoxLayout(m_middleRow);
m_middleLayout->setContentsMargins(0, 0, 0, 0);
m_middleLayout->setSpacing(8);
m_sideMinLabel = createHintLabel(m_middleRow,
Qt::AlignLeft | Qt::AlignVCenter);
m_middleLayout->addWidget(m_sideMinLabel, 0, Qt::AlignVCenter);
m_slider = new ModernSlider(Qt::Horizontal, m_middleRow);
m_slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_slider->setFixedHeight(sliderPreferredHeight());
m_middleLayout->addWidget(m_slider, 1);
m_sideMaxLabel = createHintLabel(m_middleRow,
Qt::AlignRight | Qt::AlignVCenter);
m_middleLayout->addWidget(m_sideMaxLabel, 0, Qt::AlignVCenter);
m_mainLayout->addWidget(m_middleRow);
m_bottomRow = new QWidget(this);
m_bottomLayout = new QHBoxLayout(m_bottomRow);
m_bottomLayout->setContentsMargins(0, 0, 0, 0);
m_bottomLayout->setSpacing(8);
m_bottomMinLabel = createHintLabel(m_bottomRow,
Qt::AlignLeft | Qt::AlignVCenter);
m_bottomLayout->addWidget(m_bottomMinLabel, 1);
m_bottomMaxLabel = createHintLabel(m_bottomRow,
Qt::AlignRight | Qt::AlignVCenter);
m_bottomLayout->addWidget(m_bottomMaxLabel, 1);
m_mainLayout->addWidget(m_bottomRow);
applySpec();
syncFromStore();
connect(m_slider, &QSlider::valueChanged, this,
&DanmakuOptionSlider::handleSliderValueChanged);
connect(m_slider, &QSlider::sliderReleased, this,
&DanmakuOptionSlider::handleSliderReleased);
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this,
&DanmakuOptionSlider::handleConfigValueChanged);
connect(m_valueLabel, &EditableValueLabel::textSubmitted, this,
&DanmakuOptionSlider::handleValueTextSubmitted);
setCompactMode(false);
}
int DanmakuOptionSlider::value() const
{
return m_slider ? m_slider->value() : 0;
}
void DanmakuOptionSlider::setCompactMode(bool compact)
{
if (m_mainLayout) {
m_mainLayout->setSpacing(compact ? 2 : 4);
}
if (m_topLayout) {
m_topLayout->setSpacing(compact ? 6 : 8);
}
if (m_middleLayout) {
m_middleLayout->setSpacing(compact ? 6 : 8);
}
if (m_bottomLayout) {
m_bottomLayout->setSpacing(compact ? 6 : 8);
}
if (m_slider) {
m_slider->setFixedHeight(compact ? 20 : 24);
}
m_compactMode = compact;
updateAdaptiveLayout();
updateGeometry();
}
bool DanmakuOptionSlider::isCompactMode() const
{
return m_compactMode;
}
void DanmakuOptionSlider::setAdaptiveRangeLabelPlacementEnabled(bool enabled)
{
m_adaptiveRangeLabelPlacementEnabled = enabled;
updateAdaptiveLayout();
updateGeometry();
}
bool DanmakuOptionSlider::isAdaptiveRangeLabelPlacementEnabled() const
{
return m_adaptiveRangeLabelPlacementEnabled;
}
void DanmakuOptionSlider::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
if (m_adaptiveRangeLabelPlacementEnabled) {
updateAdaptiveLayout();
}
}
void DanmakuOptionSlider::handleSliderValueChanged(int value)
{
setCurrentValue(value, true, false);
if (!m_slider || !m_slider->isSliderDown()) {
emit valueChanged(this->value());
}
}
void DanmakuOptionSlider::handleSliderReleased()
{
emit valueChanged(value());
}
void DanmakuOptionSlider::handleConfigValueChanged(const QString &key,
const QVariant &newValue)
{
if (key != m_configKey || !m_slider) {
return;
}
const DanmakuOptionUtils::SliderSpec spec =
DanmakuOptionUtils::sliderSpec(m_kind);
const int resolvedValue = DanmakuOptionUtils::clampSliderValue(
m_kind, variantToInt(newValue, spec.defaultValue));
setCurrentValue(resolvedValue, false, false);
}
void DanmakuOptionSlider::handleValueTextSubmitted(const QString &text)
{
int resolvedValue = 0;
if (!DanmakuOptionUtils::parseSliderValue(m_kind, text, resolvedValue)) {
refreshLabels();
return;
}
setCurrentValue(resolvedValue, true, true);
}
void DanmakuOptionSlider::applySpec()
{
const DanmakuOptionUtils::SliderSpec spec =
DanmakuOptionUtils::sliderSpec(m_kind);
m_slider->setRange(spec.minimum, spec.maximum);
m_slider->setSingleStep(spec.singleStep);
m_slider->setPageStep(spec.pageStep);
m_slider->setBufferValue(spec.minimum);
}
void DanmakuOptionSlider::refreshLabels()
{
if (!m_slider || !m_valueLabel) {
return;
}
const DanmakuOptionUtils::SliderSpec spec =
DanmakuOptionUtils::sliderSpec(m_kind);
m_valueLabel->setText(
DanmakuOptionUtils::formatSliderValue(m_kind, m_slider->value()));
updateRangeLabels(formatRangeHintValue(spec.minimum),
formatRangeHintValue(spec.maximum));
updateAdaptiveLayout();
}
void DanmakuOptionSlider::syncFromStore()
{
const DanmakuOptionUtils::SliderSpec spec =
DanmakuOptionUtils::sliderSpec(m_kind);
int resolvedValue = spec.defaultValue;
if (!m_configKey.trimmed().isEmpty()) {
const QVariant value = ConfigStore::instance()->get<QVariant>(
m_configKey, QVariant(QString::number(spec.defaultValue)));
resolvedValue =
DanmakuOptionUtils::clampSliderValue(m_kind,
variantToInt(value, spec.defaultValue));
}
setCurrentValue(resolvedValue, false, false);
}
void DanmakuOptionSlider::setCurrentValue(int value, bool persistToStore,
bool notify)
{
if (!m_slider) {
return;
}
const int resolvedValue =
DanmakuOptionUtils::clampSliderValue(m_kind, value);
if (m_slider->value() != resolvedValue) {
QSignalBlocker blocker(m_slider);
m_slider->setValue(resolvedValue);
}
refreshLabels();
if (persistToStore && !m_configKey.trimmed().isEmpty()) {
ConfigStore::instance()->set(m_configKey, QString::number(resolvedValue));
}
if (notify) {
emit valueChanged(resolvedValue);
}
}
int DanmakuOptionSlider::variantToInt(const QVariant &value, int fallback)
{
bool ok = false;
const int result = value.toInt(&ok);
if (ok) {
return result;
}
const QString text = value.toString().trimmed();
const int parsed = text.toInt(&ok);
return ok ? parsed : fallback;
}
void DanmakuOptionSlider::updateAdaptiveLayout()
{
if (!m_valueLabel || !m_slider || !m_topMinLabel || !m_topMaxLabel ||
!m_sideMinLabel || !m_sideMaxLabel || !m_bottomMinLabel ||
!m_bottomMaxLabel) {
return;
}
if (!m_adaptiveRangeLabelPlacementEnabled) {
applyRangeLabelPlacement(RangeLabelPlacement::Bottom);
return;
}
const int spacing = m_mainLayout ? m_mainLayout->spacing() : 0;
const int sliderHeight = sliderPreferredHeight();
const int valueHeight = m_valueLabel->sizeHint().height();
const int hintHeight = std::max(
{m_topMinLabel->sizeHint().height(), m_topMaxLabel->sizeHint().height(),
m_sideMinLabel->sizeHint().height(), m_sideMaxLabel->sizeHint().height(),
m_bottomMinLabel->sizeHint().height(),
m_bottomMaxLabel->sizeHint().height()});
const int availableHeight =
height() > 0 ? height()
: (m_compactMode ? valueHeight + spacing + sliderHeight
: valueHeight + spacing + sliderHeight +
spacing + hintHeight);
const int availableWidth =
width() > 0 ? width() : qMax(minimumWidth(), 240);
const int topRequiredHeight = qMax(valueHeight, hintHeight) + spacing +
sliderHeight;
const int bottomRequiredHeight =
valueHeight + spacing + sliderHeight + spacing + hintHeight;
const int sideRequiredHeight =
valueHeight + spacing + qMax(sliderHeight, hintHeight);
const int topRequiredWidth =
m_topMinLabel->sizeHint().width() + m_valueLabel->minimumSizeHint().width() +
m_topMaxLabel->sizeHint().width() +
(m_topLayout ? m_topLayout->spacing() * 2 : 0);
const int sideRequiredWidth =
m_sideMinLabel->sizeHint().width() + sideSliderMinimumWidth() +
m_sideMaxLabel->sizeHint().width() +
(m_middleLayout ? m_middleLayout->spacing() * 2 : 0);
const bool canShowBottom = availableHeight >= bottomRequiredHeight;
const bool canShowTop =
availableHeight >= topRequiredHeight &&
availableWidth >= topRequiredWidth;
const bool canShowSide =
availableHeight >= sideRequiredHeight &&
availableWidth >= sideRequiredWidth;
RangeLabelPlacement placement = RangeLabelPlacement::Bottom;
if (m_compactMode) {
if (canShowTop) {
placement = RangeLabelPlacement::Top;
} else {
placement = RangeLabelPlacement::Side;
}
} else {
if (!canShowBottom) {
placement = canShowTop ? RangeLabelPlacement::Top
: RangeLabelPlacement::Side;
}
if (placement == RangeLabelPlacement::Side && !canShowSide && canShowTop) {
placement = RangeLabelPlacement::Top;
}
}
applyRangeLabelPlacement(placement);
}
void DanmakuOptionSlider::applyRangeLabelPlacement(
RangeLabelPlacement placement)
{
m_rangeLabelPlacement = placement;
const bool showTopHints = placement == RangeLabelPlacement::Top;
const bool showSideHints = placement == RangeLabelPlacement::Side;
const bool showBottomHints = placement == RangeLabelPlacement::Bottom;
if (m_topMinLabel) {
m_topMinLabel->setVisible(showTopHints);
}
if (m_topMaxLabel) {
m_topMaxLabel->setVisible(showTopHints);
}
if (m_sideMinLabel) {
m_sideMinLabel->setVisible(showSideHints);
}
if (m_sideMaxLabel) {
m_sideMaxLabel->setVisible(showSideHints);
}
if (m_bottomRow) {
m_bottomRow->setVisible(showBottomHints);
}
if (m_slider) {
m_slider->setMinimumWidth(showSideHints ? sideSliderMinimumWidth() : 0);
}
updateGeometry();
}
void DanmakuOptionSlider::updateRangeLabels(const QString &minimumText,
const QString &maximumText)
{
if (m_topMinLabel) {
m_topMinLabel->setText(minimumText);
}
if (m_sideMinLabel) {
m_sideMinLabel->setText(minimumText);
}
if (m_bottomMinLabel) {
m_bottomMinLabel->setText(minimumText);
}
if (m_topMaxLabel) {
m_topMaxLabel->setText(maximumText);
}
if (m_sideMaxLabel) {
m_sideMaxLabel->setText(maximumText);
}
if (m_bottomMaxLabel) {
m_bottomMaxLabel->setText(maximumText);
}
}
int DanmakuOptionSlider::sliderPreferredHeight() const
{
return m_compactMode ? 20 : 24;
}
int DanmakuOptionSlider::sideSliderMinimumWidth() const
{
return m_compactMode ? 48 : 96;
}
QString DanmakuOptionSlider::formatRangeHintValue(int value) const
{
if (!m_compactMode) {
return DanmakuOptionUtils::formatSliderValue(m_kind, value);
}
if (m_kind == DanmakuOptionUtils::SliderKind::OffsetMs) {
if (value == 0) {
return QStringLiteral("0");
}
const double seconds = value / 1000.0;
const bool isIntegerSecond = std::fmod(std::fabs(seconds), 1.0) < 0.001;
QString text = stripTrailingZeros(
QString::number(seconds, 'f', isIntegerSecond ? 0 : 1));
if (value > 0) {
text.prepend(QLatin1Char('+'));
}
return text + QStringLiteral("s");
}
return DanmakuOptionUtils::formatSliderValue(m_kind, value);
}

View File

@@ -0,0 +1,84 @@
#ifndef DANMAKUOPTIONSLIDER_H
#define DANMAKUOPTIONSLIDER_H
#include "../utils/danmakuoptionutils.h"
#include <QVariant>
#include <QWidget>
class QHBoxLayout;
class QLabel;
class ModernSlider;
class EditableValueLabel;
class QResizeEvent;
class QVBoxLayout;
class DanmakuOptionSlider : public QWidget
{
Q_OBJECT
public:
explicit DanmakuOptionSlider(DanmakuOptionUtils::SliderKind kind,
QString configKey,
QWidget *parent = nullptr);
int value() const;
void setCompactMode(bool compact);
bool isCompactMode() const;
void setAdaptiveRangeLabelPlacementEnabled(bool enabled);
bool isAdaptiveRangeLabelPlacementEnabled() const;
signals:
void valueChanged(int value);
protected:
void resizeEvent(QResizeEvent *event) override;
private slots:
void handleSliderValueChanged(int value);
void handleSliderReleased();
void handleConfigValueChanged(const QString &key, const QVariant &newValue);
void handleValueTextSubmitted(const QString &text);
private:
enum class RangeLabelPlacement {
Bottom,
Top,
Side
};
void applySpec();
void refreshLabels();
void syncFromStore();
void setCurrentValue(int value, bool persistToStore, bool notify);
void updateAdaptiveLayout();
void applyRangeLabelPlacement(RangeLabelPlacement placement);
void updateRangeLabels(const QString &minimumText, const QString &maximumText);
QString formatRangeHintValue(int value) const;
int sliderPreferredHeight() const;
int sideSliderMinimumWidth() const;
static int variantToInt(const QVariant &value, int fallback);
DanmakuOptionUtils::SliderKind m_kind;
QString m_configKey;
QVBoxLayout *m_mainLayout = nullptr;
QWidget *m_topRow = nullptr;
QWidget *m_middleRow = nullptr;
QWidget *m_bottomRow = nullptr;
QHBoxLayout *m_topLayout = nullptr;
QHBoxLayout *m_middleLayout = nullptr;
QHBoxLayout *m_bottomLayout = nullptr;
EditableValueLabel *m_valueLabel = nullptr;
QLabel *m_topMinLabel = nullptr;
QLabel *m_topMaxLabel = nullptr;
QLabel *m_sideMinLabel = nullptr;
QLabel *m_sideMaxLabel = nullptr;
QLabel *m_bottomMinLabel = nullptr;
QLabel *m_bottomMaxLabel = nullptr;
ModernSlider *m_slider = nullptr;
RangeLabelPlacement m_rangeLabelPlacement = RangeLabelPlacement::Bottom;
bool m_compactMode = false;
bool m_adaptiveRangeLabelPlacementEnabled = false;
};
#endif

View File

@@ -0,0 +1,887 @@
#include "danmakuserverdialog.h"
#include "danmakuserverlistitemwidget.h"
#include "modernmessagebox.h"
#include "services/danmaku/danmakusettings.h"
#include <QAbstractItemView>
#include <QEvent>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPushButton>
#include <QSignalBlocker>
#include <QShowEvent>
#include <QUrl>
#include <QUuid>
#include <QVBoxLayout>
#include <utility>
namespace {
QString fallbackServerName(const DanmakuServerDefinition &server)
{
return server.name.trimmed().isEmpty() ? server.baseUrl.trimmed()
: server.name.trimmed();
}
}
DanmakuServerDialog::DanmakuServerDialog(QWidget *parent)
: ModernDialogBase(parent)
{
setTitle(tr("Danmaku Servers"));
setMinimumSize(920, 620);
auto *hintLabel = new QLabel(
tr("Manage multiple danmaku servers here. Click a server card to edit "
"it and choose the active server used for matching and loading "
"danmaku."),
this);
hintLabel->setObjectName("dialog-text");
hintLabel->setWordWrap(true);
contentLayout()->addWidget(hintLabel);
contentLayout()->addSpacing(8);
auto *bodyLayout = new QHBoxLayout();
bodyLayout->setSpacing(18);
auto *listPanel = new QFrame(this);
listPanel->setObjectName("DanmakuServerListPanel");
listPanel->setMinimumWidth(360);
auto *listLayout = new QVBoxLayout(listPanel);
listLayout->setContentsMargins(18, 18, 18, 18);
listLayout->setSpacing(12);
auto *listTitle = new QLabel(tr("Servers"), listPanel);
listTitle->setObjectName("SettingsCardTitle");
listLayout->addWidget(listTitle);
auto *listDesc = new QLabel(
tr("Each item shows the danmaku server name, address, app "
"credentials, and description. The built-in official "
"DandanPlay server supports anime only."),
listPanel);
listDesc->setObjectName("SettingsCardDesc");
listDesc->setWordWrap(true);
listLayout->addWidget(listDesc);
auto *actionRow = new QHBoxLayout();
actionRow->setSpacing(10);
m_addButton = new QPushButton(tr("Add"), listPanel);
m_addButton->setObjectName("SettingsCardButton");
m_addButton->setCursor(Qt::PointingHandCursor);
m_addButton->setFixedHeight(32);
actionRow->addWidget(m_addButton);
actionRow->addStretch();
listLayout->addLayout(actionRow);
m_serverList = new QListWidget(listPanel);
m_serverList->setObjectName("DanmakuServerList");
m_serverList->setFrameShape(QFrame::NoFrame);
m_serverList->setCursor(Qt::PointingHandCursor);
m_serverList->setSelectionMode(QAbstractItemView::SingleSelection);
m_serverList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_serverList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_serverList->setSpacing(10);
m_serverList->viewport()->installEventFilter(this);
listLayout->addWidget(m_serverList, 1);
bodyLayout->addWidget(listPanel, 1);
auto *editorPanel = new QFrame(this);
editorPanel->setObjectName("DanmakuServerEditorPanel");
editorPanel->setMinimumWidth(420);
auto *editorLayout = new QVBoxLayout(editorPanel);
editorLayout->setContentsMargins(18, 18, 18, 18);
editorLayout->setSpacing(14);
auto *editorTitle = new QLabel(tr("Edit Selected Server"), editorPanel);
editorTitle->setObjectName("DanmakuServerEditorTitle");
editorLayout->addWidget(editorTitle);
auto *editorDesc = new QLabel(
tr("Update the server name, address, description, and app credentials for the "
"selected danmaku server. Official DandanPlay endpoints require "
"App ID and App Secret and support anime only."),
editorPanel);
editorDesc->setObjectName("DanmakuServerEditorDesc");
editorDesc->setWordWrap(true);
editorLayout->addWidget(editorDesc);
auto *formLayout = new QFormLayout();
formLayout->setContentsMargins(0, 4, 0, 0);
formLayout->setHorizontalSpacing(14);
formLayout->setVerticalSpacing(14);
formLayout->setLabelAlignment(Qt::AlignLeft | Qt::AlignVCenter);
auto *nameLabel = new QLabel(tr("Name"), editorPanel);
nameLabel->setObjectName("LibraryMetadataFieldLabel");
m_nameEdit = new QLineEdit(editorPanel);
m_nameEdit->setObjectName("ManageLibInput");
m_nameEdit->setClearButtonEnabled(true);
m_nameEdit->setPlaceholderText(tr("e.g., Official DandanPlay"));
formLayout->addRow(nameLabel, m_nameEdit);
auto *urlLabel = new QLabel(tr("Server URL"), editorPanel);
urlLabel->setObjectName("LibraryMetadataFieldLabel");
m_baseUrlEdit = new QLineEdit(editorPanel);
m_baseUrlEdit->setObjectName("ManageLibInput");
m_baseUrlEdit->setClearButtonEnabled(true);
m_baseUrlEdit->setPlaceholderText(
QStringLiteral("https://api.dandanplay.net"));
formLayout->addRow(urlLabel, m_baseUrlEdit);
m_descriptionLabel = new QLabel(tr("Description"), editorPanel);
m_descriptionLabel->setObjectName("LibraryMetadataFieldLabel");
m_descriptionEdit = new QLineEdit(editorPanel);
m_descriptionEdit->setObjectName("ManageLibInput");
m_descriptionEdit->setClearButtonEnabled(true);
m_descriptionEdit->setPlaceholderText(tr("e.g., Supports anime only"));
formLayout->addRow(m_descriptionLabel, m_descriptionEdit);
m_appIdLabel = new QLabel(tr("App ID"), editorPanel);
m_appIdLabel->setObjectName("LibraryMetadataFieldLabel");
m_appIdEdit = new QLineEdit(editorPanel);
m_appIdEdit->setObjectName("ManageLibInput");
m_appIdEdit->setClearButtonEnabled(true);
m_appIdEdit->setPlaceholderText(tr("Required for official DandanPlay"));
formLayout->addRow(m_appIdLabel, m_appIdEdit);
m_appSecretLabel = new QLabel(tr("App Secret"), editorPanel);
m_appSecretLabel->setObjectName("LibraryMetadataFieldLabel");
m_appSecretEdit = new QLineEdit(editorPanel);
m_appSecretEdit->setObjectName("ManageLibInput");
m_appSecretEdit->setClearButtonEnabled(true);
m_appSecretEdit->setEchoMode(QLineEdit::Password);
m_appSecretEdit->setPlaceholderText(
tr("Required for official DandanPlay"));
formLayout->addRow(m_appSecretLabel, m_appSecretEdit);
editorLayout->addLayout(formLayout);
editorLayout->addStretch();
bodyLayout->addWidget(editorPanel, 1);
contentLayout()->addLayout(bodyLayout);
contentLayout()->addSpacing(24);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->setSpacing(12);
buttonLayout->addStretch();
auto *cancelButton = new QPushButton(tr("Cancel"), this);
cancelButton->setObjectName("dialog-btn-cancel");
cancelButton->setCursor(Qt::PointingHandCursor);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
buttonLayout->addWidget(cancelButton);
auto *saveButton = new QPushButton(tr("Save"), this);
saveButton->setObjectName("dialog-btn-primary");
saveButton->setCursor(Qt::PointingHandCursor);
connect(saveButton, &QPushButton::clicked, this,
[this]() { accept(); });
buttonLayout->addWidget(saveButton);
contentLayout()->addLayout(buttonLayout);
connect(m_serverList, &QListWidget::currentRowChanged, this,
[this](int row) {
if (m_loading || row < 0 || row >= m_servers.size()) {
return;
}
m_editingServerId = m_servers.at(row).id;
if (m_servers.at(row).enabled) {
m_selectedServerId = m_servers.at(row).id;
}
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
});
connect(m_nameEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerName(text); });
connect(m_baseUrlEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerUrl(text); });
connect(m_descriptionEdit, &QLineEdit::textChanged, this,
[this](const QString &text) {
updateCurrentServerDescription(text);
});
connect(m_appIdEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerAppId(text); });
connect(m_appSecretEdit, &QLineEdit::textChanged, this,
[this](const QString &text) {
updateCurrentServerAppSecret(text);
});
connect(m_addButton, &QPushButton::clicked, this, [this]() {
DanmakuServerDefinition server =
DanmakuSettings::builtInOfficialDandanplayServer();
server.id = createServerId();
server.builtIn = false;
server.contentScope.clear();
server.description.clear();
server.appId.clear();
server.appSecret.clear();
server.name = tr("Danmaku Server %1").arg(m_servers.size() + 1);
server.enabled = true;
m_servers.append(server);
refreshServerList();
selectServer(m_servers.size() - 1);
m_nameEdit->setFocus();
m_nameEdit->selectAll();
});
setServers({DanmakuSettings::builtInOfficialDandanplayServer()},
QStringLiteral("default"));
}
void DanmakuServerDialog::setServers(QList<DanmakuServerDefinition> servers,
QString selectedServerId)
{
if (servers.isEmpty()) {
servers.append(DanmakuSettings::builtInOfficialDandanplayServer());
}
for (DanmakuServerDefinition &server : servers) {
server.id = server.id.trimmed();
server.name = server.name.trimmed();
server.provider = server.provider.trimmed();
server.baseUrl = normalizeBaseUrl(server.baseUrl);
server.description = server.description.trimmed();
server.appId = server.appId.trimmed();
server.appSecret = server.appSecret.trimmed();
server.contentScope = server.contentScope.trimmed().toLower();
if (server.id.isEmpty()) {
server.id = createServerId();
}
if (server.builtIn) {
const DanmakuServerDefinition builtIn =
DanmakuSettings::builtInOfficialDandanplayServer();
server.id = builtIn.id;
server.provider = builtIn.provider;
server.baseUrl = builtIn.baseUrl;
server.appId = builtIn.appId;
server.appSecret = builtIn.appSecret;
server.description.clear();
server.contentScope = builtIn.contentScope;
if (server.name.isEmpty()) {
server.name = builtIn.name;
}
}
if (server.provider.isEmpty()) {
server.provider = QStringLiteral("dandanplay");
}
if (server.name.isEmpty()) {
server.name = fallbackServerName(server);
}
}
m_servers = std::move(servers);
m_selectedServerId = selectedServerId.trimmed();
if (m_selectedServerId.trimmed().isEmpty() && !m_servers.isEmpty()) {
const int activeIndex = preferredServerIndex(0);
if (activeIndex >= 0) {
m_selectedServerId = m_servers.at(activeIndex).id;
}
}
m_editingServerId = m_selectedServerId;
refreshServerList();
selectServer(editingServerIndex());
}
QList<DanmakuServerDefinition> DanmakuServerDialog::servers() const
{
return m_servers;
}
QString DanmakuServerDialog::selectedServerId() const
{
if (enabledServerCount() <= 0) {
return QString();
}
const int index = preferredServerIndex(currentServerIndex());
if (index >= 0 && index < m_servers.size()) {
return m_servers.at(index).id;
}
return m_selectedServerId.trimmed().isEmpty() && !m_servers.isEmpty()
? m_servers.first().id
: m_selectedServerId;
}
void DanmakuServerDialog::accept()
{
if (m_servers.isEmpty()) {
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("At least one danmaku server is required."), tr("OK"));
return;
}
for (const DanmakuServerDefinition &server : std::as_const(m_servers)) {
if (server.name.trimmed().isEmpty()) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_nameEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Every danmaku server must have a name."), tr("OK"));
return;
}
if (server.baseUrl.trimmed().isEmpty()) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_baseUrlEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Every danmaku server must have a server URL."), tr("OK"));
return;
}
const QUrl url = QUrl::fromUserInput(server.baseUrl.trimmed());
const QString scheme = url.scheme().trimmed().toLower();
if (!url.isValid() || url.host().trimmed().isEmpty() ||
(scheme != QStringLiteral("http") &&
scheme != QStringLiteral("https"))) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_baseUrlEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Server URL must be a valid http or https address."),
tr("OK"));
return;
}
}
const QString activeServerId = selectedServerId();
if (!activeServerId.trimmed().isEmpty()) {
m_selectedServerId = activeServerId;
}
QDialog::accept();
}
bool DanmakuServerDialog::eventFilter(QObject *watched, QEvent *event)
{
if (m_serverList && watched == m_serverList->viewport() &&
event && event->type() == QEvent::Resize) {
syncAllServerListItemSizes();
}
return ModernDialogBase::eventFilter(watched, event);
}
void DanmakuServerDialog::showEvent(QShowEvent *event)
{
ModernDialogBase::showEvent(event);
syncAllServerListItemSizes();
}
int DanmakuServerDialog::currentServerIndex() const
{
for (int index = 0; index < m_servers.size(); ++index) {
if (m_servers.at(index).id == m_selectedServerId) {
return index;
}
}
return m_servers.isEmpty() ? -1 : 0;
}
int DanmakuServerDialog::editingServerIndex() const
{
for (int index = 0; index < m_servers.size(); ++index) {
if (m_servers.at(index).id == m_editingServerId) {
return index;
}
}
return m_servers.isEmpty() ? -1 : currentServerIndex();
}
int DanmakuServerDialog::enabledServerCount() const
{
int count = 0;
for (const DanmakuServerDefinition &server : m_servers) {
if (server.enabled) {
++count;
}
}
return count;
}
int DanmakuServerDialog::preferredServerIndex(int preferredIndex) const
{
if (m_servers.isEmpty()) {
return -1;
}
const int normalizedPreferred =
qBound(0, preferredIndex < 0 ? 0 : preferredIndex, m_servers.size() - 1);
if (enabledServerCount() > 0) {
for (int offset = 0; offset < m_servers.size(); ++offset) {
const int forwardIndex = normalizedPreferred + offset;
if (forwardIndex < m_servers.size() &&
m_servers.at(forwardIndex).enabled) {
return forwardIndex;
}
const int backwardIndex = normalizedPreferred - offset;
if (offset > 0 && backwardIndex >= 0 &&
m_servers.at(backwardIndex).enabled) {
return backwardIndex;
}
}
}
return normalizedPreferred;
}
void DanmakuServerDialog::removeServer(int index)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
if (m_servers.size() <= 1) {
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("At least one danmaku server is required."), tr("OK"));
return;
}
const QString removedServerId = m_servers.at(index).id;
const QString serverName = fallbackServerName(m_servers.at(index));
if (!ModernMessageBox::question(
this, tr("Remove Danmaku Server"),
tr("Remove \"%1\" from the danmaku server list?").arg(serverName),
tr("Remove"), tr("Cancel"), ModernMessageBox::Danger,
ModernMessageBox::Warning)) {
return;
}
const bool removingSelected = removedServerId == m_selectedServerId;
const bool removingEditing = removedServerId == m_editingServerId;
m_servers.removeAt(index);
if (m_servers.isEmpty()) {
refreshServerList();
return;
}
if (removingSelected) {
const int nextActiveIndex =
preferredServerIndex(qBound(0, index, m_servers.size() - 1));
if (nextActiveIndex >= 0 && nextActiveIndex < m_servers.size()) {
m_selectedServerId = m_servers.at(nextActiveIndex).id;
}
}
if (removingEditing) {
const int nextEditingIndex =
qBound(0, index, m_servers.size() - 1);
m_editingServerId = m_servers.at(nextEditingIndex).id;
}
refreshServerList();
}
void DanmakuServerDialog::selectServer(int index)
{
if (index < 0 || index >= m_servers.size()) {
updateEditorState();
updateActionState();
return;
}
m_loading = true;
{
const QSignalBlocker blocker(m_serverList);
m_serverList->setCurrentRow(index);
}
m_editingServerId = m_servers.at(index).id;
if (m_servers.at(index).enabled) {
m_selectedServerId = m_servers.at(index).id;
}
m_loading = false;
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::refreshServerList()
{
const int activeIndex = preferredServerIndex(currentServerIndex());
const int editorIndex = editingServerIndex();
m_loading = true;
m_serverList->blockSignals(true);
m_serverList->clear();
for (int index = 0; index < m_servers.size(); ++index) {
const DanmakuServerDefinition &server = m_servers.at(index);
auto *item = new QListWidgetItem();
auto *widget = new DanmakuServerListItemWidget(m_serverList);
widget->setServer(server);
widget->setRemovable(m_servers.size() > 1 && !server.builtIn);
item->setData(Qt::UserRole, server.id);
m_serverList->addItem(item);
m_serverList->setItemWidget(item, widget);
connect(widget, &DanmakuServerListItemWidget::clicked, this,
[this, item]() {
if (!item) {
return;
}
const int row = m_serverList->row(item);
if (row < 0 || row >= m_servers.size()) {
return;
}
selectServer(row);
});
connect(widget, &DanmakuServerListItemWidget::enabledChanged, this,
[this, item](bool enabled) {
if (!item) {
return;
}
const int row = m_serverList->row(item);
if (row < 0 || row >= m_servers.size()) {
return;
}
updateServerEnabled(row, enabled);
});
connect(widget, &DanmakuServerListItemWidget::removeRequested, this,
[this, item]() {
if (!item) {
return;
}
removeServer(m_serverList->row(item));
});
}
if (!m_servers.isEmpty()) {
const int row = qBound(
0, editorIndex < 0 ? activeIndex : editorIndex, m_servers.size() - 1);
m_serverList->setCurrentRow(row);
m_editingServerId = m_servers.at(row).id;
const int normalizedActiveIndex =
activeIndex >= 0 ? activeIndex : preferredServerIndex(row);
if (normalizedActiveIndex >= 0) {
m_selectedServerId = m_servers.at(normalizedActiveIndex).id;
}
}
m_serverList->blockSignals(false);
m_loading = false;
syncAllServerListItemSizes();
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::refreshServerListItem(int index)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
auto *item = m_serverList->item(index);
if (!item) {
return;
}
auto *widget =
qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(item));
if (!widget) {
return;
}
widget->setServer(m_servers.at(index));
widget->setRemovable(m_servers.size() > 1 &&
!m_servers.at(index).builtIn);
syncServerListItemSize(index);
updateSelectionState();
}
void DanmakuServerDialog::syncServerListItemSize(int index)
{
constexpr int kListItemRightInset = 8;
if (!m_serverList || index < 0 || index >= m_serverList->count() ||
index >= m_servers.size()) {
return;
}
auto *item = m_serverList->item(index);
if (!item) {
return;
}
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(item));
if (!widget || !m_serverList->viewport()) {
return;
}
const int viewportWidth = m_serverList->viewport()->width();
const int targetWidth = viewportWidth - kListItemRightInset;
if (targetWidth <= 0) {
return;
}
widget->updateGeometry();
int targetHeight = widget->hasHeightForWidth()
? widget->heightForWidth(targetWidth)
: widget->sizeHint().height();
if (targetHeight <= 0) {
widget->adjustSize();
targetHeight = widget->sizeHint().height();
}
targetHeight = qMax(targetHeight, widget->minimumSizeHint().height());
item->setSizeHint(QSize(targetWidth, targetHeight));
widget->resize(targetWidth, targetHeight);
}
void DanmakuServerDialog::syncAllServerListItemSizes()
{
for (int index = 0; index < m_serverList->count(); ++index) {
syncServerListItemSize(index);
}
}
void DanmakuServerDialog::loadCurrentServer()
{
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
m_loading = true;
const DanmakuServerDefinition &server = m_servers.at(index);
m_nameEdit->setText(server.name);
m_baseUrlEdit->setText(server.baseUrl);
m_descriptionEdit->setText(server.description);
m_appIdEdit->setText(server.appId);
m_appSecretEdit->setText(server.appSecret);
m_loading = false;
}
void DanmakuServerDialog::updateSelectionState()
{
const int currentRow = m_serverList->currentRow();
const QString activeServerId = selectedServerId();
for (int row = 0; row < m_serverList->count(); ++row) {
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(m_serverList->item(row)));
if (widget) {
widget->setSelected(row == currentRow);
widget->setCurrentServer(m_servers.at(row).id == activeServerId);
}
}
}
void DanmakuServerDialog::updateEditorState()
{
const int index = editingServerIndex();
const bool hasCurrentServer = index >= 0 && index < m_servers.size();
const bool isBuiltIn = hasCurrentServer && m_servers.at(index).builtIn;
m_nameEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_baseUrlEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_descriptionLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_descriptionEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_descriptionEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_appIdLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_appIdEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_appIdEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_appSecretLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_appSecretEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_appSecretEdit->setEnabled(hasCurrentServer && !isBuiltIn);
}
void DanmakuServerDialog::updateServerEnabled(int index, bool enabled)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
const QString previousActiveServerId = selectedServerId();
m_servers[index].enabled = enabled;
if (enabled && m_servers.at(index).id == m_editingServerId) {
m_selectedServerId = m_servers.at(index).id;
} else if (!enabled && m_servers.at(index).id == m_selectedServerId) {
const int nextIndex = preferredServerIndex(index);
if (nextIndex >= 0 && nextIndex != index) {
m_selectedServerId = m_servers.at(nextIndex).id;
}
}
refreshServerListItem(index);
const QString activeServerId = selectedServerId();
if (activeServerId != previousActiveServerId) {
for (int row = 0; row < m_servers.size(); ++row) {
if (m_servers.at(row).id == previousActiveServerId ||
m_servers.at(row).id == activeServerId) {
refreshServerListItem(row);
}
}
}
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::updateCurrentServerName(const QString &name)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].name = name.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerUrl(const QString &baseUrl)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].baseUrl = baseUrl.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerDescription(
const QString &description)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].description = description.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerAppId(const QString &appId)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].appId = appId.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerAppSecret(
const QString &appSecret)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].appSecret = appSecret.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateActionState()
{
for (int row = 0; row < m_serverList->count(); ++row) {
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(m_serverList->item(row)));
if (widget) {
const bool removable =
row < m_servers.size() && m_servers.size() > 1 &&
!m_servers.at(row).builtIn;
widget->setRemovable(removable);
}
}
}
QString DanmakuServerDialog::createServerId()
{
return QUuid::createUuid().toString(QUuid::WithoutBraces);
}
QString DanmakuServerDialog::normalizeBaseUrl(QString baseUrl)
{
baseUrl = baseUrl.trimmed();
while (baseUrl.endsWith('/') &&
!baseUrl.endsWith(QStringLiteral("://"))) {
baseUrl.chop(1);
}
return baseUrl;
}

View File

@@ -0,0 +1,76 @@
#ifndef DANMAKUSERVERDIALOG_H
#define DANMAKUSERVERDIALOG_H
#include "moderndialogbase.h"
#include "models/danmaku/danmakumodels.h"
#include <QList>
#include <QString>
class QListWidget;
class QLineEdit;
class QLabel;
class QPushButton;
class QEvent;
class QShowEvent;
class DanmakuServerDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit DanmakuServerDialog(QWidget *parent = nullptr);
void setServers(QList<DanmakuServerDefinition> servers,
QString selectedServerId = QString());
QList<DanmakuServerDefinition> servers() const;
QString selectedServerId() const;
protected:
void accept() override;
bool eventFilter(QObject *watched, QEvent *event) override;
void showEvent(QShowEvent *event) override;
private:
int currentServerIndex() const;
int editingServerIndex() const;
int enabledServerCount() const;
int preferredServerIndex(int preferredIndex) const;
void removeServer(int index);
void selectServer(int index);
void refreshServerList();
void refreshServerListItem(int index);
void syncServerListItemSize(int index);
void syncAllServerListItemSizes();
void loadCurrentServer();
void updateSelectionState();
void updateEditorState();
void updateServerEnabled(int index, bool enabled);
void updateCurrentServerName(const QString &name);
void updateCurrentServerUrl(const QString &baseUrl);
void updateCurrentServerDescription(const QString &description);
void updateCurrentServerAppId(const QString &appId);
void updateCurrentServerAppSecret(const QString &appSecret);
void updateActionState();
static QString createServerId();
static QString normalizeBaseUrl(QString baseUrl);
QList<DanmakuServerDefinition> m_servers;
QString m_selectedServerId;
QString m_editingServerId;
bool m_loading = false;
QListWidget *m_serverList = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_baseUrlEdit = nullptr;
QLabel *m_descriptionLabel = nullptr;
QLineEdit *m_descriptionEdit = nullptr;
QLabel *m_appIdLabel = nullptr;
QLineEdit *m_appIdEdit = nullptr;
QLabel *m_appSecretLabel = nullptr;
QLineEdit *m_appSecretEdit = nullptr;
QPushButton *m_addButton = nullptr;
};
#endif

View File

@@ -0,0 +1,230 @@
#include "danmakuserverlistitemwidget.h"
#include "../managers/thememanager.h"
#include "modernswitch.h"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSizePolicy>
#include <QStyle>
#include <QStringList>
#include <QVBoxLayout>
namespace {
QColor activeRemoveIconColor()
{
return ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#FCA5A5"))
: QColor(QStringLiteral("#DC2626"));
}
QColor inactiveRemoveIconColor()
{
return ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#64748B"))
: QColor(QStringLiteral("#94A3B8"));
}
}
DanmakuServerListItemWidget::DanmakuServerListItemWidget(QWidget *parent)
: QFrame(parent)
{
constexpr int actionColumnWidth = 74;
setObjectName("DanmakuServerListRow");
setCursor(Qt::PointingHandCursor);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
auto *outerLayout = new QHBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 8, 0);
outerLayout->setSpacing(0);
m_cardFrame = new QFrame(this);
m_cardFrame->setObjectName("DanmakuServerCard");
m_cardFrame->setProperty("selected", false);
m_cardFrame->setProperty("currentServer", false);
m_cardFrame->setProperty("serverEnabled", true);
outerLayout->addWidget(m_cardFrame);
auto *layout = new QHBoxLayout(m_cardFrame);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(12);
auto *textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(4);
layout->addLayout(textLayout, 1);
auto *actionContainer = new QWidget(m_cardFrame);
actionContainer->setFixedWidth(actionColumnWidth);
actionContainer->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
auto *actionLayout = new QHBoxLayout(actionContainer);
actionLayout->setContentsMargins(0, 0, 2, 0);
actionLayout->setSpacing(4);
actionLayout->addStretch(1);
layout->addWidget(actionContainer, 0, Qt::AlignVCenter);
m_removeButton = new QPushButton(actionContainer);
m_removeButton->setObjectName("DanmakuServerRemoveButton");
m_removeButton->setCursor(Qt::PointingHandCursor);
m_removeButton->setToolTip(tr("Remove Server"));
m_removeButton->setFixedSize(20, 20);
m_removeButton->setIconSize(QSize(10, 10));
m_removeButton->setFocusPolicy(Qt::NoFocus);
m_enabledSwitch = new ModernSwitch(actionContainer);
m_enabledSwitch->setFixedSize(m_enabledSwitch->sizeHint());
m_enabledSwitch->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
actionLayout->addWidget(m_removeButton, 0, Qt::AlignVCenter | Qt::AlignRight);
actionLayout->addWidget(m_enabledSwitch, 0, Qt::AlignVCenter | Qt::AlignRight);
m_nameLabel = new QLabel(m_cardFrame);
m_nameLabel->setObjectName("DanmakuServerCardTitle");
m_nameLabel->setWordWrap(true);
m_nameLabel->installEventFilter(this);
textLayout->addWidget(m_nameLabel);
m_baseUrlLabel = new QLabel(m_cardFrame);
m_baseUrlLabel->setObjectName("DanmakuServerCardLine");
m_baseUrlLabel->setWordWrap(true);
m_baseUrlLabel->installEventFilter(this);
textLayout->addWidget(m_baseUrlLabel);
m_credentialsLabel = new QLabel(m_cardFrame);
m_credentialsLabel->setObjectName("DanmakuServerCardMeta");
m_credentialsLabel->setWordWrap(true);
m_credentialsLabel->installEventFilter(this);
textLayout->addWidget(m_credentialsLabel);
m_cardFrame->installEventFilter(this);
refreshRemoveIcon();
connect(m_enabledSwitch, &ModernSwitch::toggled, this,
&DanmakuServerListItemWidget::enabledChanged);
connect(m_removeButton, &QPushButton::clicked, this,
&DanmakuServerListItemWidget::removeRequested);
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this,
[this](ThemeManager::Theme) { refreshRemoveIcon(); });
}
void DanmakuServerListItemWidget::setServer(
const DanmakuServerDefinition &server)
{
m_server = server;
const QString displayName = server.name.trimmed().isEmpty()
? server.baseUrl.trimmed()
: server.name.trimmed();
{
const QSignalBlocker blocker(m_enabledSwitch);
m_enabledSwitch->setChecked(server.enabled);
}
m_cardFrame->setProperty("serverEnabled", server.enabled);
m_nameLabel->setText(displayName);
m_baseUrlLabel->setText(server.baseUrl.trimmed());
refreshCredentialsLabel();
refreshSelectionState();
}
void DanmakuServerListItemWidget::setSelected(bool selected)
{
if (m_cardFrame->property("selected").toBool() == selected) {
return;
}
m_cardFrame->setProperty("selected", selected);
refreshSelectionState();
}
void DanmakuServerListItemWidget::setCurrentServer(bool currentServer)
{
if (m_cardFrame->property("currentServer").toBool() == currentServer) {
return;
}
m_cardFrame->setProperty("currentServer", currentServer);
refreshSelectionState();
}
void DanmakuServerListItemWidget::setRemovable(bool removable)
{
m_removable = removable;
m_removeButton->setVisible(removable);
m_removeButton->setEnabled(removable);
m_removeButton->setProperty("removable", removable);
m_removeButton->setToolTip(removable ? tr("Remove Server")
: QString());
m_removeButton->style()->unpolish(m_removeButton);
m_removeButton->style()->polish(m_removeButton);
refreshRemoveIcon();
updateGeometry();
}
bool DanmakuServerListItemWidget::eventFilter(QObject *watched, QEvent *event)
{
if ((watched == m_cardFrame || watched == m_nameLabel ||
watched == m_baseUrlLabel || watched == m_credentialsLabel) &&
event && event->type() == QEvent::MouseButtonPress) {
emit clicked();
}
return QFrame::eventFilter(watched, event);
}
QString DanmakuServerListItemWidget::maskedSecret(const QString &secret)
{
const QString trimmed = secret.trimmed();
if (trimmed.isEmpty()) {
return QString();
}
if (trimmed.size() <= 4) {
return QStringLiteral("****");
}
return QStringLiteral("%1****%2")
.arg(trimmed.left(2), trimmed.right(2));
}
void DanmakuServerListItemWidget::refreshCredentialsLabel()
{
QStringList parts;
if (m_server.builtIn &&
m_server.contentScope.trimmed().compare(QStringLiteral("anime"),
Qt::CaseInsensitive) == 0) {
parts.append(tr("Anime only"));
}
if (!m_server.builtIn && !m_server.description.trimmed().isEmpty()) {
parts.append(m_server.description.trimmed());
}
if (!m_server.builtIn && !m_server.appId.trimmed().isEmpty()) {
parts.append(tr("App ID: %1").arg(m_server.appId.trimmed()));
}
if (!m_server.builtIn && !m_server.appSecret.trimmed().isEmpty()) {
parts.append(tr("App Secret: %1")
.arg(maskedSecret(m_server.appSecret)));
}
const QString text = parts.join(QStringLiteral(" "));
m_credentialsLabel->setVisible(!text.isEmpty());
m_credentialsLabel->setText(text);
updateGeometry();
}
void DanmakuServerListItemWidget::refreshRemoveIcon()
{
m_removeButton->setIcon(ThemeManager::getAdaptiveIcon(
QStringLiteral(":/svg/light/delete.svg"),
m_removable ? activeRemoveIconColor() : inactiveRemoveIconColor()));
}
void DanmakuServerListItemWidget::refreshSelectionState()
{
m_cardFrame->style()->unpolish(m_cardFrame);
m_cardFrame->style()->polish(m_cardFrame);
m_cardFrame->update();
}

View File

@@ -0,0 +1,49 @@
#ifndef DANMAKUSERVERLISTITEMWIDGET_H
#define DANMAKUSERVERLISTITEMWIDGET_H
#include <QFrame>
#include "models/danmaku/danmakumodels.h"
class QEvent;
class QFrame;
class QLabel;
class ModernSwitch;
class QPushButton;
class DanmakuServerListItemWidget : public QFrame
{
Q_OBJECT
public:
explicit DanmakuServerListItemWidget(QWidget *parent = nullptr);
void setServer(const DanmakuServerDefinition &server);
void setSelected(bool selected);
void setCurrentServer(bool currentServer);
void setRemovable(bool removable);
signals:
void clicked();
void enabledChanged(bool enabled);
void removeRequested();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
static QString maskedSecret(const QString &secret);
void refreshCredentialsLabel();
void refreshRemoveIcon();
void refreshSelectionState();
DanmakuServerDefinition m_server;
bool m_removable = true;
QFrame *m_cardFrame = nullptr;
QLabel *m_nameLabel = nullptr;
QLabel *m_baseUrlLabel = nullptr;
QLabel *m_credentialsLabel = nullptr;
ModernSwitch *m_enabledSwitch = nullptr;
QPushButton *m_removeButton = nullptr;
};
#endif

View File

@@ -0,0 +1,560 @@
#include "dashboardsectionorderstrip.h"
#include <QApplication>
#include <QEvent>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPropertyAnimation>
#include <QResizeEvent>
#include <QStyle>
namespace {
constexpr int StripHeight = 36;
constexpr int ChipHeight = 28;
constexpr int ChipSpacing = 8;
constexpr int ChipHorizontalPadding = 12;
constexpr int DragPreviewYOffset = 5;
constexpr int ChipAnimationDuration = 220;
constexpr qreal DragPreviewOpacity = 0.82;
}
DashboardSectionOrderStrip::DashboardSectionOrderStrip(QWidget* parent)
: QFrame(parent)
{
setObjectName("DashboardSectionOrderStrip");
setFrameShape(QFrame::NoFrame);
setMouseTracking(true);
setMinimumHeight(StripHeight);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_dropIndicator = new QFrame(this);
m_dropIndicator->setObjectName("DashboardSectionOrderDropIndicator");
m_dropIndicator->setAttribute(Qt::WA_TransparentForMouseEvents);
m_dropIndicator->setFixedHeight(ChipHeight);
m_dropIndicator->hide();
m_dragPreview = new QFrame(this);
m_dragPreview->setObjectName("DashboardSectionOrderDragPreview");
m_dragPreview->setAttribute(Qt::WA_TransparentForMouseEvents);
m_dragPreview->hide();
auto* previewLayout = new QHBoxLayout(m_dragPreview);
previewLayout->setContentsMargins(ChipHorizontalPadding, 0,
ChipHorizontalPadding, 0);
previewLayout->setSpacing(0);
m_dragPreviewLabel = new QLabel(m_dragPreview);
m_dragPreviewLabel->setObjectName("DashboardSectionOrderDragPreviewLabel");
m_dragPreviewLabel->setAlignment(Qt::AlignCenter);
previewLayout->addWidget(m_dragPreviewLabel);
m_dragPreviewOpacity = new QGraphicsOpacityEffect(m_dragPreview);
m_dragPreviewOpacity->setOpacity(DragPreviewOpacity);
m_dragPreview->setGraphicsEffect(m_dragPreviewOpacity);
}
void DashboardSectionOrderStrip::setSectionOrder(
const QStringList& order, const QHash<QString, QString>& titles)
{
if (m_dragging || m_pressedChip) {
resetDragState(false);
}
m_titles = titles;
m_order = order;
rebuildChips();
}
QStringList DashboardSectionOrderStrip::sectionOrder() const
{
return m_order;
}
QSize DashboardSectionOrderStrip::sizeHint() const
{
return QSize(fullContentWidth(), StripHeight);
}
QSize DashboardSectionOrderStrip::minimumSizeHint() const
{
return QSize(0, StripHeight);
}
bool DashboardSectionOrderStrip::eventFilter(QObject* watched, QEvent* event)
{
auto* chip = qobject_cast<QFrame*>(watched);
if (!chip) {
return QFrame::eventFilter(watched, event);
}
switch (event->type()) {
case QEvent::MouseButtonPress: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (mouseEvent->button() != Qt::LeftButton) {
break;
}
m_pressedChip = chip;
m_pressGlobalPos = mouseEvent->globalPosition().toPoint();
m_pressChipOffset = mouseEvent->position().toPoint();
chip->setCursor(Qt::ClosedHandCursor);
return true;
}
case QEvent::MouseMove: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (!m_pressedChip || !(mouseEvent->buttons() & Qt::LeftButton)) {
break;
}
const QPoint globalPos = mouseEvent->globalPosition().toPoint();
if (!m_dragging &&
(globalPos - m_pressGlobalPos).manhattanLength() >=
QApplication::startDragDistance()) {
beginDrag(m_pressedChip);
}
if (m_dragging && chip == m_draggedChip) {
updateDrag(globalPos);
}
return true;
}
case QEvent::MouseButtonRelease: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (mouseEvent->button() != Qt::LeftButton) {
break;
}
if (m_dragging && chip == m_draggedChip) {
finishDrag();
} else {
chip->setCursor(Qt::OpenHandCursor);
resetDragState();
}
return true;
}
case QEvent::Leave:
if (!m_dragging) {
chip->setCursor(Qt::OpenHandCursor);
}
break;
default:
break;
}
return QFrame::eventFilter(watched, event);
}
QFrame* DashboardSectionOrderStrip::createChip(const QString& title)
{
auto* chip = new QFrame(this);
chip->setObjectName("DashboardSectionOrderChip");
chip->setCursor(Qt::OpenHandCursor);
chip->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
chip->installEventFilter(this);
auto* layout = new QHBoxLayout(chip);
layout->setContentsMargins(ChipHorizontalPadding, 0, ChipHorizontalPadding,
0);
layout->setSpacing(0);
auto* label = new QLabel(title, chip);
label->setObjectName("DashboardSectionOrderChipLabel");
label->setAlignment(Qt::AlignCenter);
label->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(label);
chip->setFixedSize(
qMax(68, label->sizeHint().width() + ChipHorizontalPadding * 2),
ChipHeight);
return chip;
}
void DashboardSectionOrderStrip::rebuildChips()
{
for (ChipItem& item : m_chipItems) {
if (item.chip) {
item.chip->deleteLater();
}
}
m_chipItems.clear();
for (const QString& sectionId : m_order) {
const QString title = m_titles.value(sectionId, sectionId);
QFrame* chip = createChip(title);
m_chipItems.append({sectionId, chip});
}
relayoutChips(false);
updateGeometry();
}
int DashboardSectionOrderStrip::indexOfChip(const QFrame* chip) const
{
for (int index = 0; index < m_chipItems.size(); ++index) {
if (m_chipItems[index].chip == chip) {
return index;
}
}
return -1;
}
int DashboardSectionOrderStrip::insertionIndexForPosition(int localX) const
{
int insertionIndex = 0;
for (const ChipItem& item : m_chipItems) {
if (!item.chip || item.chip == m_draggedChip) {
continue;
}
if (localX < item.chip->geometry().center().x()) {
return insertionIndex;
}
++insertionIndex;
}
return insertionIndex;
}
void DashboardSectionOrderStrip::beginDrag(QFrame* chip)
{
m_draggedChip = chip;
m_dragSourceIndex = indexOfChip(chip);
if (m_dragSourceIndex < 0) {
resetDragState();
return;
}
m_dragging = true;
setDraggingChip(chip, true);
if (m_dragPreview && m_dragPreviewLabel) {
if (auto* label =
chip->findChild<QLabel*>("DashboardSectionOrderChipLabel")) {
m_dragPreviewLabel->setText(label->text());
} else {
m_dragPreviewLabel->clear();
}
m_dragPreview->setFixedSize(chip->size());
m_dragPreview->show();
m_dragPreview->raise();
updateDragPreview(m_pressGlobalPos);
}
grabMouse();
chip->hide();
setIndicatorIndex(m_dragSourceIndex, true);
}
void DashboardSectionOrderStrip::updateDrag(const QPoint& globalPos)
{
const QPoint localPos = mapFromGlobal(globalPos);
updateDragPreview(globalPos);
setIndicatorIndex(insertionIndexForPosition(localPos.x()));
}
void DashboardSectionOrderStrip::updateDragPreview(const QPoint& globalPos)
{
if (!m_dragPreview || !m_dragPreview->isVisible()) {
return;
}
const QPoint localPos = mapFromGlobal(globalPos);
const int previewWidth = m_dragPreview->width();
const int previewHeight = m_dragPreview->height();
int x = localPos.x() - m_pressChipOffset.x();
if (m_dropIndicator->isVisible()) {
const int indicatorCenterX = m_dropIndicator->geometry().center().x();
const int previewCenterX = x + previewWidth / 2;
const int snappedCenterX =
qRound(previewCenterX * 0.7 + indicatorCenterX * 0.3);
x = snappedCenterX - previewWidth / 2;
}
x = qBound(0, x, qMax(0, width() - previewWidth));
const int y = qBound(0, chipY() - DragPreviewYOffset,
qMax(0, height() - previewHeight));
m_dragPreview->move(x, y);
m_dragPreview->raise();
}
void DashboardSectionOrderStrip::finishDrag()
{
if (!m_dragging || m_dragSourceIndex < 0 || !m_draggedChip) {
resetDragState();
return;
}
QStringList newOrder = m_order;
const QString draggedSectionId = newOrder.takeAt(m_dragSourceIndex);
const int targetIndex =
qBound(0, m_indicatorIndex, newOrder.size());
newOrder.insert(targetIndex, draggedSectionId);
const bool changed = newOrder != m_order;
ChipItem draggedItem = m_chipItems.takeAt(m_dragSourceIndex);
m_chipItems.insert(targetIndex, draggedItem);
m_order = newOrder;
resetDragState(true);
if (changed) {
emit sectionOrderChanged(m_order);
}
}
void DashboardSectionOrderStrip::resetDragState(bool animateDraggedChip)
{
if (QWidget::mouseGrabber() == this) {
releaseMouse();
}
QPointer<QFrame> draggedChip = m_draggedChip;
const QPoint dragPreviewPos =
m_dragPreview ? m_dragPreview->pos() : QPoint();
if (m_draggedChip) {
setDraggingChip(m_draggedChip, false);
}
if (draggedChip) {
if (animateDraggedChip && m_dragPreview && m_dragPreview->isVisible()) {
draggedChip->move(dragPreviewPos);
draggedChip->raise();
}
draggedChip->show();
draggedChip->setCursor(Qt::OpenHandCursor);
}
if (m_pressedChip) {
m_pressedChip->setCursor(Qt::OpenHandCursor);
}
if (m_dragPreview) {
m_dragPreview->hide();
}
m_pressedChip.clear();
m_draggedChip.clear();
m_dragSourceIndex = -1;
m_indicatorIndex = -1;
m_dragging = false;
relayoutChips(animateDraggedChip);
}
void DashboardSectionOrderStrip::mouseMoveEvent(QMouseEvent* event)
{
if (m_dragging) {
updateDrag(event->globalPosition().toPoint());
event->accept();
return;
}
QFrame::mouseMoveEvent(event);
}
void DashboardSectionOrderStrip::mouseReleaseEvent(QMouseEvent* event)
{
if (m_dragging && event->button() == Qt::LeftButton) {
finishDrag();
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}
void DashboardSectionOrderStrip::resizeEvent(QResizeEvent* event)
{
QFrame::resizeEvent(event);
relayoutChips(false);
}
void DashboardSectionOrderStrip::setDraggingChip(QFrame* chip, bool dragging)
{
if (!chip) {
return;
}
chip->setProperty("dragging", dragging);
chip->style()->unpolish(chip);
chip->style()->polish(chip);
chip->update();
}
void DashboardSectionOrderStrip::setIndicatorIndex(int index, bool animate)
{
const int maxIndex = m_dragging ? qMax(0, m_chipItems.size() - 1)
: m_chipItems.size();
const int adjustedIndex = qBound(0, index, maxIndex);
if (m_indicatorIndex == adjustedIndex && m_dropIndicator->isVisible()) {
return;
}
m_indicatorIndex = adjustedIndex;
relayoutChips(animate);
}
void DashboardSectionOrderStrip::relayoutChips(bool animate)
{
if (m_chipItems.isEmpty()) {
if (m_dropIndicator) {
stopPosAnimations(m_dropIndicator);
m_dropIndicator->hide();
}
return;
}
const bool showDropSlot =
m_dragging && m_draggedChip && m_indicatorIndex >= 0;
const int slotWidth =
(showDropSlot && m_draggedChip) ? m_draggedChip->width() : 0;
const int layoutWidth = showDropSlot ? fullContentWidth()
: contentWidthForCurrentState();
const int startX = qMax(0, width() - layoutWidth);
int x = startX;
int visibleIndex = 0;
bool indicatorPlaced = false;
for (int index = 0; index < m_chipItems.size(); ++index) {
auto* chip = m_chipItems[index].chip;
if (!chip || (m_dragging && chip == m_draggedChip)) {
continue;
}
if (showDropSlot && m_indicatorIndex == visibleIndex) {
m_dropIndicator->setFixedSize(slotWidth, ChipHeight);
m_dropIndicator->show();
moveWidgetTo(m_dropIndicator, QPoint(x, indicatorY()), animate,
ChipAnimationDuration);
m_dropIndicator->raise();
indicatorPlaced = true;
x += slotWidth + ChipSpacing;
}
chip->show();
moveWidgetTo(chip, QPoint(x, chipY()), animate, ChipAnimationDuration);
x += chip->width();
const bool hasTrailingVisibleChip = [&]() {
for (int next = index + 1; next < m_chipItems.size(); ++next) {
if (!m_chipItems[next].chip ||
(m_dragging && m_chipItems[next].chip == m_draggedChip)) {
continue;
}
return true;
}
return false;
}();
if (hasTrailingVisibleChip || (showDropSlot && m_indicatorIndex > visibleIndex)) {
x += ChipSpacing;
}
++visibleIndex;
}
if (showDropSlot && !indicatorPlaced) {
m_dropIndicator->setFixedSize(slotWidth, ChipHeight);
m_dropIndicator->show();
moveWidgetTo(m_dropIndicator, QPoint(x, indicatorY()), animate,
ChipAnimationDuration);
m_dropIndicator->raise();
indicatorPlaced = true;
}
if (!indicatorPlaced) {
stopPosAnimations(m_dropIndicator);
m_dropIndicator->hide();
}
if (m_dragPreview && m_dragPreview->isVisible()) {
m_dragPreview->raise();
}
}
void DashboardSectionOrderStrip::moveWidgetTo(QWidget* widget,
const QPoint& targetPos,
bool animate, int duration)
{
if (!widget) {
return;
}
stopPosAnimations(widget);
if (!animate || widget->pos() == targetPos) {
widget->move(targetPos);
return;
}
auto* animation = new QPropertyAnimation(widget, "pos", widget);
animation->setDuration(duration);
animation->setEasingCurve(QEasingCurve::OutCubic);
animation->setEndValue(targetPos);
animation->start(QAbstractAnimation::DeleteWhenStopped);
}
void DashboardSectionOrderStrip::stopPosAnimations(QWidget* widget) const
{
if (!widget) {
return;
}
const auto children = widget->children();
for (auto* child : children) {
auto* animation = qobject_cast<QPropertyAnimation*>(child);
if (!animation || animation->propertyName() != "pos") {
continue;
}
animation->stop();
animation->deleteLater();
}
}
int DashboardSectionOrderStrip::fullContentWidth() const
{
if (m_chipItems.isEmpty()) {
return 0;
}
int totalWidth = 0;
for (const ChipItem& item : m_chipItems) {
if (item.chip) {
totalWidth += item.chip->width();
}
}
totalWidth += ChipSpacing * qMax(0, m_chipItems.size() - 1);
return totalWidth;
}
int DashboardSectionOrderStrip::contentWidthForCurrentState() const
{
int totalWidth = 0;
int visibleCount = 0;
for (const ChipItem& item : m_chipItems) {
if (!item.chip || (m_dragging && item.chip == m_draggedChip)) {
continue;
}
totalWidth += item.chip->width();
++visibleCount;
}
totalWidth += ChipSpacing * qMax(0, visibleCount - 1);
return totalWidth;
}
int DashboardSectionOrderStrip::chipY() const
{
return qMax(0, (height() - ChipHeight) / 2);
}
int DashboardSectionOrderStrip::indicatorY() const
{
return chipY();
}

View File

@@ -0,0 +1,78 @@
#ifndef DASHBOARDSECTIONORDERSTRIP_H
#define DASHBOARDSECTIONORDERSTRIP_H
#include <QFrame>
#include <QHash>
#include <QSize>
#include <QPointer>
#include <QStringList>
class QGraphicsOpacityEffect;
class QLabel;
class QMouseEvent;
class QResizeEvent;
class DashboardSectionOrderStrip : public QFrame {
Q_OBJECT
public:
explicit DashboardSectionOrderStrip(QWidget* parent = nullptr);
void setSectionOrder(const QStringList& order,
const QHash<QString, QString>& titles);
QStringList sectionOrder() const;
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
signals:
void sectionOrderChanged(const QStringList& order);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
struct ChipItem {
QString sectionId;
QFrame* chip = nullptr;
};
QFrame* createChip(const QString& title);
void rebuildChips();
int indexOfChip(const QFrame* chip) const;
int insertionIndexForPosition(int localX) const;
void beginDrag(QFrame* chip);
void updateDrag(const QPoint& globalPos);
void updateDragPreview(const QPoint& globalPos);
void finishDrag();
void resetDragState(bool animateDraggedChip = false);
void setDraggingChip(QFrame* chip, bool dragging);
void setIndicatorIndex(int index, bool animate = true);
void relayoutChips(bool animate);
void moveWidgetTo(QWidget* widget, const QPoint& targetPos, bool animate,
int duration = 180);
void stopPosAnimations(QWidget* widget) const;
int fullContentWidth() const;
int contentWidthForCurrentState() const;
int chipY() const;
int indicatorY() const;
QFrame* m_dropIndicator = nullptr;
QFrame* m_dragPreview = nullptr;
QLabel* m_dragPreviewLabel = nullptr;
QGraphicsOpacityEffect* m_dragPreviewOpacity = nullptr;
QList<ChipItem> m_chipItems;
QStringList m_order;
QHash<QString, QString> m_titles;
QPointer<QFrame> m_pressedChip;
QPointer<QFrame> m_draggedChip;
QPoint m_pressGlobalPos;
QPoint m_pressChipOffset;
int m_dragSourceIndex = -1;
int m_indicatorIndex = -1;
bool m_dragging = false;
};
#endif

View File

@@ -0,0 +1,97 @@
#include "dashboardsectionorderwidget.h"
#include "dashboardsectionorderstrip.h"
#include "../utils/dashboardsectionorderutils.h"
#include <QHash>
#include <QLabel>
#include <QVBoxLayout>
DashboardSectionOrderWidget::DashboardSectionOrderWidget(QWidget* parent)
: QWidget(parent)
{
setObjectName("DashboardSectionOrderWidget");
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(8);
m_hintLabel = new QLabel(
tr("Drag the items below to change the order of home screen sections. "
"Left to right matches top to bottom on the home screen."),
this);
m_hintLabel->setObjectName("SettingsCardDesc");
m_hintLabel->setWordWrap(true);
layout->addWidget(m_hintLabel);
m_orderStrip = new DashboardSectionOrderStrip(this);
layout->addWidget(m_orderStrip);
connect(m_orderStrip, &DashboardSectionOrderStrip::sectionOrderChanged, this,
&DashboardSectionOrderWidget::sectionOrderChanged);
setSectionOrder(DashboardSectionOrderUtils::defaultSectionOrder());
}
void DashboardSectionOrderWidget::setSectionOrder(const QStringList& order)
{
updateSectionOrder(DashboardSectionOrderUtils::normalizeSectionOrder(order));
}
QStringList DashboardSectionOrderWidget::sectionOrder() const
{
if (!m_orderStrip) {
return {};
}
return DashboardSectionOrderUtils::normalizeSectionOrder(
m_orderStrip->sectionOrder());
}
QString DashboardSectionOrderWidget::titleForSectionId(
const QString& sectionId) const
{
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::ContinueWatchingSectionId)) {
return tr("Continue Watching");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) {
return tr("Latest Media");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) {
return tr("Recommended");
}
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::CompletedWatchingSectionId)) {
return tr("Completed Watching");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::AllLibrariesSectionId)) {
return tr("All Libraries");
}
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::
EachLibrarySectionsSectionId)) {
return tr("Each Library Sections");
}
return sectionId;
}
void DashboardSectionOrderWidget::updateSectionOrder(const QStringList& order)
{
if (!m_orderStrip) {
return;
}
QHash<QString, QString> titles;
const QStringList normalizedOrder =
DashboardSectionOrderUtils::normalizeSectionOrder(order);
for (const QString& sectionId : normalizedOrder) {
titles.insert(sectionId, titleForSectionId(sectionId));
}
m_orderStrip->setSectionOrder(normalizedOrder, titles);
}

View File

@@ -0,0 +1,30 @@
#ifndef DASHBOARDSECTIONORDERWIDGET_H
#define DASHBOARDSECTIONORDERWIDGET_H
#include <QStringList>
#include <QWidget>
class DashboardSectionOrderStrip;
class QLabel;
class DashboardSectionOrderWidget : public QWidget {
Q_OBJECT
public:
explicit DashboardSectionOrderWidget(QWidget* parent = nullptr);
void setSectionOrder(const QStringList& order);
QStringList sectionOrder() const;
signals:
void sectionOrderChanged(const QStringList& order);
private:
QString titleForSectionId(const QString& sectionId) const;
void updateSectionOrder(const QStringList& order);
QLabel* m_hintLabel = nullptr;
DashboardSectionOrderStrip* m_orderStrip = nullptr;
};
#endif

View File

@@ -0,0 +1,450 @@
#include "detailactionwidget.h"
#include "../managers/externalplayerdetector.h"
#include "../managers/thememanager.h"
#include "../utils/mediasourcepreferenceutils.h"
#include "../utils/playerpreferenceutils.h"
#include "modernmenubutton.h"
#include "splitplayerbutton.h"
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QSet>
#include <QStyle>
#include <QVBoxLayout>
#include <config/config_keys.h>
#include <config/configstore.h>
DetailActionWidget::DetailActionWidget(QWidget *parent) : QWidget(parent) {
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(4);
auto *actionsLayout = new QHBoxLayout();
actionsLayout->setSpacing(12);
actionsLayout->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_resumeBtn = new QPushButton(tr("▶ Resume"), this);
m_resumeBtn->setObjectName("detail-resume-btn");
m_resumeBtn->setCursor(Qt::PointingHandCursor);
m_resumeBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
m_playBtn = new QPushButton(tr("▶ Play"), this);
m_playBtn->setObjectName("detail-play-btn");
m_playBtn->setCursor(Qt::PointingHandCursor);
m_playBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
m_favBtn = new QPushButton(this);
m_favBtn->setObjectName("detail-fav-btn");
m_favBtn->setIcon(QIcon(":/svg/light/heart.svg"));
m_favBtn->setIconSize(QSize(20, 20));
m_favBtn->setFixedSize(36, 36);
m_favBtn->setCursor(Qt::PointingHandCursor);
m_playedBtn = new QPushButton(this);
m_playedBtn->setObjectName("detail-played-btn");
m_playedBtn->setIconSize(QSize(20, 20));
m_playedBtn->setFixedSize(36, 36);
m_playedBtn->setCursor(Qt::PointingHandCursor);
m_progressWidget = new QWidget(this);
m_progressWidget->setMaximumWidth(450);
auto *progressLayout = new QHBoxLayout(m_progressWidget);
progressLayout->setContentsMargins(0, 0, 0, 0);
progressLayout->setSpacing(10);
m_progressBar = new QProgressBar(m_progressWidget);
m_progressBar->setObjectName("detail-progress-bar");
m_progressBar->setTextVisible(false);
m_remainingTimeLabel = new QLabel(m_progressWidget);
m_remainingTimeLabel->setObjectName("detail-remaining-time");
progressLayout->addWidget(m_progressBar, 1);
progressLayout->addWidget(m_remainingTimeLabel);
m_extPlayerBtn = new SplitPlayerButton(this);
m_extPlayerBtn->setObjectName("detail-ext-player-btn");
m_extPlayerBtn->setIconOnly(true);
m_extPlayerBtn->hide();
connect(m_extPlayerBtn, &SplitPlayerButton::playRequested, this,
&DetailActionWidget::externalPlayRequested);
connect(m_extPlayerBtn, &SplitPlayerButton::playerSelected, this,
&DetailActionWidget::externalPlayRequested);
actionsLayout->addWidget(m_resumeBtn);
actionsLayout->addWidget(m_playBtn);
actionsLayout->addWidget(m_extPlayerBtn);
actionsLayout->addWidget(m_favBtn);
actionsLayout->addWidget(m_playedBtn);
actionsLayout->addWidget(m_progressWidget);
actionsLayout->addStretch();
auto *streamSelectorsLayout = new QHBoxLayout();
streamSelectorsLayout->setSpacing(6);
streamSelectorsLayout->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_versionComboBox = new ModernMenuButton(this);
m_audioComboBox = new ModernMenuButton(this);
m_subtitleComboBox = new ModernMenuButton(this);
streamSelectorsLayout->addWidget(m_versionComboBox);
streamSelectorsLayout->addWidget(m_audioComboBox);
streamSelectorsLayout->addWidget(m_subtitleComboBox);
streamSelectorsLayout->addStretch();
mainLayout->addLayout(actionsLayout);
mainLayout->addLayout(streamSelectorsLayout);
connect(m_playBtn, &QPushButton::clicked, this,
&DetailActionWidget::playRequested);
connect(m_resumeBtn, &QPushButton::clicked, this,
&DetailActionWidget::resumeRequested);
connect(m_favBtn, &QPushButton::clicked, this,
&DetailActionWidget::favoriteRequested);
connect(m_playedBtn, &QPushButton::clicked, this,
&DetailActionWidget::playedToggleRequested);
connect(m_versionComboBox, &ModernMenuButton::currentIndexChanged, this,
&DetailActionWidget::sourceVersionChanged);
clear();
}
void DetailActionWidget::clear() {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_progressWidget->hide();
m_versionComboBox->blockSignals(true);
m_versionComboBox->clear();
m_versionComboBox->hide();
m_versionComboBox->blockSignals(false);
m_audioComboBox->blockSignals(true);
m_audioComboBox->clear();
m_audioComboBox->hide();
m_audioComboBox->blockSignals(false);
m_subtitleComboBox->blockSignals(true);
m_subtitleComboBox->clear();
m_subtitleComboBox->hide();
m_subtitleComboBox->blockSignals(false);
}
void DetailActionWidget::setupNormalMode(const MediaItem &item) {
m_playBtn->setEnabled(true);
if (item.userData.playedPercentage > 0 &&
item.userData.playedPercentage < 100) {
m_resumeBtn->show();
m_playBtn->setText(tr("↺ Play from Beginning"));
m_progressBar->setValue(static_cast<int>(item.userData.playedPercentage));
long long remainingTicks =
item.runTimeTicks - item.userData.playbackPositionTicks;
if (remainingTicks > 0) {
m_remainingTimeLabel->setText(
tr("%1 remaining").arg(formatRunTime(remainingTicks)));
} else {
m_remainingTimeLabel->clear();
}
m_progressWidget->show();
} else {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_progressWidget->hide();
}
}
void DetailActionWidget::setSeriesLoadingMode() {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_playBtn->setEnabled(false);
m_progressWidget->hide();
}
void DetailActionWidget::setupSeriesMode(const MediaItem &nextUpItem,
const QString &epTag) {
m_playBtn->setEnabled(true);
if (nextUpItem.userData.playbackPositionTicks > 0) {
m_resumeBtn->show();
m_resumeBtn->setText(tr("▶ Resume %1").arg(epTag));
m_playBtn->setText(tr("↺ Restart %1").arg(epTag));
if (nextUpItem.userData.playedPercentage > 0) {
m_progressBar->setValue(
static_cast<int>(nextUpItem.userData.playedPercentage));
long long remainingTicks =
nextUpItem.runTimeTicks - nextUpItem.userData.playbackPositionTicks;
if (remainingTicks > 0) {
m_remainingTimeLabel->setText(
tr("%1 remaining").arg(formatRunTime(remainingTicks)));
} else {
m_remainingTimeLabel->clear();
}
m_progressWidget->show();
}
} else {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play %1").arg(epTag));
m_progressWidget->hide();
}
}
void DetailActionWidget::setFavoriteState(bool isFavorite) {
m_favBtn->setProperty("isFavorite", isFavorite);
m_favBtn->setIcon(QIcon(isFavorite ? ":/svg/light/heart-fill.svg"
: ":/svg/light/heart-outline.svg"));
m_favBtn->style()->unpolish(m_favBtn);
m_favBtn->style()->polish(m_favBtn);
}
void DetailActionWidget::setPlayedState(bool played) {
m_playedBtn->setProperty("played", played);
const QString themeDir =
ThemeManager::instance()->isDarkMode() ? "dark" : "light";
if (played) {
m_playedBtn->setIcon(QIcon(":/svg/dark/played-check.svg"));
} else {
m_playedBtn->setIcon(
QIcon(QString(":/svg/%1/unplayed-check.svg").arg(themeDir)));
}
m_playedBtn->style()->unpolish(m_playedBtn);
m_playedBtn->style()->polish(m_playedBtn);
}
void DetailActionWidget::setSources(const QList<MediaSourceInfo> &sources,
int currentIndex) {
m_versionComboBox->blockSignals(true);
m_versionComboBox->clear();
if (!sources.isEmpty()) {
for (int i = 0; i < sources.size(); ++i) {
const auto &src = sources[i];
QString versionName =
src.name.isEmpty() ? tr("Version %1").arg(i + 1) : src.name;
QString videoInfo = tr("Unknown Video");
for (const auto &stream : src.mediaStreams) {
if (stream.type == "Video") {
QStringList parts;
if (stream.width > 0)
parts << QString("%1x%2").arg(stream.width).arg(stream.height);
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.realFrameRate > 0)
parts << QString::number(stream.realFrameRate, 'f', 1) + " fps";
if (stream.bitRate > 0)
parts << QString("%1 Mbps").arg(stream.bitRate / 1000000.0, 0, 'f',
1);
if (!parts.isEmpty())
videoInfo = parts.join(" · ");
break;
}
}
m_versionComboBox->addItem("🎞 " + versionName, videoInfo, QString(),
src.id);
}
int bestIndex = currentIndex;
if (sources.size() > 1) {
bestIndex = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex(
sources,
ConfigStore::instance()
->get<QString>(ConfigKeys::PlayerPreferredVersion)
.trimmed());
}
m_versionComboBox->setCurrentIndex(bestIndex);
m_versionComboBox->show();
} else {
m_versionComboBox->hide();
}
m_versionComboBox->blockSignals(false);
}
void DetailActionWidget::setStreams(const MediaSourceInfo &source) {
m_audioComboBox->blockSignals(true);
m_subtitleComboBox->blockSignals(true);
m_audioComboBox->clear();
m_subtitleComboBox->clear();
m_subtitleComboBox->addItem("🚫 " + tr("No Subtitles"), QString(),
tr("Disable subtitle display"), -1);
QString prefAudioLang = ConfigStore::instance()->get<QString>(
ConfigKeys::PlayerAudioLang, "auto");
QString prefSubLang =
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSubLang, "auto");
const int preferredAudioStreamIndex =
PlayerPreferenceUtils::findPreferredStreamIndex(
source.mediaStreams, "Audio", prefAudioLang);
const int preferredSubtitleStreamIndex =
PlayerPreferenceUtils::findPreferredStreamIndex(
source.mediaStreams, "Subtitle", prefSubLang);
const bool subtitleDisabled =
PlayerPreferenceUtils::isSubtitleDisabled(prefSubLang);
int defaultAudioIdx = 0;
int defaultSubIdx = 0;
bool subMatchedByPref = false;
bool subMatchedByDefault = false;
for (const auto &stream : source.mediaStreams) {
if (stream.type == "Audio") {
QString title =
stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle;
if (title.isEmpty())
title = tr("Audio Track");
QStringList parts;
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.channels > 0)
parts << QString("%1 ch").arg(stream.channels);
if (stream.sampleRate > 0)
parts << QString("%1 kHz").arg(stream.sampleRate / 1000.0, 0, 'f', 1);
if (stream.bitRate > 0)
parts << QString("%1 kbps").arg(stream.bitRate / 1000);
QStringList secondLineParts;
if (!stream.title.isEmpty())
secondLineParts << stream.title;
if (!parts.isEmpty())
secondLineParts << parts.join(" · ");
m_audioComboBox->addItem("🔊 " + title, QString(),
secondLineParts.join(" · "), stream.index);
int curIdx = m_audioComboBox->count() - 1;
if (preferredAudioStreamIndex >= 0 &&
stream.index == preferredAudioStreamIndex) {
defaultAudioIdx = curIdx;
} else if (preferredAudioStreamIndex < 0 && stream.isDefault) {
defaultAudioIdx = curIdx;
}
} else if (stream.type == "Subtitle") {
QString title =
stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle;
if (title.isEmpty())
title = tr("Subtitle");
QStringList parts;
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.isForced)
parts << tr("Forced");
if (stream.isExternal)
parts << tr("External");
if (stream.isHearingImpaired)
parts << tr("SDH");
QStringList secondLineParts;
if (!stream.title.isEmpty())
secondLineParts << stream.title;
if (!parts.isEmpty())
secondLineParts << parts.join(" · ");
m_subtitleComboBox->addItem("💬 " + title, QString(),
secondLineParts.join(" · "), stream.index);
int curIdx = m_subtitleComboBox->count() - 1;
if (preferredSubtitleStreamIndex >= 0 &&
stream.index == preferredSubtitleStreamIndex) {
defaultSubIdx = curIdx;
subMatchedByPref = true;
} else if (preferredSubtitleStreamIndex < 0 && !subMatchedByPref &&
stream.isDefault) {
defaultSubIdx = curIdx;
subMatchedByDefault = true;
}
}
}
if (subtitleDisabled) {
defaultSubIdx = 0;
} else if (!subMatchedByPref && !subMatchedByDefault &&
m_subtitleComboBox->count() > 1) {
defaultSubIdx = 1;
}
if (m_audioComboBox->count() > 0) {
m_audioComboBox->setCurrentIndex(defaultAudioIdx);
m_audioComboBox->show();
} else {
m_audioComboBox->hide();
}
if (m_subtitleComboBox->count() > 1) {
m_subtitleComboBox->setCurrentIndex(defaultSubIdx);
m_subtitleComboBox->show();
} else {
m_subtitleComboBox->hide();
}
m_audioComboBox->blockSignals(false);
m_subtitleComboBox->blockSignals(false);
}
int DetailActionWidget::currentSourceIndex() const {
return qMax(0, m_versionComboBox->currentIndex());
}
int DetailActionWidget::currentAudioIndex() const {
return m_audioComboBox->isVisible() ? m_audioComboBox->currentData().toInt()
: -1;
}
int DetailActionWidget::currentSubtitleIndex() const {
return m_subtitleComboBox->isVisible()
? m_subtitleComboBox->currentData().toInt()
: -1;
}
QString DetailActionWidget::formatRunTime(long long ticks) {
long long totalSeconds = ticks / 10000000;
long long hours = totalSeconds / 3600;
long long minutes = (totalSeconds % 3600) / 60;
if (hours > 0)
return QString(tr("%1 hr %2 min")).arg(hours).arg(minutes);
return QString(tr("%1 min")).arg(minutes);
}
void DetailActionWidget::refreshExtPlayerButton() {
bool extEnabled =
ConfigStore::instance()->get<bool>(ConfigKeys::ExtPlayerEnable, false);
if (!extEnabled) {
m_extPlayerBtn->hide();
return;
}
QString currentPath =
ConfigStore::instance()->get<QString>(ConfigKeys::ExtPlayerPath);
QList<DetectedPlayer> allPlayers = ExternalPlayerDetector::loadFromConfig();
QSet<QString> knownPaths;
for (const auto &p : allPlayers)
knownPaths.insert(p.path);
if (!currentPath.isEmpty() && currentPath != "custom" &&
!knownPaths.contains(currentPath) && QFileInfo::exists(currentPath)) {
allPlayers.prepend({QFileInfo(currentPath).baseName(), currentPath});
}
if (allPlayers.isEmpty()) {
m_extPlayerBtn->hide();
return;
}
QString activePlayerPath = currentPath;
if (activePlayerPath.isEmpty() || activePlayerPath == "custom") {
activePlayerPath = allPlayers.first().path;
}
m_extPlayerBtn->setPlayers(allPlayers, activePlayerPath);
}

View File

@@ -0,0 +1,63 @@
#ifndef DETAILACTIONWIDGET_H
#define DETAILACTIONWIDGET_H
#include <QWidget>
#include "models/media/mediaitem.h"
class QPushButton;
class QProgressBar;
class QLabel;
class ModernMenuButton;
class SplitPlayerButton;
class DetailActionWidget : public QWidget {
Q_OBJECT
public:
explicit DetailActionWidget(QWidget* parent = nullptr);
void setupNormalMode(const MediaItem& item);
void setupSeriesMode(const MediaItem& nextUpItem, const QString& epTag);
void setSeriesLoadingMode();
void setFavoriteState(bool isFavorite);
void setPlayedState(bool played);
void setSources(const QList<MediaSourceInfo>& sources, int currentIndex = 0);
void setStreams(const MediaSourceInfo& source);
void clear();
int currentSourceIndex() const;
int currentAudioIndex() const;
int currentSubtitleIndex() const;
void refreshExtPlayerButton();
signals:
void playRequested();
void resumeRequested();
void favoriteRequested();
void playedToggleRequested();
void sourceVersionChanged(int index);
void externalPlayRequested(const QString &playerPath);
private:
QString formatRunTime(long long ticks);
QPushButton* m_resumeBtn;
QPushButton* m_playBtn;
QPushButton* m_favBtn;
QPushButton* m_playedBtn;
QWidget* m_progressWidget;
QProgressBar* m_progressBar;
QLabel* m_remainingTimeLabel;
ModernMenuButton* m_versionComboBox;
ModernMenuButton* m_audioComboBox;
ModernMenuButton* m_subtitleComboBox;
SplitPlayerButton* m_extPlayerBtn;
};
#endif

View File

@@ -0,0 +1,494 @@
#include "detailbottominfowidget.h"
#include "detailtagbutton.h"
#include "flowlayout.h"
#include "horizontalwidgetgallery.h"
#include <QDesktopServices>
#include <QEvent>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QMargins>
#include <QPushButton>
#include <QShowEvent>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
DetailBottomInfoWidget::DetailBottomInfoWidget(QWidget *parent)
: QWidget(parent) {
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(40, 8, 40, 0);
mainLayout->setSpacing(12);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
m_tagsBottomTitle = new QLabel(tr("Tags"), this);
m_tagsBottomTitle->setObjectName("detail-section-title");
m_tagsBottomWidget = new QWidget(this);
m_tagsBottomLayout = new FlowLayout(m_tagsBottomWidget, 0, 8, 8);
m_studiosTitle = new QLabel(tr("Studios"), this);
m_studiosTitle->setObjectName("detail-section-title");
m_studiosWidget = new QWidget(this);
m_studiosLayout = new FlowLayout(m_studiosWidget, 0, 8, 8);
m_externalLinksTitle = new QLabel(tr("External Links"), this);
m_externalLinksTitle->setObjectName("detail-section-title");
m_externalLinksWidget = new QWidget(this);
m_externalLinksLayout = new FlowLayout(m_externalLinksWidget, 0, 8, 8);
m_mediaInfoTitle = new QLabel(tr("Media Info"), this);
m_mediaInfoTitle->setObjectName("detail-section-title");
m_fileInfoWidget = new QWidget(this);
auto *fileInfoLayout = new QVBoxLayout(m_fileInfoWidget);
fileInfoLayout->setContentsMargins(0, 0, 0, 4);
fileInfoLayout->setSpacing(6);
m_filePathLabel = new QLabel(m_fileInfoWidget);
m_filePathLabel->setObjectName("detail-filepath");
m_filePathLabel->setWordWrap(true);
m_filePathLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
m_fileMetaLabel = new QLabel(m_fileInfoWidget);
m_fileMetaLabel->setObjectName("detail-filemeta");
fileInfoLayout->addWidget(m_filePathLabel);
fileInfoLayout->addWidget(m_fileMetaLabel);
m_mediaInfoGallery = new HorizontalWidgetGallery(this);
m_mediaInfoGallery->setObjectName("detail-media-info-gallery");
m_tagsBottomWrapper = wrapMaxWidth(m_tagsBottomWidget, 1200);
mainLayout->addWidget(m_tagsBottomTitle);
mainLayout->addWidget(m_tagsBottomWrapper);
m_studiosWrapper = wrapMaxWidth(m_studiosWidget, 1200);
mainLayout->addWidget(m_studiosTitle);
mainLayout->addWidget(m_studiosWrapper);
m_externalLinksWrapper = wrapMaxWidth(m_externalLinksWidget, 1200);
mainLayout->addWidget(m_externalLinksTitle);
mainLayout->addWidget(m_externalLinksWrapper);
mainLayout->addWidget(m_mediaInfoTitle);
mainLayout->addWidget(m_fileInfoWidget);
mainLayout->addWidget(m_mediaInfoGallery);
auto watchFlowGeometry = [this](QWidget *widget, QWidget *wrapper) {
if (widget)
widget->installEventFilter(this);
if (wrapper)
wrapper->installEventFilter(this);
};
watchFlowGeometry(m_tagsBottomWidget, m_tagsBottomWrapper);
watchFlowGeometry(m_studiosWidget, m_studiosWrapper);
watchFlowGeometry(m_externalLinksWidget, m_externalLinksWrapper);
clear();
}
QWidget *DetailBottomInfoWidget::wrapMaxWidth(QWidget *child, int maxW) {
child->setMaximumWidth(maxW);
child->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
QWidget *wrapper = new QWidget(this);
wrapper->setObjectName("detail-maxWidth-wrapper");
wrapper->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
auto *hl = new QHBoxLayout(wrapper);
hl->setContentsMargins(0, 0, 0, 0);
hl->setSpacing(0);
hl->addWidget(child, 1);
hl->addStretch(0);
return wrapper;
}
void DetailBottomInfoWidget::clear() {
clearLayout(m_tagsBottomLayout);
clearLayout(m_studiosLayout);
clearLayout(m_externalLinksLayout);
m_mediaInfoGallery->clear();
m_tagsBottomTitle->hide();
m_tagsBottomWrapper->hide();
m_studiosTitle->hide();
m_studiosWrapper->hide();
m_externalLinksTitle->hide();
m_externalLinksWrapper->hide();
m_mediaInfoTitle->hide();
m_fileInfoWidget->hide();
m_mediaInfoGallery->hide();
updateFlowLayoutHeights();
}
void DetailBottomInfoWidget::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
scheduleFlowLayoutHeightUpdate();
}
void DetailBottomInfoWidget::showEvent(QShowEvent *event) {
QWidget::showEvent(event);
scheduleFlowLayoutHeightUpdate();
}
bool DetailBottomInfoWidget::eventFilter(QObject *watched, QEvent *event) {
const bool watchesFlowGeometry =
watched == m_tagsBottomWidget || watched == m_tagsBottomWrapper ||
watched == m_studiosWidget || watched == m_studiosWrapper ||
watched == m_externalLinksWidget ||
watched == m_externalLinksWrapper;
if (watchesFlowGeometry) {
switch (event->type()) {
case QEvent::LayoutRequest:
case QEvent::Resize:
case QEvent::Show:
scheduleFlowLayoutHeightUpdate();
break;
default:
break;
}
}
return QWidget::eventFilter(watched, event);
}
void DetailBottomInfoWidget::scheduleFlowLayoutHeightUpdate() {
if (m_flowLayoutHeightUpdatePending)
return;
m_flowLayoutHeightUpdatePending = true;
QTimer::singleShot(0, this, [this]() {
m_flowLayoutHeightUpdatePending = false;
updateFlowLayoutHeights();
});
}
int DetailBottomInfoWidget::resolveFlowLayoutWidth(QWidget *widget) const {
if (!widget)
return 0;
const int maxWidth =
widget->maximumWidth() < QWIDGETSIZE_MAX ? widget->maximumWidth() : 0;
auto clampWidth = [maxWidth](int width) {
if (maxWidth > 0)
width = qMin(width, maxWidth);
return qMax(0, width);
};
auto horizontalMargins = [this]() {
if (QLayout *mainLayout = this->layout()) {
const QMargins margins = mainLayout->contentsMargins();
return margins.left() + margins.right();
}
return 0;
};
auto contentWidthFrom = [&horizontalMargins](const QWidget *source) {
if (!source)
return 0;
return qMax(0, source->contentsRect().width() - horizontalMargins());
};
QWidget *wrapper = widget->parentWidget();
const int wrapperWidth = wrapper ? wrapper->contentsRect().width() : 0;
if (isVisible() && wrapper && wrapper->isVisible() && wrapperWidth > 0)
return clampWidth(wrapperWidth);
if (!isVisible() && maxWidth > 0) {
const int parentContentWidth = contentWidthFrom(parentWidget());
if (parentContentWidth > 160)
return clampWidth(parentContentWidth);
return maxWidth;
}
int targetWidth = contentWidthFrom(this);
if (targetWidth <= 0 && parentWidget())
targetWidth = contentWidthFrom(parentWidget());
if (targetWidth <= 0)
targetWidth = widget->width();
if (targetWidth <= 0 && maxWidth > 0)
targetWidth = maxWidth;
return clampWidth(targetWidth);
}
void DetailBottomInfoWidget::updateFlowLayoutHeight(QWidget *widget,
FlowLayout *layout) {
if (!widget || !layout)
return;
int targetHeight = 0;
if (layout->count() > 0) {
const int targetWidth = resolveFlowLayoutWidth(widget);
if (targetWidth > 0) {
layout->invalidate();
targetHeight = layout->heightForWidth(targetWidth);
}
}
if (widget->minimumHeight() != targetHeight) {
widget->setMinimumHeight(targetHeight);
}
if (widget->maximumHeight() != targetHeight) {
widget->setMaximumHeight(targetHeight);
}
widget->updateGeometry();
if (QWidget *wrapper = widget->parentWidget()) {
if (wrapper->minimumHeight() != targetHeight) {
wrapper->setMinimumHeight(targetHeight);
}
if (wrapper->maximumHeight() != targetHeight) {
wrapper->setMaximumHeight(targetHeight);
}
wrapper->updateGeometry();
}
}
void DetailBottomInfoWidget::updateFlowLayoutHeights() {
updateFlowLayoutHeight(m_tagsBottomWidget, m_tagsBottomLayout);
updateFlowLayoutHeight(m_studiosWidget, m_studiosLayout);
updateFlowLayoutHeight(m_externalLinksWidget, m_externalLinksLayout);
updateGeometry();
}
void DetailBottomInfoWidget::clearLayout(QLayout *layout) {
if (!layout)
return;
QLayoutItem *child;
while ((child = layout->takeAt(0)) != nullptr) {
if (child->widget()) {
child->widget()->hide();
child->widget()->setParent(nullptr);
child->widget()->deleteLater();
} else if (child->layout()) {
clearLayout(child->layout());
}
delete child;
}
}
QString DetailBottomInfoWidget::formatSize(long long bytes) {
if (bytes < 1024)
return QString::number(bytes) + " B";
if (bytes < 1024 * 1024)
return QString::number(bytes / 1024.0, 'f', 1) + " KB";
if (bytes < 1024 * 1024 * 1024)
return QString::number(bytes / 1048576.0, 'f', 1) + " MB";
return QString::number(bytes / 1073741824.0, 'f', 1) + " GB";
}
void DetailBottomInfoWidget::addInfoRow(QGridLayout *layout, int &row,
const QString &key,
const QString &value) {
if (value.isEmpty())
return;
QLabel *kLabel = new QLabel(key);
kLabel->setObjectName("stream-info-key");
QLabel *vLabel = new QLabel(value);
vLabel->setObjectName("stream-info-value");
vLabel->setWordWrap(true);
layout->addWidget(kLabel, row, 0, Qt::AlignTop | Qt::AlignLeft);
layout->addWidget(vLabel, row, 1, Qt::AlignTop | Qt::AlignLeft);
row++;
}
void DetailBottomInfoWidget::setInfo(const MediaItem &item,
const QList<MediaSourceInfo> &sources) {
clear();
if (!item.tags.isEmpty()) {
m_tagsBottomTitle->show();
m_tagsBottomWrapper->show();
for (const QString &tag : item.tags) {
auto *btn = new DetailTagButton(tag, m_tagsBottomWidget);
connect(btn, &QPushButton::clicked, this,
[this, tag]() { Q_EMIT filterClicked("Tag", tag); });
m_tagsBottomLayout->addWidget(btn);
}
}
if (!item.studios.isEmpty()) {
m_studiosTitle->show();
m_studiosWrapper->show();
for (const auto &studio : item.studios) {
auto *btn = new DetailTagButton(studio.name, m_studiosWidget);
connect(btn, &QPushButton::clicked, this,
[this, name = studio.name]() { Q_EMIT filterClicked("Studio", name); });
m_studiosLayout->addWidget(btn);
}
}
if (!item.externalUrls.isEmpty()) {
m_externalLinksTitle->show();
m_externalLinksWrapper->show();
for (const auto &link : item.externalUrls) {
auto *btn = new DetailTagButton(link.name, m_externalLinksWidget);
connect(btn, &QPushButton::clicked, this,
[link]() { QDesktopServices::openUrl(QUrl(link.url)); });
m_externalLinksLayout->addWidget(btn);
}
}
updateFlowLayoutHeights();
scheduleFlowLayoutHeightUpdate();
if (sources.isEmpty())
return;
m_mediaInfoTitle->show();
m_fileInfoWidget->show();
m_mediaInfoGallery->show();
const auto &source = sources.first();
QString actualPath = source.path.isEmpty() ? item.path : source.path;
m_filePathLabel->setText(QUrl::fromPercentEncoding(actualPath.toUtf8()));
QStringList metaParts;
QString container =
source.container.isEmpty() ? item.container : source.container;
if (!container.isEmpty())
metaParts << container.toUpper();
long long size = source.size > 0 ? source.size : item.size;
if (size > 0)
metaParts << formatSize(size);
if (!item.dateCreated.isEmpty())
metaParts << tr("Added %1").arg(item.dateCreated);
m_fileMetaLabel->setText(metaParts.join(""));
for (const auto &stream : source.mediaStreams) {
QWidget *card = new QWidget();
card->setObjectName("stream-card");
card->setMinimumWidth(280);
auto *layout = new QVBoxLayout(card);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(8);
QString typeText = stream.type;
if (typeText == "Video")
typeText = tr("Video");
else if (typeText == "Audio")
typeText = tr("Audio");
else if (typeText == "Subtitle")
typeText = tr("Subtitle");
QLabel *typeLabel = new QLabel(typeText, card);
typeLabel->setObjectName("stream-type");
layout->addWidget(typeLabel);
QGridLayout *grid = new QGridLayout();
grid->setContentsMargins(0, 4, 0, 0);
grid->setSpacing(8);
grid->setColumnMinimumWidth(0, 80);
int row = 0;
addInfoRow(grid, row, tr("Title"), stream.displayTitle);
addInfoRow(grid, row, tr("Codec"), stream.codec.toUpper());
if (stream.type == "Video") {
if (!stream.language.isEmpty())
addInfoRow(grid, row, tr("Language"), stream.language);
if (!stream.codecTag.isEmpty())
addInfoRow(grid, row, tr("Codec Tag"), stream.codecTag);
if (!stream.profile.isEmpty())
addInfoRow(grid, row, tr("Profile"), stream.profile);
if (stream.level > 0)
addInfoRow(grid, row, tr("Level"), QString::number(stream.level));
if (stream.width > 0 && stream.height > 0)
addInfoRow(grid, row, tr("Resolution"),
QString("%1x%2").arg(stream.width).arg(stream.height));
if (!stream.aspectRatio.isEmpty())
addInfoRow(grid, row, tr("Aspect Ratio"), stream.aspectRatio);
if (stream.isInterlaced)
addInfoRow(grid, row, tr("Interlaced"), tr("Yes"));
if (stream.realFrameRate > 0)
addInfoRow(grid, row, tr("Framerate"),
QString::number(stream.realFrameRate, 'f', 2));
if (stream.bitRate > 0)
addInfoRow(grid, row, tr("Bitrate"),
QString("%1 kbps").arg(stream.bitRate / 1000));
if (stream.bitDepth > 0)
addInfoRow(grid, row, tr("Bit Depth"),
QString("%1 bit").arg(stream.bitDepth));
if (!stream.pixelFormat.isEmpty())
addInfoRow(grid, row, tr("Pixel Format"), stream.pixelFormat);
if (stream.refFrames > 0)
addInfoRow(grid, row, tr("Ref Frames"),
QString::number(stream.refFrames));
} else if (stream.type == "Audio") {
if (!stream.language.isEmpty())
addInfoRow(grid, row, tr("Language"), stream.language);
if (!stream.codecTag.isEmpty())
addInfoRow(grid, row, tr("Codec Tag"), stream.codecTag);
if (!stream.profile.isEmpty())
addInfoRow(grid, row, tr("Profile"), stream.profile);
if (!stream.channelLayout.isEmpty())
addInfoRow(grid, row, tr("Layout"), stream.channelLayout);
if (stream.channels > 0)
addInfoRow(grid, row, tr("Channels"),
QString("%1 ch").arg(stream.channels));
if (stream.bitRate > 0)
addInfoRow(grid, row, tr("Bitrate"),
QString("%1 kbps").arg(stream.bitRate / 1000));
if (stream.sampleRate > 0)
addInfoRow(grid, row, tr("Sample Rate"),
QString("%1 Hz").arg(stream.sampleRate));
if (stream.isDefault)
addInfoRow(grid, row, tr("Default"), tr("Yes"));
} else if (stream.type == "Subtitle") {
QString displayLang = stream.displayLanguage.isEmpty()
? stream.language
: stream.displayLanguage;
if (!displayLang.isEmpty())
addInfoRow(grid, row, tr("Language"), displayLang);
if (stream.index >= 0)
addInfoRow(grid, row, tr("Index"), QString::number(stream.index));
if (stream.isDefault)
addInfoRow(grid, row, tr("Default"), tr("Yes"));
if (stream.isForced)
addInfoRow(grid, row, tr("Forced"), tr("Yes"));
if (stream.isHearingImpaired)
addInfoRow(grid, row, tr("Hearing Impaired"), tr("Yes"));
if (stream.isExternal)
addInfoRow(grid, row, tr("External"), tr("Yes"));
if (stream.isTextSubtitleStream)
addInfoRow(grid, row, tr("Text Stream"), tr("Yes"));
if (stream.supportsExternalStream)
addInfoRow(grid, row, tr("Ext. Stream Supported"), tr("Yes"));
if (stream.isExternalUrl)
addInfoRow(grid, row, tr("External URL"), tr("Yes"));
if (!stream.deliveryMethod.isEmpty())
addInfoRow(grid, row, tr("Delivery Method"), stream.deliveryMethod);
if (!stream.protocol.isEmpty() && stream.protocol != "None")
addInfoRow(grid, row, tr("Protocol"), stream.protocol);
if (!stream.extendedVideoType.isEmpty() &&
stream.extendedVideoType != "None")
addInfoRow(grid, row, tr("Ext. Video Type"), stream.extendedVideoType);
if (!stream.extendedVideoSubType.isEmpty() &&
stream.extendedVideoSubType != "None")
addInfoRow(grid, row, tr("Ext. Video SubType"),
stream.extendedVideoSubType);
if (stream.attachmentSize > 0)
addInfoRow(grid, row, tr("Size"), formatSize(stream.attachmentSize));
QFileInfo fi(stream.path);
if (!stream.path.isEmpty())
addInfoRow(grid, row, tr("Path"), fi.completeBaseName());
if (!stream.deliveryUrl.isEmpty())
addInfoRow(grid, row, tr("Delivery URL"), stream.deliveryUrl);
}
layout->addLayout(grid);
layout->addStretch();
m_mediaInfoGallery->addWidget(card);
}
m_mediaInfoGallery->adjustHeightToContent();
}

View File

@@ -0,0 +1,67 @@
#ifndef DETAILBOTTOMINFOWIDGET_H
#define DETAILBOTTOMINFOWIDGET_H
#include <QWidget>
#include "models/media/mediaitem.h"
class FlowLayout;
class HorizontalWidgetGallery;
class QLabel;
class QVBoxLayout;
class QGridLayout;
class QEvent;
class QResizeEvent;
class QShowEvent;
class DetailBottomInfoWidget : public QWidget {
Q_OBJECT
public:
explicit DetailBottomInfoWidget(QWidget* parent = nullptr);
void setInfo(const MediaItem& item, const QList<MediaSourceInfo>& sources);
void clear();
signals:
void filterClicked(const QString& filterType, const QString& filterValue);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void showEvent(QShowEvent* event) override;
private:
void clearLayout(QLayout* layout);
QString formatSize(long long bytes);
void addInfoRow(QGridLayout* layout, int& row, const QString& key, const QString& value);
QWidget* wrapMaxWidth(QWidget* child, int maxW);
int resolveFlowLayoutWidth(QWidget* widget) const;
void updateFlowLayoutHeight(QWidget* widget, FlowLayout* layout);
void updateFlowLayoutHeights();
void scheduleFlowLayoutHeightUpdate();
QLabel* m_tagsBottomTitle;
QWidget* m_tagsBottomWidget;
QWidget* m_tagsBottomWrapper;
FlowLayout* m_tagsBottomLayout;
QLabel* m_studiosTitle;
QWidget* m_studiosWidget;
QWidget* m_studiosWrapper;
FlowLayout* m_studiosLayout;
QLabel* m_externalLinksTitle;
QWidget* m_externalLinksWidget;
QWidget* m_externalLinksWrapper;
FlowLayout* m_externalLinksLayout;
QLabel* m_mediaInfoTitle;
QWidget* m_fileInfoWidget;
QLabel* m_filePathLabel;
QLabel* m_fileMetaLabel;
HorizontalWidgetGallery* m_mediaInfoGallery;
bool m_flowLayoutHeightUpdatePending = false;
};
#endif

View File

@@ -0,0 +1,91 @@
#include "detailcontentwidget.h"
#include <QPainter>
#include <QStyleOption>
#include <QLinearGradient>
DetailContentWidget::DetailContentWidget(QWidget* parent)
: QWidget(parent),
m_cachedWidth(-1),
m_baseColor(QColor("#FFFFFF")),
m_gradientColor(QColor(249, 250, 251))
{
setAttribute(Qt::WA_StyledBackground, true);
}
void DetailContentWidget::setBackdrop(const QPixmap& pix) {
m_backdrop = pix;
m_cachedWidth = -1;
update();
}
QColor DetailContentWidget::baseColor() const {
return m_baseColor;
}
void DetailContentWidget::setBaseColor(const QColor& color) {
if (m_baseColor != color) {
m_baseColor = color;
update();
}
}
QColor DetailContentWidget::gradientColor() const {
return m_gradientColor;
}
void DetailContentWidget::setGradientColor(const QColor& color) {
if (m_gradientColor != color) {
m_gradientColor = color;
update();
}
}
void DetailContentWidget::paintEvent(QPaintEvent* event) {
Q_UNUSED(event);
QStyleOption opt;
opt.initFrom(this);
QPainter painter(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.fillRect(rect(), m_baseColor);
if (m_backdrop.isNull()) return;
int w = width();
int imgW = m_backdrop.width();
int imgH = m_backdrop.height();
if (imgW <= 0) return;
int targetHeight = w * imgH / imgW;
if (w != m_cachedWidth) {
m_scaledBackdrop = m_backdrop.scaled(w, targetHeight, Qt::KeepAspectRatio, Qt::SmoothTransformation);
m_cachedWidth = w;
}
painter.drawPixmap(0, 0, m_scaledBackdrop);
QLinearGradient gradient(0, 0, 0, targetHeight);
int r = m_gradientColor.red();
int g = m_gradientColor.green();
int b = m_gradientColor.blue();
gradient.setColorAt(0.0, QColor(r, g, b, 0));
gradient.setColorAt(0.15, QColor(r, g, b, 150));
gradient.setColorAt(0.4, QColor(r, g, b, 180));
gradient.setColorAt(1.0, QColor(r, g, b, 255));
painter.fillRect(0, 0, w, targetHeight, gradient);
}

View File

@@ -0,0 +1,39 @@
#ifndef DETAILCONTENTWIDGET_H
#define DETAILCONTENTWIDGET_H
#include <QWidget>
#include <QPixmap>
#include <QColor>
class DetailContentWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor)
Q_PROPERTY(QColor gradientColor READ gradientColor WRITE setGradientColor)
public:
explicit DetailContentWidget(QWidget* parent = nullptr);
void setBackdrop(const QPixmap& pix);
QColor baseColor() const;
void setBaseColor(const QColor& color);
QColor gradientColor() const;
void setGradientColor(const QColor& color);
protected:
void paintEvent(QPaintEvent* event) override;
private:
QPixmap m_backdrop;
QPixmap m_scaledBackdrop;
int m_cachedWidth;
QColor m_baseColor;
QColor m_gradientColor;
};
#endif

View File

@@ -0,0 +1,27 @@
#include "detailtagbutton.h"
#include <QSizePolicy>
DetailTagButton::DetailTagButton(const QString &text, QWidget *parent)
: QPushButton(text, parent) {
setObjectName("detail-genre-tag");
setAttribute(Qt::WA_LayoutUsesWidgetRect, true);
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setFlat(true);
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed);
setFixedHeight(kTagHeight);
ensurePolished();
}
QSize DetailTagButton::sizeHint() const {
QSize size = QPushButton::sizeHint();
size.setHeight(kTagHeight);
return size;
}
QSize DetailTagButton::minimumSizeHint() const {
QSize size = QPushButton::minimumSizeHint();
size.setHeight(kTagHeight);
return size;
}

View File

@@ -0,0 +1,19 @@
#ifndef DETAILTAGBUTTON_H
#define DETAILTAGBUTTON_H
#include <QPushButton>
#include <QSize>
#include <QString>
class DetailTagButton : public QPushButton {
public:
explicit DetailTagButton(const QString &text, QWidget *parent = nullptr);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
static constexpr int kTagHeight = 28;
};
#endif

View File

@@ -0,0 +1,932 @@
#include "downloadmanagerdialog.h"
#include "../../qEmbyCore/config/config_keys.h"
#include "../../qEmbyCore/config/configstore.h"
#include "../managers/thememanager.h"
#include "../utils/layoututils.h"
#include "elidedlabel.h"
#include "modernmessagebox.h"
#include <qembycore.h>
#include <services/manager/servermanager.h>
#include <QColor>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
#include <QDir>
#include <QEvent>
#include <QFileDialog>
#include <QFileInfo>
#include <QFrame>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QPixmap>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QSize>
#include <QStringList>
#include <QStyle>
#include <QUrl>
#include <QVBoxLayout>
#include <QtMath>
namespace
{
QString formatBytes(qint64 bytes)
{
if (bytes < 0)
{
return DownloadManagerDialog::tr("Unknown");
}
static const QStringList units = {DownloadManagerDialog::tr("B"), DownloadManagerDialog::tr("KB"),
DownloadManagerDialog::tr("MB"), DownloadManagerDialog::tr("GB"),
DownloadManagerDialog::tr("TB")};
double value = static_cast<double>(bytes);
int unitIndex = 0;
while (value >= 1024.0 && unitIndex < units.size() - 1)
{
value /= 1024.0;
++unitIndex;
}
const int precision = unitIndex == 0 ? 0 : 1;
return QStringLiteral("%1 %2").arg(QString::number(value, 'f', precision), units.at(unitIndex));
}
QString formatDateTime(qint64 timestampMs)
{
if (timestampMs <= 0)
{
return DownloadManagerDialog::tr("--");
}
return QDateTime::fromMSecsSinceEpoch(timestampMs).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
}
QString formatSize(const DownloadManager::DownloadRecord &record)
{
if (record.bytesTotal > 0)
{
return formatBytes(record.bytesTotal);
}
if (record.bytesReceived > 0)
{
return formatBytes(record.bytesReceived);
}
return DownloadManagerDialog::tr("Unknown");
}
QString formatProgress(const DownloadManager::DownloadRecord &record)
{
const QString downloadingText = DownloadManager::tr("Downloading");
const QString pausingText = DownloadManager::tr("Pausing");
const QString cancellingText = DownloadManager::tr("Cancelling");
if (record.isActive)
{
QString actionText = downloadingText;
if (record.statusText == pausingText)
{
actionText = pausingText;
}
else if (record.statusText == cancellingText)
{
actionText = cancellingText;
}
if (record.bytesTotal > 0)
{
const double ratio = static_cast<double>(record.bytesReceived) / static_cast<double>(record.bytesTotal);
const int percent = qBound(0, qRound(ratio * 100.0), 100);
return DownloadManagerDialog::tr("%1 %2% (%3 / %4)")
.arg(actionText)
.arg(percent)
.arg(formatBytes(record.bytesReceived))
.arg(formatBytes(record.bytesTotal));
}
if (record.bytesReceived > 0)
{
return DownloadManagerDialog::tr("%1 %2").arg(actionText).arg(formatBytes(record.bytesReceived));
}
if (record.statusText == pausingText || record.statusText == cancellingText)
{
return record.statusText;
}
return DownloadManagerDialog::tr("Waiting...");
}
if (record.isPaused)
{
if (record.bytesTotal > 0)
{
const double ratio = static_cast<double>(record.bytesReceived) / static_cast<double>(record.bytesTotal);
const int percent = qBound(0, qRound(ratio * 100.0), 100);
return DownloadManagerDialog::tr("Paused %1% (%2 / %3)")
.arg(percent)
.arg(formatBytes(record.bytesReceived))
.arg(formatBytes(record.bytesTotal));
}
if (record.bytesReceived > 0)
{
return DownloadManagerDialog::tr("Paused %1").arg(formatBytes(record.bytesReceived));
}
return DownloadManagerDialog::tr("Paused");
}
if (record.isCompleted)
{
return DownloadManagerDialog::tr("Completed");
}
return record.statusText.trimmed().isEmpty() ? DownloadManagerDialog::tr("Failed") : record.statusText;
}
QString progressTone(const DownloadManager::DownloadRecord &record)
{
if (record.isActive)
{
if (record.statusText == DownloadManager::tr("Cancelling"))
{
return QStringLiteral("danger");
}
return QStringLiteral("active");
}
if (record.isPaused)
{
return QStringLiteral("paused");
}
if (record.isCompleted)
{
return QStringLiteral("success");
}
return QStringLiteral("danger");
}
QColor accentIconColor()
{
return ThemeManager::instance()->isDarkMode() ? QColor(QStringLiteral("#93C5FD"))
: QColor(QStringLiteral("#2563EB"));
}
QColor dangerIconColor()
{
return ThemeManager::instance()->isDarkMode() ? QColor(QStringLiteral("#FCA5A5"))
: QColor(QStringLiteral("#DC2626"));
}
QColor mutedIconColor()
{
return ThemeManager::instance()->isDarkMode() ? QColor(QStringLiteral("#64748B"))
: QColor(QStringLiteral("#94A3B8"));
}
constexpr int kIndexColumnWidth = 46;
constexpr int kSizeColumnWidth = 104;
constexpr int kDateColumnWidth = 150;
constexpr int kProgressColumnWidth = 220;
constexpr int kActionButtonSize = 30;
constexpr int kActionButtonsSpacing = 8;
constexpr int kRecordsTrailingInset = 16;
constexpr int kActionAreaWidth = (kActionButtonSize * 4) + (kActionButtonsSpacing * 3);
QPushButton *createIconButton(const QIcon &icon, const QString &toolTip, const QString &tone, QWidget *parent)
{
auto *button = new QPushButton(parent);
button->setObjectName("DownloadIconButton");
button->setProperty("tone", tone);
button->setCursor(Qt::PointingHandCursor);
button->setFocusPolicy(Qt::NoFocus);
button->setFixedSize(kActionButtonSize, kActionButtonSize);
button->setIcon(icon);
button->setIconSize(QSize(16, 16));
button->setToolTip(toolTip);
return button;
}
QWidget *createActionPlaceholder(int width, QWidget *parent)
{
auto *placeholder = new QWidget(parent);
placeholder->setFixedWidth(width);
return placeholder;
}
QLabel *createHeaderLabel(const QString &text, int width, Qt::Alignment alignment, QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setObjectName("DownloadHeaderLabel");
label->setAlignment(alignment);
if (width > 0)
{
label->setFixedWidth(width);
}
return label;
}
QLabel *createValueLabel(const QString &text, const QString &objectName, int width, Qt::Alignment alignment,
QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setObjectName(objectName);
label->setAlignment(alignment);
if (width > 0)
{
label->setFixedWidth(width);
}
label->setToolTip(text);
return label;
}
void setButtonTone(QPushButton *button, const QString &tone)
{
if (!button || button->property("tone").toString() == tone)
{
return;
}
button->setProperty("tone", tone);
button->style()->unpolish(button);
button->style()->polish(button);
button->update();
}
}
void DownloadManagerDialog::showManager(QEmbyCore *core, QWidget *parent)
{
static QPointer<DownloadManagerDialog> s_dialog;
QWidget *owner = parent ? parent->window() : nullptr;
DownloadManager::instance()->init(core);
if (!s_dialog)
{
s_dialog = new DownloadManagerDialog(core, owner);
s_dialog->setAttribute(Qt::WA_DeleteOnClose, true);
}
else
{
s_dialog->m_core = core;
s_dialog->bindServerSignals();
s_dialog->refreshUi();
}
s_dialog->show();
if (s_dialog->isMinimized())
{
s_dialog->showNormal();
}
s_dialog->raise();
s_dialog->activateWindow();
}
DownloadManagerDialog::DownloadManagerDialog(QEmbyCore *core, QWidget *parent) : ModernDialogBase(parent), m_core(core)
{
setModal(false);
resize(1180, 660);
setTitle(tr("Download Manager"));
auto *rootLayout = contentLayout();
rootLayout->setContentsMargins(20, 10, 0, 20);
rootLayout->setSpacing(10);
auto *listCard = new QWidget(this);
listCard->setObjectName("DownloadListSection");
listCard->setAttribute(Qt::WA_StyledBackground, true);
auto *listLayout = new QVBoxLayout(listCard);
listLayout->setContentsMargins(0, 0, 0, 0);
listLayout->setSpacing(8);
auto *summaryBar = new QWidget(listCard);
summaryBar->setObjectName("DownloadSummaryBar");
summaryBar->setAttribute(Qt::WA_StyledBackground, true);
auto *summaryLayout = new QHBoxLayout(summaryBar);
summaryLayout->setContentsMargins(2, 0, 2, 2);
summaryLayout->setSpacing(10);
m_summaryLabel = new QLabel(summaryBar);
m_summaryLabel->setObjectName("DownloadSummaryLabel");
m_summaryLabel->setWordWrap(true);
summaryLayout->addWidget(m_summaryLabel);
listLayout->addWidget(summaryBar);
m_recordsHeader = new QWidget(listCard);
m_recordsHeader->setObjectName("DownloadListHeader");
m_recordsHeader->setAttribute(Qt::WA_StyledBackground, true);
auto *headerLayout = new QHBoxLayout(m_recordsHeader);
headerLayout->setContentsMargins(8, 6, 8 + kRecordsTrailingInset, 10);
headerLayout->setSpacing(12);
headerLayout->addWidget(createHeaderLabel(tr("No."), kIndexColumnWidth, Qt::AlignCenter, m_recordsHeader));
headerLayout->addWidget(createHeaderLabel(tr("Media"), 0, Qt::AlignVCenter | Qt::AlignLeft, m_recordsHeader), 1);
headerLayout->addWidget(createHeaderLabel(tr("Size"), kSizeColumnWidth, Qt::AlignCenter, m_recordsHeader));
headerLayout->addWidget(createHeaderLabel(tr("Started"), kDateColumnWidth, Qt::AlignCenter, m_recordsHeader));
headerLayout->addWidget(createHeaderLabel(tr("Finished"), kDateColumnWidth, Qt::AlignCenter, m_recordsHeader));
headerLayout->addWidget(createHeaderLabel(tr("Progress"), kProgressColumnWidth, Qt::AlignCenter, m_recordsHeader));
headerLayout->addWidget(createActionPlaceholder(kActionAreaWidth, m_recordsHeader));
listLayout->addWidget(m_recordsHeader);
m_emptyState = new QFrame(listCard);
m_emptyState->setObjectName("DownloadEmptyState");
m_emptyState->setAttribute(Qt::WA_StyledBackground, true);
auto *emptyLayout = new QVBoxLayout(m_emptyState);
emptyLayout->setContentsMargins(28, 28, 28, 28);
emptyLayout->setSpacing(12);
emptyLayout->addStretch(1);
m_emptyIconLabel = new QLabel(m_emptyState);
m_emptyIconLabel->setObjectName("DownloadEmptyIcon");
m_emptyIconLabel->setAlignment(Qt::AlignCenter);
emptyLayout->addWidget(m_emptyIconLabel, 0, Qt::AlignCenter);
m_emptyTitleLabel = new QLabel(tr("No records yet"), m_emptyState);
m_emptyTitleLabel->setObjectName("DownloadEmptyTitle");
m_emptyTitleLabel->setAlignment(Qt::AlignCenter);
emptyLayout->addWidget(m_emptyTitleLabel, 0, Qt::AlignCenter);
m_emptyHintLabel = new QLabel(tr("Start a download and the record will appear here."), m_emptyState);
m_emptyHintLabel->setObjectName("DownloadEmptyHint");
m_emptyHintLabel->setAlignment(Qt::AlignCenter);
m_emptyHintLabel->setWordWrap(true);
emptyLayout->addWidget(m_emptyHintLabel, 0, Qt::AlignCenter);
emptyLayout->addStretch(1);
listLayout->addWidget(m_emptyState, 1);
m_recordsScrollArea = new QScrollArea(listCard);
m_recordsScrollArea->setObjectName("DownloadRecordsArea");
m_recordsScrollArea->setWidgetResizable(true);
m_recordsScrollArea->setFrameShape(QFrame::NoFrame);
m_recordsScrollArea->setFocusPolicy(Qt::NoFocus);
m_recordsScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_recordsScrollArea->viewport()->setObjectName("DownloadRecordsViewport");
m_recordsContainer = new QWidget(m_recordsScrollArea);
m_recordsContainer->setObjectName("DownloadRecordsContainer");
m_recordsContainer->setAttribute(Qt::WA_StyledBackground, true);
m_recordsLayout = new QVBoxLayout(m_recordsContainer);
m_recordsLayout->setContentsMargins(0, 0, kRecordsTrailingInset, 0);
m_recordsLayout->setSpacing(8);
m_recordsLayout->addStretch(1);
m_recordsScrollArea->setWidget(m_recordsContainer);
listLayout->addWidget(m_recordsScrollArea, 1);
rootLayout->addWidget(listCard, 1);
auto *locationCard = new QWidget(this);
locationCard->setObjectName("DownloadFooterBar");
locationCard->setAttribute(Qt::WA_StyledBackground, true);
auto *locationLayout = new QHBoxLayout(locationCard);
locationLayout->setContentsMargins(2, 14, kRecordsTrailingInset, 0);
locationLayout->setSpacing(12);
auto *locationTextLayout = new QVBoxLayout();
locationTextLayout->setContentsMargins(0, 0, 0, 0);
locationTextLayout->setSpacing(4);
auto *locationTitle = new QLabel(tr("Saved To"), locationCard);
locationTitle->setObjectName("DownloadSectionTitle");
locationTextLayout->addWidget(locationTitle);
m_locationValueLabel = new QLabel(locationCard);
m_locationValueLabel->setObjectName("DownloadLocationValue");
m_locationValueLabel->setWordWrap(true);
m_locationValueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
locationTextLayout->addWidget(m_locationValueLabel);
locationLayout->addLayout(locationTextLayout, 1);
m_openDirectoryButton = new QPushButton(locationCard);
m_openDirectoryButton->setObjectName("DownloadIconButton");
m_openDirectoryButton->setProperty("tone", QStringLiteral("default"));
m_openDirectoryButton->setCursor(Qt::PointingHandCursor);
m_openDirectoryButton->setFocusPolicy(Qt::NoFocus);
m_openDirectoryButton->setFixedSize(32, 32);
m_openDirectoryButton->setToolTip(tr("Open Directory"));
locationLayout->addWidget(m_openDirectoryButton, 0, Qt::AlignBottom);
m_changeDirectoryButton = new QPushButton(locationCard);
m_changeDirectoryButton->setObjectName("DownloadIconButton");
m_changeDirectoryButton->setProperty("tone", QStringLiteral("accent"));
m_changeDirectoryButton->setCursor(Qt::PointingHandCursor);
m_changeDirectoryButton->setFocusPolicy(Qt::NoFocus);
m_changeDirectoryButton->setFixedSize(32, 32);
m_changeDirectoryButton->setToolTip(tr("Set Directory"));
locationLayout->addWidget(m_changeDirectoryButton, 0, Qt::AlignBottom);
rootLayout->addWidget(locationCard);
connect(DownloadManager::instance(), &DownloadManager::downloadsChanged, this, [this]() { refreshUi(); });
connect(DownloadManager::instance(), &DownloadManager::downloadDirectoryChanged, this,
[this](const QString &) { refreshUi(); });
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, [this](ThemeManager::Theme) { refreshUi(); });
connect(m_openDirectoryButton, &QPushButton::clicked, this,
[this]()
{
const QString directory = DownloadManager::instance()->downloadDirectory();
QDir().mkpath(directory);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
{
ModernMessageBox::warning(this, tr("Open Directory"),
tr("The download folder is no longer available."));
}
});
connect(m_changeDirectoryButton, &QPushButton::clicked, this,
[this]()
{
const QString selectedDirectory = QFileDialog::getExistingDirectory(
this, tr("Select Download Directory"), DownloadManager::instance()->downloadDirectory(),
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (selectedDirectory.trimmed().isEmpty())
{
return;
}
DownloadManager::instance()->setDownloadDirectory(selectedDirectory);
});
bindServerSignals();
refreshUi();
}
void DownloadManagerDialog::bindServerSignals()
{
QObject::disconnect(m_activeServerChangedConnection);
m_activeServerChangedConnection = {};
if (!m_core || !m_core->serverManager())
{
return;
}
m_activeServerChangedConnection =
connect(m_core->serverManager(), &ServerManager::activeServerChanged, this,
[this](const ServerProfile &profile)
{
qDebug() << "[DownloadManagerDialog] Active server changed"
<< "| serverUrl:" << profile.url
<< "| userId:" << profile.userId;
refreshUi();
});
}
void DownloadManagerDialog::applyIcons()
{
if (m_openDirectoryButton)
{
m_openDirectoryButton->setIcon(ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/folder.svg")));
m_openDirectoryButton->setIconSize(QSize(17, 17));
}
if (m_changeDirectoryButton)
{
m_changeDirectoryButton->setIcon(
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/hard-drive.svg"), accentIconColor()));
m_changeDirectoryButton->setIconSize(QSize(17, 17));
}
if (m_emptyIconLabel)
{
const QIcon emptyIcon =
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/download-sidebar.svg"), mutedIconColor());
m_emptyIconLabel->setPixmap(emptyIcon.pixmap(42, 42));
}
}
void DownloadManagerDialog::refreshUi()
{
const QList<DownloadManager::DownloadRecord> activeRecords =
DownloadManager::instance()->activeDownloadsForCurrentServer();
const QList<DownloadManager::DownloadRecord> historyRecords =
DownloadManager::instance()->historyDownloadsForCurrentServer();
int completedCount = 0;
for (const auto &record : historyRecords)
{
if (record.isCompleted)
{
++completedCount;
}
}
const int failedCount = historyRecords.size() - completedCount;
const int totalCount = activeRecords.size() + historyRecords.size();
QString activeServerUrl;
QString activeUserId;
if (m_core && m_core->serverManager())
{
const ServerProfile profile = m_core->serverManager()->activeProfile();
activeServerUrl = profile.url;
activeUserId = profile.userId;
}
qDebug() << "[DownloadManagerDialog] Refreshing records for active server"
<< "| serverUrl:" << activeServerUrl
<< "| userId:" << activeUserId
<< "| activeCount:" << activeRecords.size()
<< "| historyCount:" << historyRecords.size();
m_summaryLabel->setText(tr("%1 records, %2 active, %3 completed, %4 failed")
.arg(totalCount)
.arg(activeRecords.size())
.arg(completedCount)
.arg(failedCount));
const QString directory = QDir::toNativeSeparators(DownloadManager::instance()->downloadDirectory());
m_locationValueLabel->setText(directory);
m_locationValueLabel->setToolTip(directory);
QList<DownloadManager::DownloadRecord> records;
records.reserve(activeRecords.size() + historyRecords.size());
for (const auto &record : activeRecords)
{
records.append(record);
}
for (const auto &record : historyRecords)
{
records.append(record);
}
rebuildRecordList(records);
applyIcons();
}
void DownloadManagerDialog::rebuildRecordList(const QList<DownloadManager::DownloadRecord> &records)
{
QStringList orderedIds;
orderedIds.reserve(records.size());
int index = 1;
for (const auto &record : records)
{
orderedIds.append(record.id);
if (!m_recordRows.contains(record.id))
{
m_recordRows.insert(record.id, createRecordRow());
}
updateRecordRow(record.id, record, index++);
}
const QStringList existingIds = m_recordRows.keys();
for (const QString &recordId : existingIds)
{
if (!orderedIds.contains(recordId))
{
removeRecordRow(recordId);
}
}
if (orderedIds != m_rowOrder)
{
rebuildRecordLayout(orderedIds);
}
const bool hasRecords = !records.isEmpty();
if (m_recordsHeader)
{
m_recordsHeader->setVisible(hasRecords);
}
if (m_recordsScrollArea)
{
m_recordsScrollArea->setVisible(hasRecords);
}
if (m_emptyState)
{
m_emptyState->setVisible(!hasRecords);
}
}
DownloadManagerDialog::RecordRowWidgets DownloadManagerDialog::createRecordRow()
{
RecordRowWidgets widgets;
widgets.row = new QFrame(m_recordsContainer);
widgets.row->setObjectName("DownloadRecordRow");
widgets.row->setAttribute(Qt::WA_StyledBackground, true);
widgets.row->setAttribute(Qt::WA_Hover, true);
widgets.row->installEventFilter(this);
auto *rowLayout = new QHBoxLayout(widgets.row);
rowLayout->setContentsMargins(14, 12, 14, 12);
rowLayout->setSpacing(12);
widgets.indexLabel = createValueLabel(QString(), QStringLiteral("DownloadRecordIndex"), kIndexColumnWidth,
Qt::AlignCenter, widgets.row);
widgets.indexLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.indexLabel);
widgets.titleLabel = new ElidedLabel(widgets.row);
widgets.titleLabel->setObjectName("DownloadRecordTitle");
widgets.titleLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
widgets.titleLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.titleLabel, 1);
widgets.sizeLabel = createValueLabel(QString(), QStringLiteral("DownloadRecordValue"), kSizeColumnWidth,
Qt::AlignCenter, widgets.row);
widgets.sizeLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.sizeLabel);
widgets.startedLabel = createValueLabel(QString(), QStringLiteral("DownloadRecordValue"), kDateColumnWidth,
Qt::AlignCenter, widgets.row);
widgets.startedLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.startedLabel);
widgets.finishedLabel = createValueLabel(QString(), QStringLiteral("DownloadRecordValue"), kDateColumnWidth,
Qt::AlignCenter, widgets.row);
widgets.finishedLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.finishedLabel);
widgets.progressLabel = new ElidedLabel(widgets.row);
widgets.progressLabel->setObjectName("DownloadRecordProgress");
widgets.progressLabel->setAlignment(Qt::AlignCenter);
widgets.progressLabel->setFixedWidth(kProgressColumnWidth);
widgets.progressLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
rowLayout->addWidget(widgets.progressLabel);
widgets.actionsContainer = new QWidget(widgets.row);
widgets.actionsContainer->setObjectName("DownloadActions");
widgets.actionsContainer->setFixedWidth(kActionAreaWidth);
auto *actionsLayout = new QHBoxLayout(widgets.actionsContainer);
actionsLayout->setContentsMargins(0, 0, 0, 0);
actionsLayout->setSpacing(kActionButtonsSpacing);
widgets.primaryButton = createIconButton(QIcon(), QString(), QStringLiteral("accent"), widgets.actionsContainer);
actionsLayout->addWidget(widgets.primaryButton);
widgets.stopButton =
createIconButton(QIcon(), tr("Stop Download"), QStringLiteral("danger"), widgets.actionsContainer);
actionsLayout->addWidget(widgets.stopButton);
widgets.folderButton =
createIconButton(QIcon(), tr("Open Folder"), QStringLiteral("default"), widgets.actionsContainer);
actionsLayout->addWidget(widgets.folderButton);
widgets.deleteButton =
createIconButton(QIcon(), tr("Delete Record"), QStringLiteral("danger"), widgets.actionsContainer);
actionsLayout->addWidget(widgets.deleteButton);
rowLayout->addWidget(widgets.actionsContainer);
return widgets;
}
void DownloadManagerDialog::updateRecordRow(const QString &recordId, const DownloadManager::DownloadRecord &record,
int index)
{
auto it = m_recordRows.find(recordId);
if (it == m_recordRows.end())
{
return;
}
RecordRowWidgets &widgets = it.value();
const QIcon openIcon =
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/quick-play.svg"), accentIconColor());
const QIcon pauseIcon =
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/click-pause.svg"), accentIconColor());
const QIcon resumeIcon =
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/task-run.svg"), accentIconColor());
const QIcon stopIcon =
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/task-stop.svg"), dangerIconColor());
const QIcon folderIcon = ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/folder.svg"));
const QIcon deleteIcon = ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/delete.svg"), dangerIconColor());
const QFileInfo fileInfo(record.filePath);
const bool canOpenFile = record.isCompleted && fileInfo.exists() && fileInfo.isFile();
const bool canOpenFolder = !record.filePath.trimmed().isEmpty();
const bool canPauseDownload = record.isActive && record.statusText != DownloadManager::tr("Cancelling") &&
record.statusText != DownloadManager::tr("Pausing");
const bool canStopDownload =
(record.isActive && record.statusText != DownloadManager::tr("Cancelling")) || record.isPaused;
const QString progressText = formatProgress(record);
widgets.row->setProperty("recordId", record.id);
widgets.row->setCursor(canOpenFile ? Qt::PointingHandCursor : Qt::ArrowCursor);
widgets.indexLabel->setText(QString::number(index));
widgets.indexLabel->setToolTip(QString::number(index));
widgets.titleLabel->setFullText(record.itemName);
widgets.sizeLabel->setText(formatSize(record));
widgets.sizeLabel->setToolTip(formatSize(record));
widgets.startedLabel->setText(formatDateTime(record.createdAtMs));
widgets.startedLabel->setToolTip(formatDateTime(record.createdAtMs));
widgets.finishedLabel->setText(formatDateTime(record.finishedAtMs));
widgets.finishedLabel->setToolTip(formatDateTime(record.finishedAtMs));
widgets.progressLabel->setFullText(progressText);
widgets.progressLabel->setProperty("tone", progressTone(record));
widgets.progressLabel->style()->unpolish(widgets.progressLabel);
widgets.progressLabel->style()->polish(widgets.progressLabel);
widgets.progressLabel->update();
if (record.isActive)
{
widgets.primaryButton->setIcon(pauseIcon);
widgets.primaryButton->setToolTip(tr("Pause Download"));
widgets.primaryButton->setEnabled(canPauseDownload);
setButtonTone(widgets.primaryButton, QStringLiteral("accent"));
}
else if (record.isPaused)
{
widgets.primaryButton->setIcon(resumeIcon);
widgets.primaryButton->setToolTip(tr("Resume Download"));
widgets.primaryButton->setEnabled(true);
setButtonTone(widgets.primaryButton, QStringLiteral("accent"));
}
else
{
widgets.primaryButton->setIcon(openIcon);
widgets.primaryButton->setToolTip(tr("Open Media"));
widgets.primaryButton->setEnabled(canOpenFile);
setButtonTone(widgets.primaryButton, QStringLiteral("accent"));
}
widgets.primaryButton->setIconSize(QSize(16, 16));
widgets.stopButton->setEnabled(canStopDownload);
widgets.stopButton->setIcon(stopIcon);
widgets.stopButton->setIconSize(QSize(16, 16));
widgets.stopButton->setToolTip(tr("Stop Download"));
widgets.folderButton->setEnabled(canOpenFolder);
widgets.folderButton->setIcon(folderIcon);
widgets.folderButton->setIconSize(QSize(16, 16));
widgets.folderButton->setToolTip(tr("Open Folder"));
widgets.deleteButton->setEnabled(!(record.isActive && record.statusText == DownloadManager::tr("Cancelling")));
widgets.deleteButton->setIcon(deleteIcon);
widgets.deleteButton->setIconSize(QSize(16, 16));
widgets.deleteButton->setToolTip(tr("Delete Record"));
widgets.row->setToolTip(QString());
widgets.primaryButton->disconnect(this);
connect(widgets.primaryButton, &QPushButton::clicked, this,
[this, record]()
{
if (record.isActive)
{
DownloadManager::instance()->pauseDownload(record.id);
return;
}
if (record.isPaused)
{
DownloadManager::instance()->resumeDownload(record.id);
return;
}
if (!DownloadManager::instance()->openDownloadedFile(record.id))
{
ModernMessageBox::warning(this, tr("Open Media"),
tr("The downloaded media file is no longer available."));
}
});
widgets.folderButton->disconnect(this);
connect(widgets.folderButton, &QPushButton::clicked, this,
[this, record]()
{
if (!DownloadManager::instance()->openContainingFolder(record.id))
{
ModernMessageBox::warning(this, tr("Open Folder"),
tr("The download folder is no longer available."));
}
});
widgets.stopButton->disconnect(this);
connect(widgets.stopButton, &QPushButton::clicked, this, [this, record]() { handleStopRecord(record); });
widgets.deleteButton->disconnect(this);
connect(widgets.deleteButton, &QPushButton::clicked, this, [this, record]() { handleDeleteRecord(record); });
}
void DownloadManagerDialog::rebuildRecordLayout(const QStringList &orderedIds)
{
while (QLayoutItem *item = m_recordsLayout->takeAt(0))
{
delete item;
}
for (const QString &recordId : orderedIds)
{
const auto it = m_recordRows.constFind(recordId);
if (it == m_recordRows.cend() || !it.value().row)
{
continue;
}
m_recordsLayout->addWidget(it.value().row);
}
m_recordsLayout->addStretch(1);
m_rowOrder = orderedIds;
}
void DownloadManagerDialog::removeRecordRow(const QString &recordId)
{
auto it = m_recordRows.find(recordId);
if (it == m_recordRows.end())
{
return;
}
if (it.value().row)
{
it.value().row->deleteLater();
}
m_recordRows.erase(it);
m_rowOrder.removeAll(recordId);
}
void DownloadManagerDialog::handleStopRecord(const DownloadManager::DownloadRecord &record)
{
if (!record.isActive && !record.isPaused)
{
return;
}
const bool confirmed =
ModernMessageBox::question(this, tr("Stop Download"), tr("Stop and remove the unfinished file"), tr("Stop"),
tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning);
if (!confirmed)
{
return;
}
DownloadManager::instance()->stopDownload(record.id);
}
bool DownloadManagerDialog::eventFilter(QObject *watched, QEvent *event)
{
if (event && event->type() == QEvent::MouseButtonDblClick)
{
if (auto *widget = qobject_cast<QWidget *>(watched))
{
const QString recordId = widget->property("recordId").toString();
if (!recordId.trimmed().isEmpty())
{
const auto record = DownloadManager::instance()->recordById(recordId);
if (record && record->isCompleted && !DownloadManager::instance()->openDownloadedFile(recordId))
{
ModernMessageBox::warning(this, tr("Open Media"),
tr("The downloaded media file is no longer available."));
}
return true;
}
}
}
return ModernDialogBase::eventFilter(watched, event);
}
void DownloadManagerDialog::handleDeleteRecord(const DownloadManager::DownloadRecord &record)
{
if (record.isActive)
{
const bool confirmed = ModernMessageBox::question(
this, tr("Delete Record"),
tr("Cancel active download for \"%1\" and remove the unfinished file?").arg(record.itemName), tr("Delete"),
tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning);
if (!confirmed)
{
return;
}
DownloadManager::instance()->cancelDownload(record.id, true, false);
return;
}
const bool hasLocalFile = !record.filePath.trimmed().isEmpty() && QFileInfo::exists(record.filePath);
bool deleteFileWithRecord = ConfigStore::instance()->get<bool>(ConfigKeys::DownloadDeleteFileWithRecord, false);
const bool confirmed =
hasLocalFile
? ModernMessageBox::questionWithToggle(this, tr("Delete Record"),
tr("Delete download record for \"%1\"?").arg(record.itemName),
tr("Also Delete File"), &deleteFileWithRecord, tr("Delete"),
tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning)
: ModernMessageBox::question(this, tr("Delete Record"),
tr("Delete download record for \"%1\"?").arg(record.itemName), tr("Delete"),
tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning);
if (hasLocalFile)
{
ConfigStore::instance()->set(ConfigKeys::DownloadDeleteFileWithRecord, deleteFileWithRecord);
}
if (!confirmed)
{
return;
}
const bool deleteFile = hasLocalFile && deleteFileWithRecord;
DownloadManager::instance()->removeHistoryRecord(record.id, deleteFile);
}

View File

@@ -0,0 +1,79 @@
#ifndef DOWNLOADMANAGERDIALOG_H
#define DOWNLOADMANAGERDIALOG_H
#include <QHash>
#include <QMetaObject>
#include <QStringList>
#include "moderndialogbase.h"
#include "../managers/downloadmanager.h"
class QEmbyCore;
class ElidedLabel;
class QFrame;
class QLabel;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class QWidget;
class DownloadManagerDialog : public ModernDialogBase
{
Q_OBJECT
public:
static void showManager(QEmbyCore* core, QWidget* parent = nullptr);
explicit DownloadManagerDialog(QEmbyCore* core, QWidget* parent = nullptr);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private:
void bindServerSignals();
struct RecordRowWidgets {
QFrame* row = nullptr;
QLabel* indexLabel = nullptr;
ElidedLabel* titleLabel = nullptr;
QLabel* sizeLabel = nullptr;
QLabel* startedLabel = nullptr;
QLabel* finishedLabel = nullptr;
ElidedLabel* progressLabel = nullptr;
QWidget* actionsContainer = nullptr;
QPushButton* primaryButton = nullptr;
QPushButton* stopButton = nullptr;
QPushButton* folderButton = nullptr;
QPushButton* deleteButton = nullptr;
};
void applyIcons();
void refreshUi();
void rebuildRecordList(const QList<DownloadManager::DownloadRecord>& records);
RecordRowWidgets createRecordRow();
void updateRecordRow(const QString& recordId,
const DownloadManager::DownloadRecord& record,
int index);
void rebuildRecordLayout(const QStringList& orderedIds);
void removeRecordRow(const QString& recordId);
void handleStopRecord(const DownloadManager::DownloadRecord& record);
void handleDeleteRecord(const DownloadManager::DownloadRecord& record);
QEmbyCore* m_core = nullptr;
QLabel* m_summaryLabel = nullptr;
QWidget* m_recordsHeader = nullptr;
QScrollArea* m_recordsScrollArea = nullptr;
QWidget* m_recordsContainer = nullptr;
QVBoxLayout* m_recordsLayout = nullptr;
QWidget* m_emptyState = nullptr;
QLabel* m_emptyIconLabel = nullptr;
QLabel* m_emptyTitleLabel = nullptr;
QLabel* m_emptyHintLabel = nullptr;
QLabel* m_locationValueLabel = nullptr;
QPushButton* m_openDirectoryButton = nullptr;
QPushButton* m_changeDirectoryButton = nullptr;
QMetaObject::Connection m_activeServerChangedConnection;
QHash<QString, RecordRowWidgets> m_recordRows;
QStringList m_rowOrder;
};
#endif

View File

@@ -0,0 +1,122 @@
#include "editablevaluelabel.h"
#include <QEvent>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QStackedLayout>
EditableValueLabel::EditableValueLabel(QString textObjectName, QWidget *parent)
: QWidget(parent)
{
m_layout = new QStackedLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(0);
m_layout->setStackingMode(QStackedLayout::StackOne);
m_label = new QLabel(this);
m_label->setObjectName(textObjectName);
m_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_label->setCursor(Qt::IBeamCursor);
m_label->installEventFilter(this);
m_layout->addWidget(m_label);
m_editor = new QLineEdit(this);
m_editor->setObjectName(textObjectName);
m_editor->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_editor->setFrame(false);
m_editor->installEventFilter(this);
m_layout->addWidget(m_editor);
m_layout->setCurrentWidget(m_label);
connect(m_editor, &QLineEdit::editingFinished, this, [this]() {
if (m_isEditing) {
finishEditing(true);
}
});
}
QString EditableValueLabel::text() const
{
return m_label ? m_label->text() : QString();
}
bool EditableValueLabel::isEditing() const
{
return m_isEditing;
}
void EditableValueLabel::setText(const QString &text)
{
if (!m_label) {
return;
}
m_label->setText(text);
if (!m_isEditing && m_editor) {
m_editor->setText(text);
}
}
void EditableValueLabel::setAlignment(Qt::Alignment alignment)
{
if (m_label) {
m_label->setAlignment(alignment);
}
if (m_editor) {
m_editor->setAlignment(alignment);
}
}
bool EditableValueLabel::eventFilter(QObject *watched, QEvent *event)
{
if (watched == m_label && event->type() == QEvent::MouseButtonDblClick) {
beginEditing();
return true;
}
if (watched == m_editor && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Return ||
keyEvent->key() == Qt::Key_Enter) {
finishEditing(true);
return true;
}
if (keyEvent->key() == Qt::Key_Escape) {
finishEditing(false);
return true;
}
}
return QWidget::eventFilter(watched, event);
}
void EditableValueLabel::beginEditing()
{
if (!m_label || !m_editor || m_isEditing) {
return;
}
m_isEditing = true;
m_editor->setText(m_label->text());
m_layout->setCurrentWidget(m_editor);
m_editor->setFocus();
m_editor->selectAll();
}
void EditableValueLabel::finishEditing(bool accept)
{
if (!m_label || !m_editor || !m_isEditing) {
return;
}
const QString submittedText = m_editor->text().trimmed();
m_isEditing = false;
m_layout->setCurrentWidget(m_label);
if (accept) {
emit textSubmitted(submittedText);
} else {
m_editor->setText(m_label->text());
}
}

View File

@@ -0,0 +1,41 @@
#ifndef EDITABLEVALUELABEL_H
#define EDITABLEVALUELABEL_H
#include <QString>
#include <QWidget>
class QLabel;
class QLineEdit;
class QStackedLayout;
class EditableValueLabel : public QWidget
{
Q_OBJECT
public:
explicit EditableValueLabel(QString textObjectName,
QWidget *parent = nullptr);
QString text() const;
bool isEditing() const;
void setText(const QString &text);
void setAlignment(Qt::Alignment alignment);
signals:
void textSubmitted(const QString &text);
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void beginEditing();
void finishEditing(bool accept);
QLabel *m_label = nullptr;
QLineEdit *m_editor = nullptr;
QStackedLayout *m_layout = nullptr;
bool m_isEditing = false;
};
#endif

View File

@@ -0,0 +1,49 @@
#include "elidedlabel.h"
#include <QFontMetrics>
ElidedLabel::ElidedLabel(QWidget* parent) : QLabel(parent)
{
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
}
void ElidedLabel::setFullText(const QString& text)
{
m_fullText = text;
setToolTip(text);
updateElidedText();
}
void ElidedLabel::resizeEvent(QResizeEvent* event)
{
QLabel::resizeEvent(event);
updateElidedText();
}
QSize ElidedLabel::sizeHint() const
{
return minimumSizeHint();
}
QSize ElidedLabel::minimumSizeHint() const
{
QFontMetrics fm(font());
return QSize(fm.horizontalAdvance("...") + 2, QLabel::minimumSizeHint().height());
}
void ElidedLabel::updateElidedText()
{
if (m_fullText.isEmpty()) {
if (text() != "") setText("");
return;
}
QFontMetrics fm(font());
QString elided = fm.elidedText(m_fullText, Qt::ElideRight, width() - 2);
if (text() != elided) {
setText(elided);
}
}

View File

@@ -0,0 +1,30 @@
#ifndef ELIDEDLABEL_H
#define ELIDEDLABEL_H
#include <QLabel>
#include <QResizeEvent>
#include <QString>
class ElidedLabel : public QLabel
{
Q_OBJECT
public:
explicit ElidedLabel(QWidget* parent = nullptr);
void setFullText(const QString& text);
QString fullText() const { return m_fullText; }
protected:
void resizeEvent(QResizeEvent* event) override;
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
void updateElidedText();
QString m_fullText;
};
#endif

View File

@@ -0,0 +1,300 @@
#include "embycodecinfodialog.h"
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QScrollArea>
#include <QStringList>
#include <QVBoxLayout>
#include <utility>
namespace {
QWidget* createSummaryRow(const QString& title, const QString& value, QWidget* parent) {
auto* row = new QWidget(parent);
auto* layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
auto* titleLabel = new QLabel(title, row);
titleLabel->setObjectName("ManageTranscodingFieldTitle");
titleLabel->setWordWrap(true);
titleLabel->setMinimumWidth(118);
layout->addWidget(titleLabel, 0, Qt::AlignTop);
auto* valueLabel = new QLabel(value, row);
valueLabel->setObjectName("ManageInfoValue");
valueLabel->setWordWrap(true);
valueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
layout->addWidget(valueLabel, 1, Qt::AlignTop);
return row;
}
QLabel* createTableLabel(const QString& text,
const char* objectName,
QWidget* parent,
bool header = false) {
auto* label = new QLabel(text, parent);
label->setObjectName(objectName);
label->setWordWrap(true);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setAlignment(Qt::AlignLeft | Qt::AlignTop);
if (header) {
label->setMinimumWidth(72);
}
return label;
}
}
EmbyCodecInfoDialog::EmbyCodecInfoDialog(QJsonObject codecInfo, QWidget* parent)
: ModernDialogBase(parent), m_codecInfo(std::move(codecInfo)) {
setTitle(codecTitle());
setMinimumWidth(700);
resize(760, 560);
setupUi();
}
void EmbyCodecInfoDialog::setupUi() {
contentLayout()->setContentsMargins(20, 10, 0, 20);
contentLayout()->setSpacing(12);
auto* scrollArea = new QScrollArea(this);
scrollArea->setObjectName("SettingsScrollArea");
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->viewport()->setAutoFillBackground(false);
contentLayout()->addWidget(scrollArea, 1);
auto* page = new QWidget(scrollArea);
auto* pageLayout = new QVBoxLayout(page);
pageLayout->setContentsMargins(0, 0, 12, 0);
pageLayout->setSpacing(18);
scrollArea->setWidget(page);
const QString description = codecDescription();
if (!description.isEmpty()) {
auto* descriptionLabel = new QLabel(description, page);
descriptionLabel->setObjectName("ManageInfoValue");
descriptionLabel->setWordWrap(true);
descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
pageLayout->addWidget(descriptionLabel);
}
struct SummaryItem {
QString title;
QString value;
};
const QList<SummaryItem> summaryItems = {
{tr("Max Bitrate:"), maxBitrateText()},
{tr("Frame Sizes:"), frameSizesText()},
{tr("Frame Rates:"), frameRatesText()},
{tr("Max Instances:"), maxInstancesText()},
{tr("Color Formats:"), colorFormatsText()}};
auto* summaryContainer = new QWidget(page);
auto* summaryLayout = new QVBoxLayout(summaryContainer);
summaryLayout->setContentsMargins(0, 0, 0, 0);
summaryLayout->setSpacing(10);
bool hasSummaryRows = false;
for (const SummaryItem& item : summaryItems) {
if (item.value.trimmed().isEmpty()) {
continue;
}
hasSummaryRows = true;
summaryLayout->addWidget(createSummaryRow(item.title, item.value, summaryContainer));
}
if (hasSummaryRows) {
pageLayout->addWidget(summaryContainer);
} else {
summaryContainer->deleteLater();
}
const QJsonArray profileRows =
m_codecInfo.value(QStringLiteral("ProfileAndLevelInformation")).toArray();
if (!profileRows.isEmpty()) {
auto* tableContainer = new QWidget(page);
auto* tableLayout = new QGridLayout(tableContainer);
tableLayout->setContentsMargins(0, 0, 0, 0);
tableLayout->setHorizontalSpacing(18);
tableLayout->setVerticalSpacing(12);
tableLayout->setColumnStretch(4, 1);
const QList<QString> headers = {tr("Profile"),
tr("Max Level"),
tr("Max Bitrate"),
tr("Bit Depths"),
tr("Resolutions")};
for (int column = 0; column < headers.size(); ++column) {
auto* label =
createTableLabel(headers.at(column), "ManageTranscodingFieldTitle",
tableContainer, true);
tableLayout->addWidget(label, 0, column);
}
int rowIndex = 1;
for (const QJsonValue& rowValue : profileRows) {
const QJsonObject rowObject = rowValue.toObject();
const QJsonObject profile = rowObject.value(QStringLiteral("Profile")).toObject();
const QJsonObject level = rowObject.value(QStringLiteral("Level")).toObject();
const QList<QString> cells = {profileDescription(profile),
levelDescription(level),
levelBitrate(level),
bitDepthsText(profile),
levelResolutions(level)};
for (int column = 0; column < cells.size(); ++column) {
auto* label =
createTableLabel(cells.at(column), "ManageInfoValue", tableContainer);
tableLayout->addWidget(label, rowIndex, column);
}
++rowIndex;
}
pageLayout->addWidget(tableContainer);
}
pageLayout->addStretch(1);
}
QString EmbyCodecInfoDialog::codecTitle() const {
const QString title = m_codecInfo.value(QStringLiteral("Name")).toString().trimmed();
if (!title.isEmpty()) {
return title;
}
return m_codecInfo.value(QStringLiteral("MediaTypeName")).toString().trimmed();
}
QString EmbyCodecInfoDialog::codecDescription() const {
const QString description =
m_codecInfo.value(QStringLiteral("Description")).toString().trimmed();
if (!description.isEmpty()) {
return description;
}
const QJsonObject deviceInfo = m_codecInfo.value(QStringLiteral("CodecDeviceInfo")).toObject();
const QString fallbackDescription =
deviceInfo.value(QStringLiteral("Desription")).toString().trimmed();
if (!fallbackDescription.isEmpty()) {
return fallbackDescription;
}
return deviceInfo.value(QStringLiteral("Description")).toString().trimmed();
}
QString EmbyCodecInfoDialog::maxBitrateText() const {
return m_codecInfo.value(QStringLiteral("MaxBitRate")).toString().trimmed();
}
QString EmbyCodecInfoDialog::frameSizesText() const {
const int minWidth = m_codecInfo.value(QStringLiteral("MinWidth")).toInt(0);
const int minHeight = m_codecInfo.value(QStringLiteral("MinHeight")).toInt(0);
const int maxWidth = m_codecInfo.value(QStringLiteral("MaxWidth")).toInt(0);
const int maxHeight = m_codecInfo.value(QStringLiteral("MaxHeight")).toInt(0);
if (minWidth > 0 && minHeight > 0 && maxWidth > 0 && maxHeight > 0) {
return QStringLiteral("%1x%2...%3x%4")
.arg(minWidth)
.arg(minHeight)
.arg(maxWidth)
.arg(maxHeight);
}
if (maxWidth > 0 && maxHeight > 0) {
return tr("max %1x%2").arg(maxWidth).arg(maxHeight);
}
if (minWidth > 0 && minHeight > 0) {
return QStringLiteral("%1x%2").arg(minWidth).arg(minHeight);
}
return {};
}
QString EmbyCodecInfoDialog::frameRatesText() const {
const int minFrameRate = m_codecInfo.value(QStringLiteral("MinFrameRate")).toInt(0);
const int maxFrameRate = m_codecInfo.value(QStringLiteral("MaxFrameRate")).toInt(0);
if (minFrameRate > 0 && maxFrameRate > 0) {
return tr("%1fps...%2fps").arg(minFrameRate).arg(maxFrameRate);
}
if (maxFrameRate > 0) {
return tr("max %1fps").arg(maxFrameRate);
}
if (minFrameRate > 0) {
return tr("%1fps").arg(minFrameRate);
}
return {};
}
QString EmbyCodecInfoDialog::maxInstancesText() const {
const int maxInstances = m_codecInfo.value(QStringLiteral("MaxInstanceCount")).toInt(0);
return maxInstances > 0 ? QString::number(maxInstances) : QString();
}
QString EmbyCodecInfoDialog::colorFormatsText() const {
QStringList formats;
for (const QJsonValue& value :
m_codecInfo.value(QStringLiteral("SupportedColorFormatStrings")).toArray()) {
const QString text = value.toString().trimmed();
if (!text.isEmpty()) {
formats.append(text);
}
}
return formats.join(QStringLiteral(", "));
}
QString EmbyCodecInfoDialog::profileDescription(const QJsonObject& profile) const {
QString description = profile.value(QStringLiteral("Description")).toString().trimmed();
description.replace(QStringLiteral(" Profile"), QString());
if (!description.isEmpty()) {
return description;
}
return profile.value(QStringLiteral("ShortName")).toString().trimmed();
}
QString EmbyCodecInfoDialog::levelDescription(const QJsonObject& level) const {
const QString description = level.value(QStringLiteral("Description")).toString().trimmed();
if (!description.isEmpty()) {
return description;
}
return level.value(QStringLiteral("ShortName")).toString().trimmed();
}
QString EmbyCodecInfoDialog::levelBitrate(const QJsonObject& level) const {
const QString display = level.value(QStringLiteral("MaxBitRateDisplay")).toString().trimmed();
if (!display.isEmpty()) {
return display;
}
return level.value(QStringLiteral("MaxBitRate")).toString().trimmed();
}
QString EmbyCodecInfoDialog::levelResolutions(const QJsonObject& level) const {
QStringList values;
for (const QJsonValue& value : level.value(QStringLiteral("ResolutionRateStrings")).toArray()) {
const QString text = value.toString().trimmed();
if (!text.isEmpty()) {
values.append(text);
}
}
if (values.isEmpty()) {
return level.value(QStringLiteral("ResolutionRatesDisplay")).toString().trimmed();
}
if (values.size() == 1) {
return values.constFirst();
}
return QStringLiteral("%1 - %2").arg(values.constFirst(), values.constLast());
}
QString EmbyCodecInfoDialog::bitDepthsText(const QJsonObject& profile) const {
QStringList values;
for (const QJsonValue& value : profile.value(QStringLiteral("BitDepths")).toArray()) {
if (value.isDouble()) {
values.append(QString::number(value.toInt()));
}
}
return values.join(QStringLiteral(", "));
}

View File

@@ -0,0 +1,30 @@
#ifndef EMBYCODECINFODIALOG_H
#define EMBYCODECINFODIALOG_H
#include "moderndialogbase.h"
#include <QJsonObject>
class EmbyCodecInfoDialog : public ModernDialogBase {
Q_OBJECT
public:
explicit EmbyCodecInfoDialog(QJsonObject codecInfo, QWidget* parent = nullptr);
private:
void setupUi();
QString codecTitle() const;
QString codecDescription() const;
QString maxBitrateText() const;
QString frameSizesText() const;
QString frameRatesText() const;
QString maxInstancesText() const;
QString colorFormatsText() const;
QString profileDescription(const QJsonObject& profile) const;
QString levelDescription(const QJsonObject& level) const;
QString levelBitrate(const QJsonObject& level) const;
QString levelResolutions(const QJsonObject& level) const;
QString bitDepthsText(const QJsonObject& profile) const;
QJsonObject m_codecInfo;
};
#endif

View File

@@ -0,0 +1,797 @@
#include "embycodecparametersdialog.h"
#include "../utils/layoututils.h"
#include "moderncombobox.h"
#include "moderntoast.h"
#include "modernswitch.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QDebug>
#include <QDoubleValidator>
#include <QFrame>
#include <QHBoxLayout>
#include <QIntValidator>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QSizePolicy>
#include <QStringList>
#include <QVBoxLayout>
#include <functional>
#include <utility>
namespace {
QString normalizePathElement(QString element) {
return element.replace(QStringLiteral("colitem"), QString(), Qt::CaseInsensitive);
}
QStringList splitPath(const QString& path) {
QStringList parts;
for (const QString& rawPart : path.split(QLatin1Char('.'), Qt::SkipEmptyParts)) {
const QString part = normalizePathElement(rawPart.trimmed());
if (!part.isEmpty()) {
parts.append(part);
}
}
return parts;
}
bool containsJsonPath(const QJsonObject& object, const QString& path) {
QJsonValue current(object);
const QStringList parts = splitPath(path);
if (parts.isEmpty()) {
return false;
}
for (const QString& part : parts) {
if (!current.isObject()) {
return false;
}
const QJsonObject currentObject = current.toObject();
if (!currentObject.contains(part)) {
return false;
}
current = currentObject.value(part);
}
return true;
}
QJsonValue jsonValueAtPath(const QJsonObject& object, const QString& path) {
QJsonValue current(object);
const QStringList parts = splitPath(path);
if (parts.isEmpty()) {
return {};
}
for (const QString& part : parts) {
if (!current.isObject()) {
return {};
}
current = current.toObject().value(part);
}
return current;
}
void setJsonValueAtPath(QJsonObject& object, const QString& path, const QJsonValue& value) {
const QStringList parts = splitPath(path);
if (parts.isEmpty()) {
return;
}
std::function<void(QJsonObject&, int)> applyValue = [&](QJsonObject& currentObject,
int depth) {
const QString& key = parts.at(depth);
if (depth == parts.size() - 1) {
currentObject.insert(key, value);
return;
}
QJsonObject childObject = currentObject.value(key).toObject();
applyValue(childObject, depth + 1);
currentObject.insert(key, childObject);
};
applyValue(object, 0);
}
QStringList optionFilterValues(const QJsonObject& object, const QString& sourceId) {
QStringList values;
if (sourceId.trimmed().isEmpty()) {
return values;
}
const QJsonValue sourceValue = jsonValueAtPath(object, sourceId);
if (!sourceValue.isArray()) {
return values;
}
for (const QJsonValue& entry : sourceValue.toArray()) {
QString text;
if (entry.isString()) {
text = entry.toString().trimmed();
} else if (entry.isObject()) {
text = entry.toObject().value(QStringLiteral("Value")).toString().trimmed();
}
if (!text.isEmpty() && !values.contains(text)) {
values.append(text);
}
}
return values;
}
QWidget* createSectionCard(const QString& title,
QVBoxLayout*& bodyLayout,
QWidget* parent) {
auto* card = new QFrame(parent);
card->setObjectName("TranscodingSectionCard");
card->setAttribute(Qt::WA_StyledBackground, true);
auto* layout = new QVBoxLayout(card);
layout->setContentsMargins(18, 16, 18, 16);
layout->setSpacing(12);
auto* titleLabel = new QLabel(title, card);
titleLabel->setObjectName("TranscodingSectionTitle");
layout->addWidget(titleLabel);
bodyLayout = new QVBoxLayout();
bodyLayout->setContentsMargins(0, 0, 0, 0);
bodyLayout->setSpacing(8);
layout->addLayout(bodyLayout);
return card;
}
QWidget* createFieldBlock(const QString& title,
const QString& description,
QWidget* control,
QWidget* parent,
bool controlOwnRow = false) {
auto* block = new QFrame(parent);
block->setObjectName("TranscodingFieldBlock");
block->setAttribute(Qt::WA_StyledBackground, true);
const bool hasDescription = !description.trimmed().isEmpty();
if (controlOwnRow) {
auto* layout = new QVBoxLayout(block);
layout->setContentsMargins(14, 11, 14, 11);
layout->setSpacing(8);
auto* titleLabel = new QLabel(title, block);
titleLabel->setObjectName("TranscodingFieldTitle");
titleLabel->setWordWrap(true);
layout->addWidget(titleLabel);
if (hasDescription) {
auto* descriptionLabel = new QLabel(description, block);
descriptionLabel->setObjectName("TranscodingFieldDescription");
descriptionLabel->setWordWrap(true);
layout->addWidget(descriptionLabel);
}
if (control) {
control->setParent(block);
layout->addWidget(control);
}
return block;
}
auto* layout = new QHBoxLayout(block);
layout->setContentsMargins(14, 11, 14, 11);
layout->setSpacing(16);
auto* textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(hasDescription ? 4 : 0);
auto* titleLabel = new QLabel(title, block);
titleLabel->setObjectName("TranscodingFieldTitle");
titleLabel->setWordWrap(true);
textLayout->addWidget(titleLabel);
if (hasDescription) {
auto* descriptionLabel = new QLabel(description, block);
descriptionLabel->setObjectName("TranscodingFieldDescription");
descriptionLabel->setWordWrap(true);
textLayout->addWidget(descriptionLabel);
}
layout->addLayout(textLayout, 1);
if (control) {
control->setParent(block);
layout->addWidget(control, 0, hasDescription ? Qt::AlignTop : Qt::AlignVCenter);
}
return block;
}
QWidget* createSwitchBlock(const QString& title,
const QString& description,
ModernSwitch* control,
QWidget* parent) {
auto* block = new QFrame(parent);
block->setObjectName("TranscodingFieldBlock");
block->setAttribute(Qt::WA_StyledBackground, true);
auto* layout = new QHBoxLayout(block);
layout->setContentsMargins(14, 11, 14, 11);
layout->setSpacing(12);
auto* textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(4);
auto* titleLabel = new QLabel(title, block);
titleLabel->setObjectName("TranscodingFieldTitle");
titleLabel->setWordWrap(true);
textLayout->addWidget(titleLabel);
if (!description.trimmed().isEmpty()) {
auto* descriptionLabel = new QLabel(description, block);
descriptionLabel->setObjectName("TranscodingFieldDescription");
descriptionLabel->setWordWrap(true);
textLayout->addWidget(descriptionLabel);
}
layout->addLayout(textLayout, 1);
if (control) {
control->setParent(block);
layout->addWidget(control, 0, Qt::AlignVCenter);
}
return block;
}
}
EmbyCodecParametersDialog::EmbyCodecParametersDialog(QEmbyCore* core,
QJsonObject codecInfo,
QWidget* parent)
: ModernDialogBase(parent), m_core(core), m_codecInfo(std::move(codecInfo)) {
m_codecId = m_codecInfo.value(QStringLiteral("Id")).toString().trimmed();
const QString title = codecTitle();
setTitle(title.isEmpty() ? tr("Codec Settings") : title);
setMinimumWidth(660);
setMaximumHeight(760);
resize(760, 640);
setupUi();
refreshUiState();
m_pendingTask = loadParameters();
}
void EmbyCodecParametersDialog::setupUi() {
contentLayout()->setContentsMargins(20, 10, 0, 18);
contentLayout()->setSpacing(10);
m_summaryLabel = new QLabel(this);
m_summaryLabel->setObjectName("TranscodingSectionSubtitle");
m_summaryLabel->setWordWrap(true);
const QString summary = codecSummary();
m_summaryLabel->setText(summary);
m_summaryLabel->setVisible(!summary.isEmpty());
contentLayout()->addWidget(m_summaryLabel);
m_feedbackLabel = new QLabel(tr("Loading codec parameters..."), this);
m_feedbackLabel->setObjectName("ManageEmptyLabel");
m_feedbackLabel->setAlignment(Qt::AlignCenter);
m_feedbackLabel->setWordWrap(true);
contentLayout()->addWidget(m_feedbackLabel);
m_formPage = new QWidget(this);
m_formLayout = new QVBoxLayout(m_formPage);
m_formLayout->setContentsMargins(0, 0, 12, 4);
m_formLayout->setSpacing(10);
m_basicCard = createSectionCard(tr("Basic Parameters"), m_basicBody, m_formPage);
m_formLayout->addWidget(m_basicCard);
m_advancedCard =
createSectionCard(tr("Advanced Parameters"), m_advancedBody, m_formPage);
m_formLayout->addWidget(m_advancedCard);
m_formLayout->addStretch(1);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setObjectName("SettingsScrollArea");
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setWidget(m_formPage);
contentLayout()->addWidget(m_scrollArea, 1);
auto* buttonLayout = new QHBoxLayout();
buttonLayout->setContentsMargins(0, 4, 12, 0);
buttonLayout->setSpacing(10);
buttonLayout->addStretch(1);
m_btnCancel = new QPushButton(tr("Cancel"), this);
m_btnCancel->setObjectName("dialog-btn-cancel");
m_btnCancel->setCursor(Qt::PointingHandCursor);
buttonLayout->addWidget(m_btnCancel);
m_btnReset = new QPushButton(tr("Reset to Defaults"), this);
m_btnReset->setObjectName("SettingsCardButton");
m_btnReset->setCursor(Qt::PointingHandCursor);
buttonLayout->addWidget(m_btnReset);
m_btnSave = new QPushButton(tr("Save"), this);
m_btnSave->setObjectName("dialog-btn-primary");
m_btnSave->setCursor(Qt::PointingHandCursor);
buttonLayout->addWidget(m_btnSave);
contentLayout()->addLayout(buttonLayout);
connect(m_btnCancel, &QPushButton::clicked, this, &QDialog::reject);
connect(m_btnReset, &QPushButton::clicked, this,
[this]() { resetToDefaults(); });
connect(m_btnSave, &QPushButton::clicked, this,
[this]() { m_pendingTask = saveParameters(); });
}
void EmbyCodecParametersDialog::clearFieldBindings() {
if (m_basicBody) {
LayoutUtils::clearLayout(m_basicBody);
}
if (m_advancedBody) {
LayoutUtils::clearLayout(m_advancedBody);
}
m_fields.clear();
}
void EmbyCodecParametersDialog::refreshUiState() {
const QString summary = codecSummary();
if (m_summaryLabel) {
m_summaryLabel->setText(summary);
m_summaryLabel->setVisible(!summary.isEmpty());
}
const bool hasFields = !m_fields.isEmpty();
QString feedback = m_feedbackText;
if (m_isLoading) {
feedback = tr("Loading codec parameters...");
} else if (feedback.isEmpty() && !hasFields) {
feedback = tr("No editable parameters available for this codec.");
}
const bool showForm = !m_isLoading && m_feedbackText.isEmpty() && hasFields;
if (m_feedbackLabel) {
m_feedbackLabel->setText(feedback);
m_feedbackLabel->setVisible(!showForm);
}
if (m_scrollArea) {
m_scrollArea->setVisible(showForm);
}
if (m_basicCard && m_basicBody) {
m_basicCard->setVisible(showForm && m_basicBody->count() > 0);
}
if (m_advancedCard && m_advancedBody) {
m_advancedCard->setVisible(showForm && m_advancedBody->count() > 0);
}
if (m_btnCancel) {
m_btnCancel->setEnabled(!m_isSaving);
}
if (m_btnReset) {
m_btnReset->setEnabled(showForm && !m_isSaving);
}
if (m_btnSave) {
m_btnSave->setEnabled(showForm && !m_isSaving);
m_btnSave->setText(m_isSaving ? tr("Saving...") : tr("Save"));
}
}
QString EmbyCodecParametersDialog::codecTitle() const {
const bool isHardwareCodec =
m_codecInfo.value(QStringLiteral("IsHardwareCodec")).toBool(false);
const QString mediaTypeName =
m_codecInfo.value(QStringLiteral("MediaTypeName")).toString().trimmed();
const QString name = m_codecInfo.value(QStringLiteral("Name")).toString().trimmed();
if (!isHardwareCodec && !mediaTypeName.isEmpty()) {
return mediaTypeName;
}
if (!name.isEmpty()) {
return name;
}
return mediaTypeName.isEmpty() ? m_codecId : mediaTypeName;
}
QString EmbyCodecParametersDialog::codecSummary() const {
QStringList parts;
const QString title = codecTitle();
const QString name = m_codecInfo.value(QStringLiteral("Name")).toString().trimmed();
const QString description =
m_codecInfo.value(QStringLiteral("Description")).toString().trimmed();
if (!name.isEmpty() && name != title) {
parts.append(name);
}
if (!description.isEmpty() && !parts.contains(description)) {
parts.append(description);
}
return parts.join(QStringLiteral("\n"));
}
void EmbyCodecParametersDialog::appendEditorItems(const QJsonObject& item,
QList<QJsonObject>& items) const {
if (item.isEmpty()) {
return;
}
const QString editorType = item.value(QStringLiteral("EditorType")).toString().trimmed();
if (editorType.compare(QStringLiteral("Group"), Qt::CaseInsensitive) == 0) {
for (const QJsonValue& value : item.value(QStringLiteral("EditorItems")).toArray()) {
appendEditorItems(value.toObject(), items);
}
return;
}
items.append(item);
}
void EmbyCodecParametersDialog::setWidgetValue(const FieldBinding& field,
const QJsonValue& value) {
if (field.lineEdit) {
if (value.isUndefined() || value.isNull()) {
field.lineEdit->clear();
return;
}
if (value.isString()) {
field.lineEdit->setText(value.toString());
return;
}
if (value.isDouble()) {
if (field.editorType == QStringLiteral("Numeric") && field.decimals > 0) {
field.lineEdit->setText(
QString::number(value.toDouble(), 'f', field.decimals));
} else {
field.lineEdit->setText(QString::number(value.toDouble(), 'g', 15));
}
return;
}
field.lineEdit->setText(value.toVariant().toString());
return;
}
if (field.comboBox) {
if (!value.isUndefined() && !value.isNull()) {
const QVariant targetValue = value.toVariant();
int index = field.comboBox->findData(targetValue);
if (index < 0 && !targetValue.toString().isEmpty()) {
field.comboBox->addItem(targetValue.toString(), targetValue);
index = field.comboBox->count() - 1;
}
field.comboBox->setCurrentIndex(index);
return;
}
const int emptyIndex = field.comboBox->findData(QString());
field.comboBox->setCurrentIndex(emptyIndex >= 0 ? emptyIndex : -1);
return;
}
if (field.switchControl) {
field.switchControl->setChecked(value.toBool(false));
}
}
void EmbyCodecParametersDialog::rebuildForm() {
clearFieldBindings();
const QJsonObject editorRoot = m_editorContainer.value(QStringLiteral("EditorRoot")).toObject();
QList<QJsonObject> editorItems;
appendEditorItems(editorRoot, editorItems);
qDebug() << "[EmbyCodecParametersDialog] Rebuilding codec parameter form"
<< "| codecId=" << m_codecId
<< "| itemCount=" << editorItems.size();
for (const QJsonObject& item : std::as_const(editorItems)) {
const QString editorType =
item.value(QStringLiteral("EditorType")).toString().trimmed();
if (editorType != QStringLiteral("Text") &&
editorType != QStringLiteral("Numeric") &&
editorType != QStringLiteral("Boolean") &&
editorType != QStringLiteral("SelectSingle")) {
qDebug() << "[EmbyCodecParametersDialog] Skipping unsupported editor type"
<< "| codecId=" << m_codecId
<< "| editorType=" << editorType
<< "| id=" << item.value(QStringLiteral("Id")).toString();
continue;
}
FieldBinding field;
field.id = item.value(QStringLiteral("Id")).toString().trimmed();
field.editorType = editorType;
field.displayName =
item.value(QStringLiteral("DisplayName")).toString(field.id).trimmed();
field.description = item.value(QStringLiteral("Description")).toString().trimmed();
field.staticItemsSourceId =
item.value(QStringLiteral("StaticItemsSourceId")).toString().trimmed();
field.allowEmpty = item.value(QStringLiteral("AllowEmpty")).toBool(true);
field.readOnly = item.value(QStringLiteral("IsReadOnly")).toBool(false);
field.advanced = item.value(QStringLiteral("IsAdvanced")).toBool(false);
field.decimals = item.value(QStringLiteral("DecimalPlaces")).toInt(0);
field.hasMinimum = item.contains(QStringLiteral("MinValue"));
field.hasMaximum = item.contains(QStringLiteral("MaxValue"));
field.minimumValue = item.value(QStringLiteral("MinValue")).toInt(0);
field.maximumValue = item.value(QStringLiteral("MaxValue")).toInt(0);
if (field.id.isEmpty()) {
continue;
}
QVBoxLayout* targetBody = field.advanced ? m_advancedBody : m_basicBody;
QWidget* sectionParent = targetBody ? targetBody->parentWidget() : this;
if (!targetBody || !sectionParent) {
continue;
}
if (editorType == QStringLiteral("Boolean")) {
auto* control = new ModernSwitch(sectionParent);
control->setCursor(Qt::PointingHandCursor);
control->setEnabled(!field.readOnly);
field.switchControl = control;
field.container = createSwitchBlock(field.displayName,
field.description,
control,
sectionParent);
} else if (editorType == QStringLiteral("SelectSingle")) {
auto* control = LayoutUtils::createStyledCombo(sectionParent);
control->setMinimumWidth(260);
control->setMaxTextWidth(260);
control->setEnabled(!field.readOnly);
const bool hasOptionFilter =
!field.staticItemsSourceId.isEmpty() &&
jsonValueAtPath(m_parameterObject, field.staticItemsSourceId).isArray();
const QStringList allowedValues =
optionFilterValues(m_parameterObject, field.staticItemsSourceId);
for (const QJsonValue& optionValue :
item.value(QStringLiteral("SelectOptions")).toArray()) {
const QJsonObject option = optionValue.toObject();
const QString value = option.value(QStringLiteral("Value")).toString();
if (hasOptionFilter && !value.isEmpty() &&
!allowedValues.contains(value)) {
continue;
}
QString label = option.value(QStringLiteral("Name")).toString();
if (label.isEmpty()) {
label = value;
}
control->addItem(label, option.value(QStringLiteral("Value")).toVariant());
}
field.comboBox = control;
field.container =
createFieldBlock(field.displayName, field.description, control, sectionParent);
} else {
auto* control = new QLineEdit(sectionParent);
control->setObjectName("ManageLibInput");
control->setClearButtonEnabled(field.allowEmpty);
const bool isTextField = editorType == QStringLiteral("Text");
control->setMinimumWidth(isTextField ? 360 : 220);
control->setSizePolicy(isTextField ? QSizePolicy::MinimumExpanding
: QSizePolicy::Preferred,
QSizePolicy::Fixed);
control->setReadOnly(field.readOnly);
control->setProperty("readOnly", field.readOnly);
if (editorType == QStringLiteral("Numeric")) {
if (field.decimals > 0) {
auto* validator = new QDoubleValidator(control);
validator->setNotation(QDoubleValidator::StandardNotation);
validator->setDecimals(field.decimals);
if (field.hasMinimum && field.hasMaximum) {
validator->setRange(field.minimumValue, field.maximumValue,
field.decimals);
}
control->setValidator(validator);
} else if (field.hasMinimum && field.hasMaximum) {
control->setValidator(
new QIntValidator(field.minimumValue, field.maximumValue, control));
}
}
field.lineEdit = control;
field.container = createFieldBlock(field.displayName,
field.description,
control,
sectionParent,
isTextField);
}
if (field.container) {
targetBody->addWidget(field.container);
}
const QJsonValue initialValue = containsJsonPath(m_parameterObject, field.id)
? jsonValueAtPath(m_parameterObject, field.id)
: containsJsonPath(m_defaultObject, field.id)
? jsonValueAtPath(m_defaultObject, field.id)
: QJsonValue();
setWidgetValue(field, initialValue);
m_fields.append(field);
}
}
QJsonObject EmbyCodecParametersDialog::collectParameters() const {
QJsonObject parameters = m_parameterObject;
for (const FieldBinding& field : m_fields) {
if (field.lineEdit) {
QString text = field.lineEdit->text();
if (field.editorType == QStringLiteral("Numeric")) {
text = text.trimmed();
}
setJsonValueAtPath(parameters, field.id, text);
continue;
}
if (field.comboBox) {
const QVariant value =
field.comboBox->currentIndex() >= 0 ? field.comboBox->currentData()
: QVariant(QString());
setJsonValueAtPath(parameters, field.id, QJsonValue::fromVariant(value));
continue;
}
if (field.switchControl) {
setJsonValueAtPath(parameters, field.id, field.switchControl->isChecked());
}
}
return parameters;
}
void EmbyCodecParametersDialog::resetToDefaults() {
if (m_isLoading || m_isSaving || m_fields.isEmpty()) {
return;
}
qDebug() << "[EmbyCodecParametersDialog] Resetting codec parameters to defaults"
<< "| codecId=" << m_codecId
<< "| fieldCount=" << m_fields.size();
for (const FieldBinding& field : std::as_const(m_fields)) {
const QJsonValue defaultValue =
containsJsonPath(m_defaultObject, field.id)
? jsonValueAtPath(m_defaultObject, field.id)
: QJsonValue(QJsonValue::Null);
setJsonValueAtPath(m_parameterObject, field.id, defaultValue);
setWidgetValue(field, defaultValue);
}
}
QCoro::Task<void> EmbyCodecParametersDialog::loadParameters() {
QPointer<EmbyCodecParametersDialog> safeThis(this);
if (m_isLoading || !m_core || !m_core->adminService() || m_codecId.isEmpty()) {
if (!m_codecId.isEmpty()) {
qWarning() << "[EmbyCodecParametersDialog] Unable to load codec parameters"
<< "| codecId=" << m_codecId
<< "| hasCore=" << (m_core != nullptr);
}
m_feedbackText = tr("Failed to load codec parameters");
refreshUiState();
co_return;
}
m_isLoading = true;
m_feedbackText.clear();
refreshUiState();
try {
const QJsonObject response =
co_await m_core->adminService()->getCodecParameters(m_codecId);
if (!safeThis) {
co_return;
}
m_editorContainer = response;
m_parameterObject = response.value(QStringLiteral("Object")).toObject();
m_defaultObject = response.value(QStringLiteral("DefaultObject")).toObject();
rebuildForm();
m_hasLoaded = true;
m_feedbackText.clear();
qDebug() << "[EmbyCodecParametersDialog] Loaded codec parameters"
<< "| codecId=" << m_codecId
<< "| fieldCount=" << m_fields.size()
<< "| objectKeys=" << m_parameterObject.keys();
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
qWarning() << "[EmbyCodecParametersDialog] Failed to load codec parameters"
<< "| codecId=" << m_codecId
<< "| error=" << e.what();
clearFieldBindings();
m_editorContainer = QJsonObject();
m_parameterObject = QJsonObject();
m_defaultObject = QJsonObject();
m_hasLoaded = false;
m_feedbackText = tr("Failed to load codec parameters");
ModernToast::showMessage(
tr("Failed to load codec parameters: %1")
.arg(QString::fromUtf8(e.what())),
3000);
}
if (!safeThis) {
co_return;
}
m_isLoading = false;
refreshUiState();
}
QCoro::Task<void> EmbyCodecParametersDialog::saveParameters() {
QPointer<EmbyCodecParametersDialog> safeThis(this);
if (m_isLoading || m_isSaving || !m_hasLoaded || m_fields.isEmpty() || !m_core ||
!m_core->adminService() || m_codecId.isEmpty()) {
co_return;
}
m_isSaving = true;
refreshUiState();
const QJsonObject parameters = collectParameters();
qDebug() << "[EmbyCodecParametersDialog] Saving codec parameters"
<< "| codecId=" << m_codecId
<< "| keyCount=" << parameters.keys().size()
<< "| keys=" << parameters.keys();
try {
co_await m_core->adminService()->updateCodecParameters(m_codecId, parameters);
if (!safeThis) {
co_return;
}
m_parameterObject = parameters;
m_isSaving = false;
refreshUiState();
ModernToast::showMessage(tr("Codec parameters saved"), 2000);
accept();
co_return;
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
qWarning() << "[EmbyCodecParametersDialog] Failed to save codec parameters"
<< "| codecId=" << m_codecId
<< "| error=" << e.what();
ModernToast::showMessage(
tr("Failed to save codec parameters: %1")
.arg(QString::fromUtf8(e.what())),
3000);
}
if (!safeThis) {
co_return;
}
m_isSaving = false;
refreshUiState();
}

View File

@@ -0,0 +1,89 @@
#ifndef EMBYCODECPARAMETERSDIALOG_H
#define EMBYCODECPARAMETERSDIALOG_H
#include "moderndialogbase.h"
#include <QJsonObject>
#include <optional>
#include <qcorotask.h>
class QEmbyCore;
class QLabel;
class QLineEdit;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class QWidget;
class ModernComboBox;
class ModernSwitch;
class EmbyCodecParametersDialog : public ModernDialogBase {
Q_OBJECT
public:
explicit EmbyCodecParametersDialog(QEmbyCore* core,
QJsonObject codecInfo,
QWidget* parent = nullptr);
private:
struct FieldBinding {
QString id;
QString editorType;
QString displayName;
QString description;
QString staticItemsSourceId;
bool allowEmpty = true;
bool readOnly = false;
bool advanced = false;
int decimals = 0;
int minimumValue = 0;
int maximumValue = 0;
bool hasMinimum = false;
bool hasMaximum = false;
QWidget* container = nullptr;
QLineEdit* lineEdit = nullptr;
ModernComboBox* comboBox = nullptr;
ModernSwitch* switchControl = nullptr;
};
void setupUi();
void rebuildForm();
void clearFieldBindings();
void refreshUiState();
QString codecTitle() const;
QString codecSummary() const;
void appendEditorItems(const QJsonObject& item, QList<QJsonObject>& items) const;
void setWidgetValue(const FieldBinding& field, const QJsonValue& value);
QJsonObject collectParameters() const;
void resetToDefaults();
QCoro::Task<void> loadParameters();
QCoro::Task<void> saveParameters();
QEmbyCore* m_core = nullptr;
QJsonObject m_codecInfo;
QString m_codecId;
QLabel* m_summaryLabel = nullptr;
QLabel* m_feedbackLabel = nullptr;
QScrollArea* m_scrollArea = nullptr;
QWidget* m_formPage = nullptr;
QVBoxLayout* m_formLayout = nullptr;
QWidget* m_basicCard = nullptr;
QWidget* m_advancedCard = nullptr;
QVBoxLayout* m_basicBody = nullptr;
QVBoxLayout* m_advancedBody = nullptr;
QPushButton* m_btnCancel = nullptr;
QPushButton* m_btnReset = nullptr;
QPushButton* m_btnSave = nullptr;
QList<FieldBinding> m_fields;
QJsonObject m_editorContainer;
QJsonObject m_parameterObject;
QJsonObject m_defaultObject;
QString m_feedbackText;
bool m_isLoading = false;
bool m_isSaving = false;
bool m_hasLoaded = false;
std::optional<QCoro::Task<void>> m_pendingTask;
};
#endif

View File

@@ -0,0 +1,54 @@
#include "fetcherrowwidget.h"
#include "modernswitch.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
FetcherRowWidget::FetcherRowWidget(const QString &name, QWidget *parent)
: QWidget(parent), m_name(name) {
setObjectName("FetcherRowWidget");
setAttribute(Qt::WA_StyledBackground, true);
auto *row = new QHBoxLayout(this);
row->setContentsMargins(6, 4, 6, 4);
row->setSpacing(4);
m_label = new QLabel(name, this);
m_label->setObjectName("FetcherRowLabel");
row->addWidget(m_label, 1);
m_upBtn = new QPushButton(this);
m_upBtn->setObjectName("FetcherArrowBtn");
m_upBtn->setFixedSize(24, 24);
m_upBtn->setCursor(Qt::PointingHandCursor);
m_upBtn->setToolTip(tr("Move up"));
m_upBtn->setText(QStringLiteral("\u25B2"));
row->addWidget(m_upBtn);
m_downBtn = new QPushButton(this);
m_downBtn->setObjectName("FetcherArrowBtn");
m_downBtn->setFixedSize(24, 24);
m_downBtn->setCursor(Qt::PointingHandCursor);
m_downBtn->setToolTip(tr("Move down"));
m_downBtn->setText(QStringLiteral("\u25BC"));
row->addWidget(m_downBtn);
m_switch = new ModernSwitch(this);
m_switch->setObjectName("ManageLibSwitch");
m_switch->setChecked(true);
row->addWidget(m_switch);
connect(m_upBtn, &QPushButton::clicked, this,
&FetcherRowWidget::moveUpClicked);
connect(m_downBtn, &QPushButton::clicked, this,
&FetcherRowWidget::moveDownClicked);
}
QString FetcherRowWidget::name() const { return m_name; }
bool FetcherRowWidget::isEnabled() const { return m_switch->isChecked(); }
void FetcherRowWidget::setFetcherEnabled(bool on) { m_switch->setChecked(on); }
void FetcherRowWidget::setUpEnabled(bool on) { m_upBtn->setEnabled(on); }
void FetcherRowWidget::setDownEnabled(bool on) { m_downBtn->setEnabled(on); }

View File

@@ -0,0 +1,36 @@
#ifndef FETCHERROWWIDGET_H
#define FETCHERROWWIDGET_H
#include <QWidget>
class QPushButton;
class QLabel;
class ModernSwitch;
class FetcherRowWidget : public QWidget {
Q_OBJECT
public:
explicit FetcherRowWidget(const QString &name, QWidget *parent = nullptr);
QString name() const;
bool isEnabled() const;
void setFetcherEnabled(bool on);
void setUpEnabled(bool on);
void setDownEnabled(bool on);
signals:
void moveUpClicked();
void moveDownClicked();
private:
QString m_name;
QPushButton *m_upBtn;
QPushButton *m_downBtn;
QLabel *m_label;
ModernSwitch *m_switch;
};
#endif

View File

@@ -0,0 +1,225 @@
#include "flowlayout.h"
#include <QWidget>
FlowLayout::FlowLayout(QWidget *parent, int margin, int hSpacing, int vSpacing)
: QLayout(parent), m_hSpace(hSpacing), m_vSpace(vSpacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::FlowLayout(int margin, int hSpacing, int vSpacing)
: m_hSpace(hSpacing), m_vSpace(vSpacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::~FlowLayout()
{
while (!itemList.isEmpty())
delete itemList.takeFirst();
}
void FlowLayout::addItem(QLayoutItem *item)
{
itemList.append(item);
invalidate();
}
int FlowLayout::horizontalSpacing() const
{
if (m_hSpace >= 0) {
return m_hSpace;
} else {
return smartSpacing(QStyle::PM_LayoutHorizontalSpacing);
}
}
int FlowLayout::verticalSpacing() const
{
if (m_vSpace >= 0) {
return m_vSpace;
} else {
return smartSpacing(QStyle::PM_LayoutVerticalSpacing);
}
}
int FlowLayout::count() const
{
return itemList.size();
}
QLayoutItem *FlowLayout::itemAt(int index) const
{
return itemList.value(index);
}
QLayoutItem *FlowLayout::takeAt(int index)
{
if (index >= 0 && index < itemList.size()) {
QLayoutItem *item = itemList.takeAt(index);
invalidate();
return item;
}
return nullptr;
}
Qt::Orientations FlowLayout::expandingDirections() const
{
return { };
}
bool FlowLayout::hasHeightForWidth() const
{
return true;
}
int FlowLayout::heightForWidth(int width) const
{
int height = doLayout(QRect(0, 0, width, 0), true);
return height;
}
void FlowLayout::invalidate()
{
QLayout::invalidate();
updateParentGeometry();
}
void FlowLayout::setGeometry(const QRect &rect)
{
QLayout::setGeometry(rect);
doLayout(rect, false);
}
void FlowLayout::setFirstLineTrailingSpacing(int spacing)
{
const int safeSpacing = qMax(0, spacing);
if (m_firstLineTrailingSpacing == safeSpacing) {
return;
}
m_firstLineTrailingSpacing = safeSpacing;
invalidate();
}
int FlowLayout::firstLineTrailingSpacing() const
{
return m_firstLineTrailingSpacing;
}
QSize FlowLayout::sizeHint() const
{
QSize size = minimumSize();
int width = -1;
if (const auto *parentWidget = qobject_cast<const QWidget *>(parent())) {
width = parentWidget->contentsRect().width();
const QWidget *p = parentWidget->parentWidget();
while (width <= 0 && p) {
width = p->contentsRect().width();
p = p->parentWidget();
}
} else if (geometry().isValid()) {
width = geometry().width();
}
if (width > 0) {
size.setWidth(qMax(size.width(), width));
size.setHeight(heightForWidth(width));
}
return size;
}
QSize FlowLayout::minimumSize() const
{
QSize size;
for (const QLayoutItem *item : itemList) {
if (const QWidget *wid = item->widget(); wid && wid->isHidden()) {
continue;
}
size = size.expandedTo(item->minimumSize());
}
const QMargins margins = contentsMargins();
size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom());
return size;
}
int FlowLayout::doLayout(const QRect &rect, bool testOnly) const
{
int left, top, right, bottom;
getContentsMargins(&left, &top, &right, &bottom);
QRect effectiveRect = rect.adjusted(+left, +top, -right, -bottom);
int x = effectiveRect.x();
int y = effectiveRect.y();
int lineHeight = 0;
for (QLayoutItem *item : itemList) {
const QWidget *wid = item->widget();
if (wid && wid->isHidden()) {
continue;
}
int spaceX = horizontalSpacing();
if (spaceX == -1) {
spaceX = wid ? wid->style()->layoutSpacing(
QSizePolicy::PushButton,
QSizePolicy::PushButton,
Qt::Horizontal)
: 0;
}
int spaceY = verticalSpacing();
if (spaceY == -1) {
spaceY = wid ? wid->style()->layoutSpacing(
QSizePolicy::PushButton,
QSizePolicy::PushButton,
Qt::Vertical)
: 0;
}
const int lineRight = (y == effectiveRect.y())
? qMax(effectiveRect.x(),
effectiveRect.right() - m_firstLineTrailingSpacing)
: effectiveRect.right();
int nextX = x + item->sizeHint().width() + spaceX;
if (nextX - spaceX > lineRight && lineHeight > 0) {
x = effectiveRect.x();
y = y + lineHeight + spaceY;
nextX = x + item->sizeHint().width() + spaceX;
lineHeight = 0;
}
if (!testOnly)
item->setGeometry(QRect(QPoint(x, y), item->sizeHint()));
x = nextX;
lineHeight = qMax(lineHeight, item->sizeHint().height());
}
return y + lineHeight - rect.y() + bottom;
}
int FlowLayout::smartSpacing(QStyle::PixelMetric pm) const
{
QObject *parent = this->parent();
if (!parent) {
return -1;
} else if (parent->isWidgetType()) {
QWidget *pw = static_cast<QWidget *>(parent);
return pw->style()->pixelMetric(pm, nullptr, pw);
} else {
return static_cast<QLayout *>(parent)->spacing();
}
}
void FlowLayout::updateParentGeometry() const
{
auto *parentWidget = qobject_cast<QWidget *>(parent());
if (!parentWidget) {
return;
}
parentWidget->updateGeometry();
if (QWidget *outerWidget = parentWidget->parentWidget()) {
outerWidget->updateGeometry();
}
}

View File

@@ -0,0 +1,43 @@
#ifndef FLOWLAYOUT_H
#define FLOWLAYOUT_H
#include <QLayout>
#include <QRect>
#include <QStyle>
#include <QList>
class FlowLayout : public QLayout
{
public:
explicit FlowLayout(QWidget *parent, int margin = -1, int hSpacing = -1, int vSpacing = -1);
explicit FlowLayout(int margin = -1, int hSpacing = -1, int vSpacing = -1);
~FlowLayout();
void addItem(QLayoutItem *item) override;
int horizontalSpacing() const;
int verticalSpacing() const;
Qt::Orientations expandingDirections() const override;
bool hasHeightForWidth() const override;
int heightForWidth(int) const override;
void invalidate() override;
int count() const override;
QLayoutItem *itemAt(int index) const override;
QSize minimumSize() const override;
void setGeometry(const QRect &rect) override;
void setFirstLineTrailingSpacing(int spacing);
int firstLineTrailingSpacing() const;
QSize sizeHint() const override;
QLayoutItem *takeAt(int index) override;
private:
int doLayout(const QRect &rect, bool testOnly) const;
int smartSpacing(QStyle::PixelMetric pm) const;
void updateParentGeometry() const;
QList<QLayoutItem *> itemList;
int m_hSpace;
int m_vSpace;
int m_firstLineTrailingSpacing = 0;
};
#endif

View File

@@ -0,0 +1,27 @@
#include "galleryoverlayfilter.h"
#include <QListView>
#include <QPushButton>
#include <QEvent>
#include <QResizeEvent>
#include <QScrollBar>
GalleryOverlayFilter::GalleryOverlayFilter(QListView *list, QPushButton *left, QPushButton *right, QObject *parent)
: QObject(parent), m_list(list), m_left(left), m_right(right) {}
bool GalleryOverlayFilter::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::Resize) {
int btnWidth = 40;
int btnHeight = 80;
int y = m_list->y() + (m_list->height() - btnHeight) / 2 - 20;
if (y < 0) y = 0;
m_left->setGeometry(m_list->x() + 10, y, btnWidth, btnHeight);
m_right->setGeometry(m_list->x() + m_list->width() - btnWidth - 10, y, btnWidth, btnHeight);
}
return QObject::eventFilter(obj, event);
}

View File

@@ -0,0 +1,19 @@
#include <QObject>
class QListView;
class QPushButton;
class GalleryOverlayFilter : public QObject {
public:
GalleryOverlayFilter(QListView* list, QPushButton* left, QPushButton* right, QObject* parent = nullptr);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
private:
QListView* m_list;
QPushButton* m_left;
QPushButton* m_right;
};

View File

@@ -0,0 +1,515 @@
#include "horizontallistviewgallery.h"
#include "shimmerwidget.h"
#include "../utils/textwraputils.h"
#include "../views/media/medialistmodel.h"
#include <QListView>
#include <QVBoxLayout>
#include <QPushButton>
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QEvent>
#include <QWheelEvent>
#include <QCursor>
#include <QScroller>
#include <QScrollerProperties>
#include <QStyleOptionViewItem>
#include <QTimer>
#include <QDebug>
#include <QSet>
#include <algorithm>
HorizontalListViewGallery::HorizontalListViewGallery(QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core), m_hScrollAnim(nullptr), m_hScrollTarget(0), m_cardStyle(MediaCardDelegate::Poster)
{
setObjectName("horizontal-listview-gallery");
setAttribute(Qt::WA_StyledBackground, true);
setMouseTracking(true);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_listView = new QListView(this);
m_listView->setViewMode(QListView::IconMode);
m_listView->setFlow(QListView::LeftToRight);
m_listView->setWrapping(false);
m_listView->setSpacing(0);
m_listView->setMovement(QListView::Static);
m_listView->setResizeMode(QListView::Adjust);
m_listView->setUniformItemSizes(true);
m_listView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setMouseTracking(true);
m_listView->viewport()->setAttribute(Qt::WA_Hover);
m_listView->setStyleSheet("QListView { background: transparent; outline: none; border: none; }");
m_listView->setSelectionMode(QAbstractItemView::NoSelection);
m_listView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
QScroller::grabGesture(m_listView->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_listView->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_hScrollAnim = new QPropertyAnimation(m_listView->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
mainLayout->addWidget(m_listView);
m_shimmer = new ShimmerWidget(this);
m_shimmer->hide();
m_listModel = new MediaListModel(400, m_core, this);
m_listDelegate = new MediaCardDelegate(MediaCardDelegate::Poster, this);
m_listView->setModel(m_listModel);
m_listView->setItemDelegate(m_listDelegate);
connect(m_listDelegate, &MediaCardDelegate::playRequested, this, &HorizontalListViewGallery::playRequested);
connect(m_listDelegate, &MediaCardDelegate::favoriteRequested, this, &HorizontalListViewGallery::favoriteRequested);
connect(m_listDelegate, &MediaCardDelegate::moreMenuRequested, this, &HorizontalListViewGallery::moreMenuRequested);
connect(m_listView, &QListView::clicked, this, [this](const QModelIndex& index) {
if (m_listModel) {
Q_EMIT itemClicked(m_listModel->getItem(index));
}
});
m_btnLeft = new QPushButton("", this);
m_btnRight = new QPushButton("", this);
QString btnStyle = "QPushButton { background-color: rgba(0,0,0,120); color: white; font-size: 20px; border: none; border-radius: 8px; }"
"QPushButton:hover { background-color: rgba(60,60,60,200); }";
m_btnLeft->setStyleSheet(btnStyle);
m_btnRight->setStyleSheet(btnStyle);
m_btnLeft->setFixedSize(40, 60);
m_btnRight->setFixedSize(40, 60);
m_btnLeft->setCursor(Qt::PointingHandCursor);
m_btnRight->setCursor(Qt::PointingHandCursor);
m_btnLeft->setFocusPolicy(Qt::NoFocus);
m_btnRight->setFocusPolicy(Qt::NoFocus);
m_btnLeft->hide();
m_btnRight->hide();
auto scrollAction = [this](int directionMultiplier) {
QScrollBar* bar = m_listView->horizontalScrollBar();
int step = this->width() / 2;
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
};
connect(m_btnLeft, &QPushButton::clicked, [scrollAction]() { scrollAction(-1); });
connect(m_btnRight, &QPushButton::clicked, [scrollAction]() { scrollAction(1); });
connect(m_listView->horizontalScrollBar(), &QScrollBar::valueChanged, this,
[this]() {
updateButtonsVisibility();
updateVisibleImagePriority();
});
connect(m_listView->horizontalScrollBar(), &QScrollBar::rangeChanged, this, &HorizontalListViewGallery::updateButtonsVisibility);
m_listView->viewport()->installEventFilter(this);
this->installEventFilter(this);
}
HorizontalListViewGallery::~HorizontalListViewGallery()
{
if (m_hScrollAnim) {
m_hScrollAnim->stop();
}
if (m_listView && m_listView->viewport()) {
m_listView->viewport()->removeEventFilter(this);
QScroller::ungrabGesture(m_listView->viewport());
}
removeEventFilter(this);
}
void HorizontalListViewGallery::setItems(const QList<MediaItem>& items)
{
if (m_listModel) {
m_listModel->setItems(items);
QTimer::singleShot(0, this,
[this]() { updateVisibleImagePriority(); });
}
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void HorizontalListViewGallery::updateItem(const MediaItem& item)
{
if (m_listModel) {
m_listModel->updateItem(item);
}
}
void HorizontalListViewGallery::prependOrUpdateItem(const MediaItem& item,
int maxItems)
{
if (m_listModel) {
m_listModel->prependOrUpdateItem(item, maxItems);
QTimer::singleShot(0, this, [this]() {
updateVisibleImagePriority();
updateButtonsVisibility();
});
}
}
void HorizontalListViewGallery::removeItem(const QString& itemId)
{
if (m_listModel) {
const int previousCount = m_listModel->rowCount();
m_listModel->removeItem(itemId);
if (m_listView && m_listModel->rowCount() < previousCount) {
m_listView->doItemsLayout();
m_listView->updateGeometry();
m_listView->update();
m_listView->viewport()->update();
QTimer::singleShot(0, this, [this]() {
if (!m_listView) {
return;
}
m_listView->doItemsLayout();
m_listView->viewport()->update();
updateButtonsVisibility();
});
}
}
}
int HorizontalListViewGallery::itemCount() const
{
return m_listModel ? m_listModel->rowCount() : 0;
}
void HorizontalListViewGallery::clearImageCache()
{
if (m_listModel) {
m_listModel->clearImageCache();
}
}
void HorizontalListViewGallery::setCardStyle(MediaCardDelegate::CardStyle style)
{
bool styleChanged = (m_cardStyle != style);
m_cardStyle = style;
if (m_listDelegate) {
m_listDelegate->setStyle(style);
if (style == MediaCardDelegate::LibraryTile) {
int imgHeight = 160;
int imgWidth = qRound(imgHeight * 16.0 / 9.0);
int cardWidth = imgWidth + 16;
const int hoverExpandH = qRound(imgHeight * 0.035);
const int cardHeight =
8 + imgHeight + hoverExpandH + 4 + 20 + 1 + 18 + 8;
m_listDelegate->setTileSize(QSize(cardWidth, cardHeight));
} else if (style == MediaCardDelegate::Poster) {
m_listDelegate->setTileSize(QSize(160, 270));
}
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
if (m_listModel) {
m_listModel->setPreferThumb(style == MediaCardDelegate::LibraryTile || style == MediaCardDelegate::EpisodeList);
if (styleChanged) {
m_listModel->clearImageCache();
}
}
updateButtonPositions();
}
void HorizontalListViewGallery::setTileSize(const QSize &size)
{
if (m_listDelegate) {
m_listDelegate->setTileSize(size);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
updateButtonPositions();
}
void HorizontalListViewGallery::setTextPixelSizes(int titlePx, int subTitlePx)
{
if (m_listDelegate) {
m_listDelegate->setTextPixelSizes(titlePx, subTitlePx);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::setContentPadding(int padding)
{
if (m_listDelegate) {
m_listDelegate->setContentPadding(padding);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
updateButtonPositions();
}
void HorizontalListViewGallery::setHoverControls(
MediaCardDelegate::HoverControls controls)
{
if (m_listDelegate) {
m_listDelegate->setHoverControls(controls);
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::scrollToItemId(const QString &itemId)
{
const QString targetItemId = itemId.trimmed();
if (targetItemId.isEmpty() || !m_listView || !m_listModel) {
return;
}
QTimer::singleShot(0, this, [this, targetItemId]() {
if (!m_listView || !m_listModel) {
return;
}
m_listView->doItemsLayout();
const int rowCount = m_listModel->rowCount();
for (int row = 0; row < rowCount; ++row) {
const QModelIndex index = m_listModel->index(row, 0);
const MediaItem item =
index.data(MediaListModel::ItemDataRole).value<MediaItem>();
if (item.id != targetItemId) {
continue;
}
qDebug() << "[HorizontalListViewGallery] Scroll to item"
<< "| itemId=" << targetItemId
<< "| row=" << row;
m_listView->scrollTo(index, QAbstractItemView::PositionAtCenter);
return;
}
qDebug() << "[HorizontalListViewGallery] Scroll target not found"
<< "| itemId=" << targetItemId
<< "| rowCount=" << rowCount;
});
}
void HorizontalListViewGallery::setHighlightedItemId(const QString &id)
{
if (m_listDelegate) {
m_listDelegate->setHighlightedItemId(id);
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::setLoading(bool loading)
{
if (!m_shimmer) {
return;
}
if (loading) {
QStyleOptionViewItem opt;
const QSize cardSize = m_listDelegate->sizeHint(opt, QModelIndex());
m_shimmer->setCardSize(cardSize);
m_shimmer->setShowSubtitle(
m_cardStyle == MediaCardDelegate::Poster ||
m_cardStyle == MediaCardDelegate::Cast);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
m_shimmer->startAnimation();
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void HorizontalListViewGallery::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
updateButtonPositions();
updateButtonsVisibility();
updateVisibleImagePriority();
if (m_shimmer && m_shimmer->isVisible()) {
m_shimmer->setGeometry(m_listView->geometry());
}
}
void HorizontalListViewGallery::updateButtonPositions()
{
int currentWidth = this->width();
QStyleOptionViewItem dummyOption;
QModelIndex dummyIndex;
QSize itemSize = m_listDelegate->sizeHint(dummyOption, dummyIndex);
int padding = m_listDelegate ? m_listDelegate->contentPadding() : 8;
int imgWidth = itemSize.width() - padding * 2;
int imgHeight = 0;
if (m_cardStyle == MediaCardDelegate::LibraryTile) {
imgHeight = qRound(imgWidth * 9.0 / 16.0);
} else {
imgHeight = qRound(imgWidth * 1.5);
}
int imageCenterY = padding + (imgHeight / 2);
int btnY = imageCenterY - (m_btnLeft->height() / 2);
m_btnLeft->move(10, btnY);
m_btnRight->move(currentWidth - m_btnRight->width() - 10, btnY);
m_btnLeft->raise();
m_btnRight->raise();
}
bool HorizontalListViewGallery::eventFilter(QObject* obj, QEvent* event)
{
if (event->type() == QEvent::Enter || event->type() == QEvent::MouseMove) {
updateButtonsVisibility();
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
}
} else if (obj == m_listView->viewport() && event->type() == QEvent::ToolTip) {
return TextWrapUtils::showWrappedMediaItemToolTip(m_listView, event);
} else if (event->type() == QEvent::Wheel) {
QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
if (qAbs(wheelEvent->angleDelta().x()) > qAbs(wheelEvent->angleDelta().y())) {
if (!m_listView || !m_hScrollAnim || !m_hScrollAnim->targetObject()) {
wheelEvent->ignore();
return false;
}
QScrollBar* hBar = m_listView->horizontalScrollBar();
if (hBar) {
int currentVal = hBar->value();
if (m_hScrollAnim->state() == QAbstractAnimation::Running) {
currentVal = m_hScrollTarget;
}
int step = wheelEvent->angleDelta().x();
int newTarget = currentVal - step;
newTarget = qBound(hBar->minimum(), newTarget, hBar->maximum());
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
return true;
} else {
wheelEvent->ignore();
return false;
}
}
return QWidget::eventFilter(obj, event);
}
void HorizontalListViewGallery::updateButtonsVisibility()
{
QPoint globalPos = QCursor::pos();
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
return;
}
int currentWidth = this->width();
QScrollBar* bar = m_listView->horizontalScrollBar();
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
}
void HorizontalListViewGallery::updateVisibleImagePriority()
{
if (!m_listView || !m_listModel) {
return;
}
QWidget* viewport = m_listView->viewport();
if (!viewport) {
return;
}
QStyleOptionViewItem option;
const QSize cellSize = m_listDelegate->sizeHint(option, QModelIndex());
const int stepX = qMax(1, cellSize.width() / 2);
const int stepY = qMax(1, cellSize.height() / 2);
QSet<int> rowSet;
const QRect rect = viewport->rect();
for (int y = rect.top(); y <= rect.bottom(); y += stepY) {
for (int x = rect.left(); x <= rect.right(); x += stepX) {
const QModelIndex idx = m_listView->indexAt(QPoint(x, y));
if (idx.isValid()) {
rowSet.insert(idx.row());
}
}
}
QList<int> rows = rowSet.values();
std::sort(rows.begin(), rows.end());
m_listModel->setPriorityRows(rows);
}

View File

@@ -0,0 +1,97 @@
#ifndef HORIZONTALLISTVIEWGALLERY_H
#define HORIZONTALLISTVIEWGALLERY_H
#include <QWidget>
#include <QPoint>
#include <QList>
#include <QSize>
#include <models/media/mediaitem.h>
class ShimmerWidget;
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class QListView;
class QPushButton;
class QPropertyAnimation;
class MediaListModel;
class HorizontalListViewGallery : public QWidget
{
Q_OBJECT
public:
explicit HorizontalListViewGallery(QEmbyCore* core, QWidget* parent = nullptr);
~HorizontalListViewGallery() override;
QListView* listView() const { return m_listView; }
void setItems(const QList<MediaItem>& items);
void updateItem(const MediaItem& item);
void prependOrUpdateItem(const MediaItem& item, int maxItems = 0);
void removeItem(const QString& itemId);
int itemCount() const;
void clearImageCache();
void setCardStyle(MediaCardDelegate::CardStyle style);
void setTileSize(const QSize &size);
void setTextPixelSizes(int titlePx, int subTitlePx);
void setContentPadding(int padding);
void setHoverControls(MediaCardDelegate::HoverControls controls);
void scrollToItemId(const QString &itemId);
void setHighlightedItemId(const QString &id);
void setLoading(bool loading);
QSize minimumSizeHint() const override {
return QSize(1, QWidget::minimumSizeHint().height());
}
Q_SIGNALS:
void itemClicked(const MediaItem& item);
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& globalPos);
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void updateButtonsVisibility();
void updateButtonPositions();
void updateVisibleImagePriority();
QEmbyCore* m_core;
QListView* m_listView;
QPushButton* m_btnLeft;
QPushButton* m_btnRight;
MediaListModel* m_listModel;
MediaCardDelegate* m_listDelegate;
QPropertyAnimation* m_hScrollAnim;
int m_hScrollTarget;
MediaCardDelegate::CardStyle m_cardStyle;
ShimmerWidget* m_shimmer = nullptr;
};
#endif

View File

@@ -0,0 +1,255 @@
#include "horizontalwidgetgallery.h"
#include <QScrollArea>
#include <QHBoxLayout>
#include <QPushButton>
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QEvent>
#include <QWheelEvent>
#include <QTimer>
#include <QCursor>
#include <QScroller>
#include <QScrollerProperties>
HorizontalWidgetGallery::HorizontalWidgetGallery(QWidget* parent)
: QWidget(parent), m_hScrollAnim(nullptr), m_hScrollTarget(0)
{
setObjectName("horizontal-widget-gallery");
setAttribute(Qt::WA_StyledBackground, true);
setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
this->setMinimumWidth(1);
this->setMouseTracking(true);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setMinimumWidth(1);
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; }");
m_scrollArea->viewport()->setObjectName("gallery-viewport");
m_scrollArea->viewport()->setStyleSheet("#gallery-viewport { background: transparent; }");
m_scrollArea->viewport()->setMouseTracking(true);
QScroller::grabGesture(m_scrollArea->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_scrollArea->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_hScrollAnim = new QPropertyAnimation(m_scrollArea->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
m_contentWidget = new QWidget(m_scrollArea);
m_contentWidget->setObjectName("gallery-content-widget");
m_contentWidget->setStyleSheet("#gallery-content-widget { background: transparent; }");
m_contentWidget->setMouseTracking(true);
m_contentLayout = new QHBoxLayout(m_contentWidget);
m_contentLayout->setSizeConstraint(QLayout::SetMinimumSize);
m_contentLayout->setContentsMargins(0, 0, 100, 0);
m_contentLayout->setSpacing(16);
m_contentLayout->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_scrollArea->setWidget(m_contentWidget);
mainLayout->addWidget(m_scrollArea);
m_btnLeft = new QPushButton("", this);
m_btnRight = new QPushButton("", this);
QString btnStyle = "QPushButton { background-color: rgba(0,0,0,120); color: white; font-size: 20px; border: none; border-radius: 8px; }"
"QPushButton:hover { background-color: rgba(60,60,60,200); }";
m_btnLeft->setStyleSheet(btnStyle);
m_btnRight->setStyleSheet(btnStyle);
m_btnLeft->setFixedSize(40, 60);
m_btnRight->setFixedSize(40, 60);
m_btnLeft->setCursor(Qt::PointingHandCursor);
m_btnRight->setCursor(Qt::PointingHandCursor);
m_btnLeft->setFocusPolicy(Qt::NoFocus);
m_btnRight->setFocusPolicy(Qt::NoFocus);
m_btnLeft->hide();
m_btnRight->hide();
auto scrollAction = [this](int directionMultiplier) {
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
int step = this->width() / 2;
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
};
connect(m_btnLeft, &QPushButton::clicked, [scrollAction]() { scrollAction(-1); });
connect(m_btnRight, &QPushButton::clicked, [scrollAction]() { scrollAction(1); });
connect(m_scrollArea->horizontalScrollBar(), &QScrollBar::valueChanged, this, &HorizontalWidgetGallery::updateButtonsVisibility);
connect(m_scrollArea->horizontalScrollBar(), &QScrollBar::rangeChanged, this, &HorizontalWidgetGallery::updateButtonsVisibility);
m_scrollArea->viewport()->installEventFilter(this);
this->installEventFilter(this);
}
void HorizontalWidgetGallery::addWidget(QWidget* widget)
{
if (!widget) return;
m_contentLayout->addWidget(widget);
}
void HorizontalWidgetGallery::clear()
{
QLayoutItem *child;
while ((child = m_contentLayout->takeAt(0)) != nullptr) {
if (child->widget()) {
child->widget()->hide();
child->widget()->deleteLater();
}
delete child;
}
}
void HorizontalWidgetGallery::setSpacing(int spacing)
{
m_contentLayout->setSpacing(spacing);
}
void HorizontalWidgetGallery::updateButtonPositions()
{
int currentWidth = this->width();
int btnY = (this->height() - m_btnLeft->height()) / 2;
m_btnLeft->move(10, btnY);
m_btnRight->move(currentWidth - m_btnRight->width() - 10, btnY);
m_btnLeft->raise();
m_btnRight->raise();
}
void HorizontalWidgetGallery::applyContentHeight()
{
m_contentWidget->adjustSize();
const int contentHeight = m_contentWidget->sizeHint().height();
const int targetHeight = qMax(0, contentHeight + 16);
if (height() != targetHeight ||
minimumHeight() != targetHeight ||
maximumHeight() != targetHeight) {
setFixedHeight(targetHeight);
}
updateButtonPositions();
updateGeometry();
}
void HorizontalWidgetGallery::adjustHeightToContent()
{
applyContentHeight();
QTimer::singleShot(0, this, [this]() { applyContentHeight(); });
}
void HorizontalWidgetGallery::resizeEvent(QResizeEvent* event)
{
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
bool wasAtEnd = (bar->maximum() > 0 && bar->value() >= bar->maximum() - 5);
QWidget::resizeEvent(event);
updateButtonPositions();
updateButtonsVisibility();
if (wasAtEnd) {
QTimer::singleShot(0, this, [bar]() {
bar->setValue(bar->maximum());
});
}
}
bool HorizontalWidgetGallery::eventFilter(QObject* obj, QEvent* event)
{
if (event->type() == QEvent::Enter || event->type() == QEvent::MouseMove) {
updateButtonsVisibility();
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
}
} else if (event->type() == QEvent::Wheel) {
QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
if (qAbs(wheelEvent->angleDelta().x()) > qAbs(wheelEvent->angleDelta().y())) {
QScrollBar* hBar = m_scrollArea->horizontalScrollBar();
if (hBar) {
int currentVal = hBar->value();
if (m_hScrollAnim->state() == QAbstractAnimation::Running) {
currentVal = m_hScrollTarget;
}
int step = wheelEvent->angleDelta().x();
int newTarget = currentVal - step;
newTarget = qBound(hBar->minimum(), newTarget, hBar->maximum());
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
return true;
} else {
wheelEvent->ignore();
return false;
}
}
return QWidget::eventFilter(obj, event);
}
void HorizontalWidgetGallery::wheelEvent(QWheelEvent *event) {
if (qAbs(event->angleDelta().x()) <= qAbs(event->angleDelta().y())) {
event->ignore();
}
}
void HorizontalWidgetGallery::updateButtonsVisibility()
{
QPoint globalPos = QCursor::pos();
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
return;
}
int currentWidth = this->width();
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
}

View File

@@ -0,0 +1,57 @@
#ifndef HORIZONTALWIDGETGALLERY_H
#define HORIZONTALWIDGETGALLERY_H
#include <QWidget>
class QScrollArea;
class QHBoxLayout;
class QPushButton;
class QPropertyAnimation;
class HorizontalWidgetGallery : public QWidget
{
Q_OBJECT
public:
explicit HorizontalWidgetGallery(QWidget* parent = nullptr);
~HorizontalWidgetGallery() override = default;
void addWidget(QWidget* widget);
void clear();
void setSpacing(int spacing);
void adjustHeightToContent();
QSize minimumSizeHint() const override {
return QSize(1, QWidget::minimumSizeHint().height());
}
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
private:
void applyContentHeight();
void updateButtonsVisibility();
void updateButtonPositions();
QScrollArea* m_scrollArea;
QWidget* m_contentWidget;
QHBoxLayout* m_contentLayout;
QPushButton* m_btnLeft;
QPushButton* m_btnRight;
QPropertyAnimation* m_hScrollAnim;
int m_hScrollTarget;
};
#endif

View File

@@ -0,0 +1,88 @@
#include "libraryactionmenu.h"
#include "../managers/thememanager.h"
#include <QAction>
#include <QFile>
#include <QIcon>
#include <QVariant>
LibraryActionMenu::LibraryActionMenu(const VirtualFolder& folder,
bool scanActive, QWidget* parent)
: QMenu(parent), m_folder(folder), m_scanActive(scanActive)
{
setObjectName("media-action-menu");
setWindowFlags(windowFlags() | Qt::FramelessWindowHint |
Qt::NoDropShadowWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setupMenu();
}
LibraryContextMenuRequest LibraryActionMenu::execRequest(const QPoint& globalPos)
{
return requestFromAction(exec(globalPos));
}
QAction* LibraryActionMenu::addMenuAction(LibraryContextMenuAction action,
const QIcon& icon,
const QString& text,
const QString& stringValue)
{
auto* menuAction = new QAction(icon, text, this);
LibraryContextMenuRequest request;
request.action = action;
request.stringValue = stringValue;
menuAction->setData(QVariant::fromValue(request));
addAction(menuAction);
return menuAction;
}
LibraryContextMenuRequest
LibraryActionMenu::requestFromAction(const QAction* action)
{
if (!action) {
return {};
}
const QVariant requestData = action->data();
if (!requestData.canConvert<LibraryContextMenuRequest>()) {
return {};
}
return requestData.value<LibraryContextMenuRequest>();
}
void LibraryActionMenu::setupMenu()
{
const QString themeDir =
ThemeManager::instance()->isDarkMode() ? "dark" : "light";
auto adaptiveIcon = [&themeDir](const QString& baseName) -> QIcon {
const QString themePath = QString(":/svg/%1/%2").arg(themeDir, baseName);
if (QFile::exists(themePath)) {
return QIcon(themePath);
}
return ThemeManager::getAdaptiveIcon(
QString(":/svg/player/%1").arg(baseName));
};
const bool hasItemId = !m_folder.itemId.trimmed().isEmpty();
QAction* editImagesAction = addMenuAction(LibraryContextMenuAction::EditImages,
adaptiveIcon("edit-images.svg"),
tr("Edit Images"));
editImagesAction->setEnabled(hasItemId && !m_scanActive);
QAction* refreshAction = addMenuAction(LibraryContextMenuAction::RefreshMetadata,
adaptiveIcon("refresh.svg"),
tr("Refresh Metadata"));
refreshAction->setEnabled(hasItemId && !m_scanActive);
QAction* scanAction = addMenuAction(LibraryContextMenuAction::ScanLibraryFiles,
adaptiveIcon("scan-library.svg"),
tr("Scan Library Files"));
scanAction->setEnabled(hasItemId && !m_scanActive);
}

View File

@@ -0,0 +1,31 @@
#ifndef LIBRARYACTIONMENU_H
#define LIBRARYACTIONMENU_H
#include "librarycontextmenurequest.h"
#include <QMenu>
#include <models/admin/adminmodels.h>
class QAction;
class QIcon;
class QPoint;
class LibraryActionMenu : public QMenu
{
Q_OBJECT
public:
explicit LibraryActionMenu(const VirtualFolder& folder, bool scanActive,
QWidget* parent = nullptr);
LibraryContextMenuRequest execRequest(const QPoint& globalPos);
private:
QAction* addMenuAction(LibraryContextMenuAction action, const QIcon& icon,
const QString& text,
const QString& stringValue = QString());
void setupMenu();
static LibraryContextMenuRequest requestFromAction(const QAction* action);
VirtualFolder m_folder;
bool m_scanActive = false;
};
#endif

View File

@@ -0,0 +1,26 @@
#ifndef LIBRARYCONTEXTMENUREQUEST_H
#define LIBRARYCONTEXTMENUREQUEST_H
#include <QMetaType>
#include <QString>
enum class LibraryContextMenuAction {
None,
EditImages,
RefreshMetadata,
ScanLibraryFiles
};
struct LibraryContextMenuRequest {
LibraryContextMenuAction action = LibraryContextMenuAction::None;
QString stringValue;
bool isValid() const
{
return action != LibraryContextMenuAction::None;
}
};
Q_DECLARE_METATYPE(LibraryContextMenuRequest)
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
#ifndef LIBRARYCREATEDLG_H
#define LIBRARYCREATEDLG_H
#include "moderndialogbase.h"
#include <models/admin/adminmodels.h>
#include <models/profile/serverprofile.h>
#include <QJsonObject>
#include <QStringList>
#include <QVariant>
class QLineEdit;
class QLabel;
class QPushButton;
class QSpinBox;
class QVBoxLayout;
class QWidget;
class ModernComboBox;
class ModernSwitch;
class ModernTagInput;
class QEmbyCore;
class FetcherRowWidget;
class CollapsibleSection;
class PathRowWidget;
class LibraryCreateDialog : public ModernDialogBase {
Q_OBJECT
public:
explicit LibraryCreateDialog(QEmbyCore* core,
const QStringList& metadataFetchers = {},
const QStringList& imageFetchers = {},
const QStringList& subtitleDownloaders = {},
const QStringList& lyricsFetchers = {},
const QList<LocalizationCulture>& cultures = {},
const QList<LocalizationCountry>& countries = {},
QWidget *parent = nullptr);
QString libraryName() const;
QString collectionType() const;
QStringList paths() const;
QJsonObject libraryOptions() const;
protected:
void setConfirmButtonText(const QString& text);
void setTypeEditable(bool editable);
void loadFromFolder(const VirtualFolder& folder);
private:
friend class LibraryCreateDialogOptionsBuilder;
void setupUi();
void setupBasicSection(QVBoxLayout* layout);
void setupLibrarySettingsSection(QVBoxLayout* layout);
void setupAdvancedSection(QVBoxLayout* layout);
void setupImageSection(QVBoxLayout* layout);
void setupSubtitleSection(QVBoxLayout* layout);
void setupLyricsSection(QVBoxLayout* layout);
void setupPlaybackSection(QVBoxLayout* layout);
void applyTypeDefaults();
void populatePreferredLanguageCombo(ModernComboBox* combo,
bool useRegionalCodes);
void populateCountryCombo(ModernComboBox* combo);
void populateSubtitleLanguageInput(ModernTagInput* input,
bool useRegionalCodes);
void populateScheduleCombo(ModernComboBox* combo) const;
void populatePlaceholderRefreshCombo(ModernComboBox* combo) const;
void populateMultiVersionCombo(ModernComboBox* combo) const;
void populateThumbnailIntervalCombo(ModernComboBox* combo) const;
void populateChapterIntervalCombo(ModernComboBox* combo) const;
QString selectedComboValue(const ModernComboBox* combo) const;
QString selectedMetadataLanguage() const;
QString selectedMetadataCountryCode() const;
QString selectedImageLanguage() const;
QStringList selectedSubtitleLanguages() const;
QStringList selectedLyricsLanguages() const;
QString normalizedCultureCode(const QString& value) const;
QString normalizedCountryCode(const QString& value) const;
QStringList normalizedCultureCodes(const QStringList& values) const;
QString normalizedCollectionType() const;
void updateTypeSpecificSettings();
int adjacentApplicableFetcherIndex(const QList<FetcherRowWidget*>& rows,
int currentIndex,
int direction) const;
void setPaths(const QStringList& paths);
void setComboCurrentData(ModernComboBox* combo, const QVariant& value);
void applyLibraryOptions(const QJsonObject& libraryOptions);
void applyFetcherSelection(QVBoxLayout* container,
QList<FetcherRowWidget*>& rows,
const QStringList& enabledNames,
const QStringList& disabledNames,
const QStringList& orderedNames);
QJsonObject mergedLibraryOptions(const QJsonObject& currentOptions) const;
void clearManagedLibraryOptionKeys(QJsonObject& options) const;
void addFetcherRows(QVBoxLayout* container,
const QStringList& names,
QList<FetcherRowWidget*>& rows);
void swapFetcherRow(QVBoxLayout* container,
QList<FetcherRowWidget*>& rows,
int from, int to);
void updateArrowStates(const QList<FetcherRowWidget*>& rows);
PathRowWidget* addPathRow(const QString& path = QString());
void removePathRow(PathRowWidget* row);
void onBrowsePath(PathRowWidget* row);
static QStringList defaultMetadataFetchers(ServerProfile::ServerType serverType);
static QStringList defaultImageFetchers(ServerProfile::ServerType serverType);
static QStringList defaultSubtitleDownloaders(ServerProfile::ServerType serverType);
QEmbyCore* m_core = nullptr;
QStringList m_metadataFetcherNames;
QStringList m_imageFetcherNames;
QStringList m_subtitleDownloaderNames;
QStringList m_lyricsFetcherNames;
QList<LocalizationCulture> m_availableCultures;
QList<LocalizationCountry> m_availableCountries;
QJsonObject m_baseLibraryOptions;
QLineEdit* m_nameEdit = nullptr;
ModernComboBox* m_typeCombo = nullptr;
QVBoxLayout* m_pathRowsLayout = nullptr;
QList<PathRowWidget*> m_pathRows;
CollapsibleSection* m_librarySettingsSection = nullptr;
CollapsibleSection* m_advancedSection = nullptr;
ModernSwitch* m_chkRealtimeMonitor = nullptr;
ModernSwitch* m_chkEmbeddedTitles = nullptr;
ModernSwitch* m_chkEnablePhotos = nullptr;
ModernSwitch* m_chkImportPlaylists = nullptr;
ModernSwitch* m_chkExcludeFromSearch = nullptr;
ModernSwitch* m_chkMergeFolders = nullptr;
ModernSwitch* m_chkSaveNfo = nullptr;
ModernSwitch* m_chkNfoReader = nullptr;
ModernSwitch* m_chkAllowAdultMetadata = nullptr;
ModernSwitch* m_chkImportCollectionInfo = nullptr;
ModernSwitch* m_chkAutoCollection = nullptr;
ModernSwitch* m_chkAutomaticallyGroupSeries = nullptr;
ModernSwitch* m_chkEnablePlexIgnore = nullptr;
ModernSwitch* m_chkEnableMultiPart = nullptr;
ModernComboBox* m_minCollSizeCombo = nullptr;
ModernComboBox* m_placeholderRefreshCombo = nullptr;
ModernComboBox* m_musicFolderStructureCombo = nullptr;
ModernComboBox* m_multiVersionCombo = nullptr;
QSpinBox* m_spinIgnoreSamplesMb = nullptr;
QWidget* m_enablePhotosRow = nullptr;
QWidget* m_importPlaylistsRow = nullptr;
QWidget* m_excludeFromSearchRow = nullptr;
QWidget* m_mergeFoldersRow = nullptr;
QWidget* m_embeddedTitlesRow = nullptr;
QWidget* m_metaLangRow = nullptr;
QWidget* m_countryRow = nullptr;
QWidget* m_imageLangRow = nullptr;
QWidget* m_musicFolderStructureRow = nullptr;
QWidget* m_nfoRow = nullptr;
QWidget* m_nfoReaderRow = nullptr;
QWidget* m_adultMetadataRow = nullptr;
QWidget* m_importCollectionRow = nullptr;
QWidget* m_autoCollectionRow = nullptr;
QWidget* m_minCollectionSizeRow = nullptr;
QWidget* m_metaRefreshRow = nullptr;
QWidget* m_placeholderRefreshRow = nullptr;
QWidget* m_ignoreSamplesRow = nullptr;
QWidget* m_plexIgnoreRow = nullptr;
QWidget* m_autoGroupSeriesRow = nullptr;
QWidget* m_multiPartRow = nullptr;
QWidget* m_multiVersionRow = nullptr;
QWidget* m_metadataFetchersPanel = nullptr;
QWidget* m_seasonMetadataFetchersPanel = nullptr;
QWidget* m_episodeMetadataFetchersPanel = nullptr;
QWidget* m_libraryTopSeparator = nullptr;
QWidget* m_libraryMiddleSeparator = nullptr;
ModernComboBox* m_metaLangCombo = nullptr;
ModernComboBox* m_countryCombo = nullptr;
ModernComboBox* m_imageLangCombo = nullptr;
ModernComboBox* m_metaRefreshCombo = nullptr;
QLabel* m_metadataFetchersLabel = nullptr;
QLabel* m_seasonMetadataFetchersLabel = nullptr;
QLabel* m_episodeMetadataFetchersLabel = nullptr;
QVBoxLayout* m_metaFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_metadataFetcherRows;
QVBoxLayout* m_seasonMetaFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_seasonMetadataFetcherRows;
QVBoxLayout* m_episodeMetaFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_episodeMetadataFetcherRows;
CollapsibleSection* m_imageSection = nullptr;
ModernSwitch* m_chkSaveArtwork = nullptr;
ModernSwitch* m_chkCacheImages = nullptr;
ModernSwitch* m_chkSaveMetadataHidden = nullptr;
ModernSwitch* m_chkDownloadImagesInAdvance = nullptr;
ModernSwitch* m_chkExtractChapterImages = nullptr;
ModernSwitch* m_chkSaveThumbnailSets = nullptr;
ModernSwitch* m_chkGenerateChapters = nullptr;
ModernComboBox* m_thumbnailScheduleCombo = nullptr;
ModernComboBox* m_thumbnailIntervalCombo = nullptr;
ModernComboBox* m_chapterIntervalCombo = nullptr;
ModernComboBox* m_introDetectionCombo = nullptr;
QWidget* m_saveArtworkRow = nullptr;
QWidget* m_cacheImagesRow = nullptr;
QWidget* m_saveMetadataHiddenRow = nullptr;
QWidget* m_downloadImagesRow = nullptr;
QWidget* m_extractChapterImagesRow = nullptr;
QWidget* m_thumbnailScheduleRow = nullptr;
QWidget* m_thumbnailIntervalRow = nullptr;
QWidget* m_saveThumbnailSetsRow = nullptr;
QWidget* m_generateChaptersRow = nullptr;
QWidget* m_chapterIntervalRow = nullptr;
QWidget* m_introDetectionRow = nullptr;
QWidget* m_imageFetchersPanel = nullptr;
QWidget* m_seasonImageFetchersPanel = nullptr;
QWidget* m_episodeImageFetchersPanel = nullptr;
QWidget* m_imageOptionsSeparator = nullptr;
QLabel* m_imageFetchersLabel = nullptr;
QLabel* m_seasonImageFetchersLabel = nullptr;
QLabel* m_episodeImageFetchersLabel = nullptr;
QVBoxLayout* m_imgFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_imageFetcherRows;
QVBoxLayout* m_seasonImgFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_seasonImageFetcherRows;
QVBoxLayout* m_episodeImgFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_episodeImageFetcherRows;
CollapsibleSection* m_subtitleSection = nullptr;
ModernSwitch* m_chkSaveSubtitles = nullptr;
ModernSwitch* m_chkSkipIfAudioMatchesSub = nullptr;
ModernSwitch* m_chkSkipIfEmbeddedSub = nullptr;
ModernSwitch* m_chkRequirePerfectSubtitleMatch = nullptr;
ModernSwitch* m_chkForcedSubtitlesOnly = nullptr;
ModernComboBox* m_subAgeLimitCombo = nullptr;
ModernTagInput* m_subtitleLangInput = nullptr;
QWidget* m_subtitleDownloadersPanel = nullptr;
QWidget* m_subtitleDownloadersSeparator = nullptr;
QLabel* m_subtitleDownloadersLabel = nullptr;
QVBoxLayout* m_subDownloaderLayout = nullptr;
QList<FetcherRowWidget*> m_subtitleDownloaderRows;
CollapsibleSection* m_lyricsSection = nullptr;
QWidget* m_lyricsFetchersPanel = nullptr;
QWidget* m_lyricsFetchersSeparator = nullptr;
QLabel* m_lyricsFetchersLabel = nullptr;
QVBoxLayout* m_lyricsFetcherLayout = nullptr;
QList<FetcherRowWidget*> m_lyricsFetcherRows;
ModernTagInput* m_lyricsLangInput = nullptr;
ModernComboBox* m_lyricsAgeLimitCombo = nullptr;
ModernSwitch* m_chkSaveLyrics = nullptr;
CollapsibleSection* m_playbackSection = nullptr;
QSpinBox* m_spinMinResumePercent = nullptr;
QSpinBox* m_spinMaxResumePercent = nullptr;
QSpinBox* m_spinMinResumeDuration = nullptr;
QPushButton* m_okBtn = nullptr;
QPushButton* m_cancelBtn = nullptr;
};
#endif

View File

@@ -0,0 +1,286 @@
#include "librarycreatedlg_p.h"
#include "fetcherrowwidget.h"
#include "librarycreatedlg.h"
#include "moderncombobox.h"
#include "modernswitch.h"
#include <QSpinBox>
#include <QDebug>
namespace LTU = LibraryTypeUtils;
namespace LOS = LibraryOptionsSerializer;
LibraryCreateDialogOptionsBuilder::LibraryCreateDialogOptionsBuilder(
const LibraryCreateDialog &dialog, const LTU::LibraryTypeProfile &profile,
ServerProfile::ServerType serverType)
: m_dialog(dialog), m_profile(profile), m_serverType(serverType) {}
LOS::LibraryOptionsState
LibraryCreateDialogOptionsBuilder::build(const QString &contentType) const {
LOS::LibraryOptionsState state;
state.contentType = contentType;
collectBasicState(state);
collectMetadataState(state);
collectAdvancedState(state);
collectImageState(state);
collectSubtitleState(state);
collectLyricsState(state);
collectPlaybackState(state);
collectFetcherState(state);
return state;
}
void LibraryCreateDialogOptionsBuilder::assignChecked(
std::optional<bool> &target, const ModernSwitch *toggle) {
if (toggle) {
target = toggle->isChecked();
}
}
void LibraryCreateDialogOptionsBuilder::assignCurrentInt(
std::optional<int> &target, const ModernComboBox *combo) {
if (combo) {
target = combo->currentData().toInt();
}
}
void LibraryCreateDialogOptionsBuilder::collectBasicState(
LOS::LibraryOptionsState &state) const {
state.enableRealtimeMonitor =
m_dialog.m_chkRealtimeMonitor &&
m_dialog.m_chkRealtimeMonitor->isChecked();
state.enableEmbeddedTitles =
m_dialog.m_chkEmbeddedTitles &&
m_dialog.m_chkEmbeddedTitles->isChecked();
if (m_profile.showEnablePhotos) {
assignChecked(state.enablePhotos, m_dialog.m_chkEnablePhotos);
}
if (m_profile.showImportPlaylists) {
assignChecked(state.importPlaylists, m_dialog.m_chkImportPlaylists);
}
if (m_profile.showExcludeFromSearch) {
assignChecked(state.excludeFromSearch, m_dialog.m_chkExcludeFromSearch);
}
if (m_profile.showMergeTopLevelFolders) {
assignChecked(state.mergeTopLevelFolders, m_dialog.m_chkMergeFolders);
}
}
void LibraryCreateDialogOptionsBuilder::collectMetadataState(
LOS::LibraryOptionsState &state) const {
assignChecked(state.saveMetadataAsNfo, m_dialog.m_chkSaveNfo);
assignChecked(state.enableNfoReader, m_dialog.m_chkNfoReader);
assignChecked(state.enableAdultMetadata, m_dialog.m_chkAllowAdultMetadata);
assignChecked(state.importCollections, m_dialog.m_chkImportCollectionInfo);
assignChecked(state.automaticallyAddToCollection,
m_dialog.m_chkAutoCollection);
assignCurrentInt(state.minCollectionItems, m_dialog.m_minCollSizeCombo);
assignCurrentInt(state.automaticRefreshIntervalDays,
m_dialog.m_metaRefreshCombo);
if (m_profile.showPlaceholderMetadataRefresh) {
assignCurrentInt(state.placeholderMetadataRefreshIntervalDays,
m_dialog.m_placeholderRefreshCombo);
}
if (m_profile.showMusicFolderStructure &&
m_dialog.m_musicFolderStructureCombo) {
state.musicFolderStructure =
m_dialog.m_musicFolderStructureCombo->currentData().toString();
}
const QString preferredMetadataLanguage =
m_dialog.selectedMetadataLanguage();
const QString metadataCountryCode =
m_dialog.selectedMetadataCountryCode();
const QString preferredImageLanguage =
m_dialog.selectedImageLanguage();
state.preferredMetadataLanguage = preferredMetadataLanguage;
state.metadataCountryCode = metadataCountryCode;
state.preferredImageLanguage = preferredImageLanguage;
qDebug() << "[LibraryCreateDialogOptionsBuilder] metadata selection"
<< "| metadataLangText:"
<< (m_dialog.m_metaLangCombo ? m_dialog.m_metaLangCombo->currentText()
: QString())
<< "| metadataLangData:" << preferredMetadataLanguage
<< "| metadataLangIndex:"
<< (m_dialog.m_metaLangCombo ? m_dialog.m_metaLangCombo->currentIndex()
: -1)
<< "| countryText:"
<< (m_dialog.m_countryCombo ? m_dialog.m_countryCombo->currentText()
: QString())
<< "| countryData:" << metadataCountryCode
<< "| countryIndex:"
<< (m_dialog.m_countryCombo ? m_dialog.m_countryCombo->currentIndex()
: -1)
<< "| imageLangText:"
<< (m_dialog.m_imageLangCombo ? m_dialog.m_imageLangCombo->currentText()
: QString())
<< "| imageLangData:" << preferredImageLanguage
<< "| imageLangIndex:"
<< (m_dialog.m_imageLangCombo ? m_dialog.m_imageLangCombo->currentIndex()
: -1);
}
void LibraryCreateDialogOptionsBuilder::collectAdvancedState(
LOS::LibraryOptionsState &state) const {
if (m_profile.showEnablePlexIgnore) {
assignChecked(state.enablePlexIgnore, m_dialog.m_chkEnablePlexIgnore);
}
if (m_profile.showAutomaticSeriesGrouping) {
assignChecked(state.enableAutomaticSeriesGrouping,
m_dialog.m_chkAutomaticallyGroupSeries);
}
if (m_profile.showMultiPartItems) {
assignChecked(state.enableMultiPartItems, m_dialog.m_chkEnableMultiPart);
}
if (m_profile.showSampleIgnoreSize && m_dialog.m_spinIgnoreSamplesMb) {
state.sampleIgnoreSizeBytes =
m_dialog.m_spinIgnoreSamplesMb->value() * 1024 * 1024;
}
if (m_profile.showMultiVersionGrouping && m_dialog.m_multiVersionCombo) {
const QString mode = m_dialog.m_multiVersionCombo->currentData().toString();
state.enableMultiVersionByFiles = (mode == "both" || mode == "files");
state.enableMultiVersionByMetadata =
(mode == "both" || mode == "metadata");
}
}
void LibraryCreateDialogOptionsBuilder::collectImageState(
LOS::LibraryOptionsState &state) const {
if (m_serverType == ServerProfile::Emby) {
assignChecked(state.saveLocalMetadata, m_dialog.m_chkSaveArtwork);
} else {
assignChecked(state.saveLocalThumbnailSets, m_dialog.m_chkSaveArtwork);
}
if (m_profile.showCacheImages) {
assignChecked(state.cacheImages, m_dialog.m_chkCacheImages);
}
if (m_profile.showSaveMetadataHidden) {
assignChecked(state.saveMetadataHidden, m_dialog.m_chkSaveMetadataHidden);
}
assignChecked(state.downloadImagesInAdvance,
m_dialog.m_chkDownloadImagesInAdvance);
if (m_profile.showSaveThumbnailSets) {
assignChecked(state.saveLocalThumbnailSets, m_dialog.m_chkSaveThumbnailSets);
}
if (m_profile.showAutoGenerateChapters) {
assignChecked(state.autoGenerateChapters, m_dialog.m_chkGenerateChapters);
}
if (m_profile.showThumbnailGeneration && m_dialog.m_thumbnailScheduleCombo) {
const QString mode =
m_dialog.m_thumbnailScheduleCombo->currentData().toString();
state.enableChapterImageExtraction =
!mode.isEmpty();
state.extractChapterImagesDuringLibraryScan = (mode == "scanandtask");
} else if (m_dialog.m_chkExtractChapterImages) {
assignChecked(state.enableChapterImageExtraction,
m_dialog.m_chkExtractChapterImages);
}
if (m_profile.showThumbnailInterval) {
assignCurrentInt(state.thumbnailImagesIntervalSeconds,
m_dialog.m_thumbnailIntervalCombo);
}
if (m_profile.showAutoGenerateChapterInterval) {
assignCurrentInt(state.autoGenerateChapterIntervalMinutes,
m_dialog.m_chapterIntervalCombo);
}
if (m_profile.showIntroDetection && m_dialog.m_introDetectionCombo) {
const QString mode =
m_dialog.m_introDetectionCombo->currentData().toString();
state.enableMarkerDetection = !mode.isEmpty();
state.enableMarkerDetectionDuringLibraryScan = (mode == "scanandtask");
}
}
void LibraryCreateDialogOptionsBuilder::collectSubtitleState(
LOS::LibraryOptionsState &state) const {
assignChecked(state.saveSubtitlesWithMedia, m_dialog.m_chkSaveSubtitles);
assignChecked(state.skipSubtitlesIfAudioTrackMatches,
m_dialog.m_chkSkipIfAudioMatchesSub);
assignChecked(state.skipSubtitlesIfEmbeddedSubtitlesPresent,
m_dialog.m_chkSkipIfEmbeddedSub);
assignCurrentInt(state.subtitleDownloadMaxAgeDays, m_dialog.m_subAgeLimitCombo);
if (m_profile.showRequirePerfectSubtitleMatch) {
assignChecked(state.requirePerfectSubtitleMatch,
m_dialog.m_chkRequirePerfectSubtitleMatch);
}
if (m_profile.showForcedSubtitlesOnly) {
assignChecked(state.forcedSubtitlesOnly,
m_dialog.m_chkForcedSubtitlesOnly);
}
state.subtitleDownloadLanguages = m_dialog.selectedSubtitleLanguages();
}
void LibraryCreateDialogOptionsBuilder::collectLyricsState(
LOS::LibraryOptionsState &state) const {
if (!m_profile.showLyricsSection) {
return;
}
assignChecked(state.saveLyricsWithMedia, m_dialog.m_chkSaveLyrics);
assignCurrentInt(state.lyricsDownloadMaxAgeDays, m_dialog.m_lyricsAgeLimitCombo);
state.lyricsDownloadLanguages = m_dialog.selectedLyricsLanguages();
}
void LibraryCreateDialogOptionsBuilder::collectPlaybackState(
LOS::LibraryOptionsState &state) const {
if (m_dialog.m_spinMinResumePercent) {
state.minResumePct = m_dialog.m_spinMinResumePercent->value();
}
if (m_dialog.m_spinMaxResumePercent) {
state.maxResumePct = m_dialog.m_spinMaxResumePercent->value();
}
if (m_dialog.m_spinMinResumeDuration) {
state.minResumeDurationSeconds = m_dialog.m_spinMinResumeDuration->value();
}
}
void LibraryCreateDialogOptionsBuilder::collectFetcherState(
LOS::LibraryOptionsState &state) const {
state.metadataFetchers = collectFetcherSelection(
m_dialog.m_metadataFetcherRows, LTU::FetcherKind::Metadata);
state.seasonMetadataFetchers = collectFetcherSelection(
m_dialog.m_seasonMetadataFetcherRows, LTU::FetcherKind::Metadata);
state.episodeMetadataFetchers = collectFetcherSelection(
m_dialog.m_episodeMetadataFetcherRows, LTU::FetcherKind::Metadata);
state.imageFetchers = collectFetcherSelection(m_dialog.m_imageFetcherRows,
LTU::FetcherKind::Image);
state.seasonImageFetchers = collectFetcherSelection(
m_dialog.m_seasonImageFetcherRows, LTU::FetcherKind::Image);
state.episodeImageFetchers = collectFetcherSelection(
m_dialog.m_episodeImageFetcherRows, LTU::FetcherKind::Image);
state.subtitleFetchers = collectFetcherSelection(
m_dialog.m_subtitleDownloaderRows, LTU::FetcherKind::Subtitle);
state.lyricsFetchers = collectFetcherSelection(
m_dialog.m_lyricsFetcherRows, LTU::FetcherKind::Lyrics);
}
LOS::FetcherSelection LibraryCreateDialogOptionsBuilder::collectFetcherSelection(
const QList<FetcherRowWidget *> &rows, LTU::FetcherKind kind) const {
LOS::FetcherSelection selection;
for (auto *row : rows) {
if (!row ||
!LTU::isFetcherApplicable(row->name(), kind, m_profile,
m_serverType)) {
continue;
}
if (row->isEnabled()) {
selection.enabledNames.append(row->name());
} else {
selection.disabledNames.append(row->name());
}
}
return selection;
}

View File

@@ -0,0 +1,47 @@
#ifndef LIBRARYCREATEDLG_P_H
#define LIBRARYCREATEDLG_P_H
#include "../utils/libraryoptionsserializer.h"
#include "../utils/librarytypeprofile.h"
#include <QList>
#include <QString>
#include <optional>
class FetcherRowWidget;
class LibraryCreateDialog;
class ModernComboBox;
class ModernSwitch;
class LibraryCreateDialogOptionsBuilder {
public:
LibraryCreateDialogOptionsBuilder(
const LibraryCreateDialog& dialog,
const LibraryTypeUtils::LibraryTypeProfile& profile,
ServerProfile::ServerType serverType);
LibraryOptionsSerializer::LibraryOptionsState build(const QString& contentType) const;
private:
static void assignChecked(std::optional<bool>& target,
const ModernSwitch* toggle);
static void assignCurrentInt(std::optional<int>& target,
const ModernComboBox* combo);
void collectBasicState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectMetadataState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectAdvancedState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectImageState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectSubtitleState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectLyricsState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectPlaybackState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
void collectFetcherState(LibraryOptionsSerializer::LibraryOptionsState& state) const;
LibraryOptionsSerializer::FetcherSelection collectFetcherSelection(
const QList<FetcherRowWidget*>& rows,
LibraryTypeUtils::FetcherKind kind) const;
const LibraryCreateDialog& m_dialog;
const LibraryTypeUtils::LibraryTypeProfile& m_profile;
ServerProfile::ServerType m_serverType;
};
#endif

View File

@@ -0,0 +1,61 @@
#include "libraryeditdlg.h"
#include <qembycore.h>
namespace {
QStringList normalizePaths(const QStringList &paths) {
QStringList result;
for (const QString &path : paths) {
const QString trimmed = path.trimmed();
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
result.append(trimmed);
}
}
return result;
}
}
LibraryEditDialog::LibraryEditDialog(const VirtualFolder &folder,
QEmbyCore *core,
const QStringList &metadataFetchers,
const QStringList &imageFetchers,
const QStringList &subtitleDownloaders,
const QStringList &lyricsFetchers,
const QList<LocalizationCulture> &cultures,
const QList<LocalizationCountry> &countries,
QWidget *parent)
: LibraryCreateDialog(core, metadataFetchers, imageFetchers,
subtitleDownloaders, lyricsFetchers,
cultures, countries, parent),
m_originalPaths(normalizePaths(folder.locations)) {
setTitle(tr("Edit Library — \"%1\"").arg(folder.name));
setConfirmButtonText(tr("Save"));
loadFromFolder(folder);
setTypeEditable(false);
}
QString LibraryEditDialog::newName() const { return libraryName(); }
QStringList LibraryEditDialog::addedPaths() const {
const QStringList currentPaths = normalizePaths(paths());
QStringList added;
for (const QString &path : currentPaths) {
if (!m_originalPaths.contains(path)) {
added.append(path);
}
}
return added;
}
QStringList LibraryEditDialog::removedPaths() const {
const QStringList currentPaths = normalizePaths(paths());
QStringList removed;
for (const QString &path : m_originalPaths) {
if (!currentPaths.contains(path)) {
removed.append(path);
}
}
return removed;
}

View File

@@ -0,0 +1,29 @@
#ifndef LIBRARYEDITDLG_H
#define LIBRARYEDITDLG_H
#include "librarycreatedlg.h"
class QEmbyCore;
class LibraryEditDialog : public LibraryCreateDialog {
Q_OBJECT
public:
explicit LibraryEditDialog(const VirtualFolder& folder,
QEmbyCore* core,
const QStringList& metadataFetchers = {},
const QStringList& imageFetchers = {},
const QStringList& subtitleDownloaders = {},
const QStringList& lyricsFetchers = {},
const QList<LocalizationCulture>& cultures = {},
const QList<LocalizationCountry>& countries = {},
QWidget *parent = nullptr);
QString newName() const;
QStringList addedPaths() const;
QStringList removedPaths() const;
private:
QStringList m_originalPaths;
};
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
#ifndef LIBRARYMETADATAEDITDIALOG_H
#define LIBRARYMETADATAEDITDIALOG_H
#include "collapsiblesection.h"
#include "moderndialogbase.h"
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QStringList>
class QCheckBox;
class QGridLayout;
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QVBoxLayout;
class LibraryMetadataEditDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit LibraryMetadataEditDialog(QJsonObject itemData,
QWidget* parent = nullptr);
QJsonObject updatedItemData() const;
private:
struct LockedFieldOption {
QString key;
QString label;
QCheckBox* checkBox = nullptr;
};
QLineEdit* createLineEdit(QString placeholderText = QString(),
bool readOnly = false) const;
QGridLayout* createSectionForm(CollapsibleSection* section) const;
QLabel* addFormRow(QGridLayout* formLayout, int row, QString labelText,
QWidget* field, Qt::Alignment labelAlignment =
Qt::AlignLeft | Qt::AlignVCenter) const;
void addLockedFieldOption(QVBoxLayout* layout, QString key,
QString labelText);
QString providerIdValue(const QStringList& keys) const;
void setProviderIdValue(QJsonObject& providerIds, const QStringList& keys,
QString value) const;
QStringList multiValueList(const QLineEdit* edit) const;
QStringList valuesFromJson(const QJsonValue& value) const;
QString joinedValues(const QJsonValue& value) const;
QString currentTagline() const;
QString normalizedPremiereDate(QString dateText) const;
QString displayPath() const;
QStringList lockedFieldKeys() const;
bool shouldShowSeriesMetadata() const;
bool shouldShowMusicMetadata() const;
bool shouldShowPersonMetadata() const;
bool shouldShowChannelMetadata() const;
bool shouldShowIndexMetadata() const;
bool shouldShowRuntimeMetadata() const;
void populateForm();
void populateLockedFieldChecks();
void updateSaveButtonState();
QJsonObject m_itemData;
QLineEdit* m_pathEdit = nullptr;
QLineEdit* m_nameEdit = nullptr;
QLineEdit* m_originalTitleEdit = nullptr;
QLineEdit* m_sortNameEdit = nullptr;
QLineEdit* m_taglineEdit = nullptr;
QLineEdit* m_dateAddedEdit = nullptr;
QLineEdit* m_genresEdit = nullptr;
QLineEdit* m_tagsEdit = nullptr;
QLineEdit* m_studiosEdit = nullptr;
QLineEdit* m_productionYearEdit = nullptr;
QLineEdit* m_premiereDateEdit = nullptr;
QLineEdit* m_endDateEdit = nullptr;
QLineEdit* m_statusEdit = nullptr;
QLineEdit* m_runtimeMinutesEdit = nullptr;
QLineEdit* m_officialRatingEdit = nullptr;
QLineEdit* m_customRatingEdit = nullptr;
QLineEdit* m_communityRatingEdit = nullptr;
QLineEdit* m_criticRatingEdit = nullptr;
QLineEdit* m_imdbIdEdit = nullptr;
QLineEdit* m_movieDbIdEdit = nullptr;
QLineEdit* m_tvdbIdEdit = nullptr;
QLineEdit* m_albumEdit = nullptr;
QLineEdit* m_albumArtistEdit = nullptr;
QLineEdit* m_artistsEdit = nullptr;
QLineEdit* m_composersEdit = nullptr;
QLineEdit* m_channelNumberEdit = nullptr;
QLineEdit* m_placeOfBirthEdit = nullptr;
QLineEdit* m_parentIndexNumberEdit = nullptr;
QLineEdit* m_indexNumberEdit = nullptr;
QLineEdit* m_preferredMetadataLanguageEdit = nullptr;
QLineEdit* m_metadataCountryCodeEdit = nullptr;
QPlainTextEdit* m_overviewEdit = nullptr;
QCheckBox* m_lockDataCheck = nullptr;
QList<LockedFieldOption> m_lockedFieldOptions;
QPushButton* m_saveButton = nullptr;
};
#endif

View File

@@ -0,0 +1,48 @@
#include "licensesdialog.h"
#include <QFile>
#include <QFrame>
#include <QScrollBar>
#include <QStyle>
#include <QTextBrowser>
#include <QTextStream>
#include <QVBoxLayout>
LicensesDialog::LicensesDialog(QWidget *parent)
: ModernDialogBase(parent) {
setObjectName(QStringLiteral("LicensesDialog"));
setTitle(tr("Open Source Licenses"));
setModal(true);
setMinimumSize(480, 400);
resize(560, 520);
auto *content = contentLayout();
content->setContentsMargins(0, 0, 0, 0);
content->setSpacing(0);
auto *browser = new QTextBrowser(this);
browser->setObjectName(QStringLiteral("AboutLicenseBrowser"));
browser->setOpenExternalLinks(true);
browser->setReadOnly(true);
browser->setFrameShape(QFrame::NoFrame);
browser->document()->setDocumentMargin(20);
QScrollBar *vBar = browser->verticalScrollBar();
vBar->setObjectName(QStringLiteral("AboutLicenseScrollBar"));
vBar->style()->unpolish(vBar);
vBar->style()->polish(vBar);
QFile licenseFile(QStringLiteral(":/html/licenses.html"));
if (licenseFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
QTextStream in(&licenseFile);
in.setEncoding(QStringConverter::Utf8);
browser->setHtml(in.readAll());
licenseFile.close();
}
content->addWidget(browser);
}

View File

@@ -0,0 +1,18 @@
#ifndef LICENSESDIALOG_H
#define LICENSESDIALOG_H
#include "moderndialogbase.h"
class LicensesDialog : public ModernDialogBase {
Q_OBJECT
public:
explicit LicensesDialog(QWidget *parent = nullptr);
};
#endif

View File

@@ -0,0 +1,185 @@
#include "loadingoverlay.h"
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QEasingCurve>
#include <QConicalGradient>
#include <QLinearGradient>
#include <QMouseEvent>
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_TranslucentBackground);
hide();
m_rotateAnim = new QPropertyAnimation(this, "angle", this);
m_rotateAnim->setStartValue(0.0);
m_rotateAnim->setEndValue(360.0);
m_rotateAnim->setDuration(1000);
m_rotateAnim->setLoopCount(-1);
m_fadeAnim = new QPropertyAnimation(this, "opacity", this);
m_fadeAnim->setDuration(300);
m_fadeAnim->setEasingCurve(QEasingCurve::InOutQuad);
}
void LoadingOverlay::start() {
show();
raise();
m_rotateAnim->start();
m_fadeAnim->disconnect();
m_fadeAnim->setStartValue(m_opacity);
m_fadeAnim->setEndValue(1.0);
m_fadeAnim->start();
}
void LoadingOverlay::stop() {
m_fadeAnim->disconnect();
connect(m_fadeAnim, &QAbstractAnimation::finished, this, [this]() {
hide();
m_rotateAnim->stop();
});
m_fadeAnim->setStartValue(m_opacity);
m_fadeAnim->setEndValue(0.0);
m_fadeAnim->start();
}
void LoadingOverlay::forceStop() {
m_fadeAnim->stop();
m_rotateAnim->stop();
m_opacity = 0.0;
hide();
update();
}
qreal LoadingOverlay::opacity() const { return m_opacity; }
void LoadingOverlay::setOpacity(qreal op) { m_opacity = op; update(); }
qreal LoadingOverlay::angle() const { return m_angle; }
void LoadingOverlay::setAngle(qreal a) { m_angle = a; update(); }
bool LoadingOverlay::isImmersive() const { return m_isImmersive; }
void LoadingOverlay::setImmersive(bool immersive) {
m_isImmersive = immersive;
update();
}
bool LoadingOverlay::isHudPanelVisible() const { return m_hudPanelVisible; }
void LoadingOverlay::setHudPanelVisible(bool visible) {
if (m_hudPanelVisible == visible) {
return;
}
m_hudPanelVisible = visible;
update();
}
bool LoadingOverlay::isSubtleOverlay() const { return m_subtleOverlay; }
void LoadingOverlay::setSubtleOverlay(bool subtle) {
if (m_subtleOverlay == subtle) {
return;
}
m_subtleOverlay = subtle;
update();
}
void LoadingOverlay::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.setOpacity(m_opacity);
if (m_isImmersive) {
p.fillRect(rect(), QColor(0, 0, 0, 60));
} else {
QColor overlayColor = m_overlayBgColor;
if (m_subtleOverlay) {
overlayColor.setAlpha(qMax(8, qRound(overlayColor.alpha() * 0.35)));
}
p.fillRect(rect(), overlayColor);
}
p.translate(width() / 2.0, height() / 2.0);
qreal radius = 24.0;
qreal thickness = 3.5;
if (!m_isImmersive && m_hudPanelVisible) {
int hudSize = 90;
QRectF hudRect(-hudSize / 2.0, -hudSize / 2.0, hudSize, hudSize);
QPainterPath shadowPath;
shadowPath.addRoundedRect(hudRect.translated(0, 8), 16, 16);
p.fillPath(shadowPath, m_shadowColor);
QPainterPath hudPath;
hudPath.addRoundedRect(hudRect, 16, 16);
QLinearGradient hudGrad(hudRect.topLeft(), hudRect.bottomRight());
hudGrad.setColorAt(0.0, m_hudColorTop);
hudGrad.setColorAt(0.5, m_hudColorMid);
hudGrad.setColorAt(1.0, m_hudColorBottom);
p.fillPath(hudPath, hudGrad);
QLinearGradient borderGrad(hudRect.topLeft(), hudRect.bottomLeft());
borderGrad.setColorAt(0.0, m_borderColor);
QColor transparentBorder = m_borderColor;
transparentBorder.setAlpha(qMax(0, m_borderColor.alpha() - 50));
borderGrad.setColorAt(1.0, transparentBorder);
QPen borderPen(QBrush(borderGrad), 1.0);
p.setPen(borderPen);
p.drawPath(hudPath);
} else if (m_isImmersive) {
radius = 36.0;
thickness = 4.0;
} else {
radius = 30.0;
thickness = 3.8;
}
QRectF ringRect(-radius, -radius, radius * 2, radius * 2);
QColor currentTrackColor = m_isImmersive ? QColor(255, 255, 255, 30) : m_trackColor;
QColor currentSweepColor = m_isImmersive ? QColor(255, 255, 255, 255) : m_sweepColor;
QPen trackPen(currentTrackColor);
trackPen.setWidthF(thickness);
p.setPen(trackPen);
p.drawEllipse(ringRect);
QConicalGradient grad(0, 0, -m_angle);
QColor sweepFull = currentSweepColor;
QColor sweepMed = currentSweepColor;
sweepMed.setAlpha(static_cast<int>(currentSweepColor.alpha() * 0.7));
QColor sweepZero = currentSweepColor;
sweepZero.setAlpha(0);
grad.setColorAt(0.0, sweepFull);
grad.setColorAt(0.1, sweepMed);
grad.setColorAt(0.75, sweepZero);
grad.setColorAt(1.0, sweepZero);
QPen sweepPen(QBrush(grad), thickness);
sweepPen.setCapStyle(Qt::RoundCap);
p.setPen(sweepPen);
p.drawArc(ringRect, qRound(-m_angle * 16.0), 280 * 16);
}
void LoadingOverlay::mousePressEvent(QMouseEvent* event) {
if (!m_isImmersive) event->accept();
}
void LoadingOverlay::mouseReleaseEvent(QMouseEvent* event) {
if (!m_isImmersive) event->accept();
}
void LoadingOverlay::mouseDoubleClickEvent(QMouseEvent* event) {
if (!m_isImmersive) event->accept();
}

View File

@@ -0,0 +1,102 @@
#ifndef LOADINGOVERLAY_H
#define LOADINGOVERLAY_H
#include <QWidget>
#include <QColor>
class QPropertyAnimation;
class LoadingOverlay : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity)
Q_PROPERTY(qreal angle READ angle WRITE setAngle)
Q_PROPERTY(QColor overlayBgColor READ overlayBgColor WRITE setOverlayBgColor)
Q_PROPERTY(QColor hudColorTop READ hudColorTop WRITE setHudColorTop)
Q_PROPERTY(QColor hudColorMid READ hudColorMid WRITE setHudColorMid)
Q_PROPERTY(QColor hudColorBottom READ hudColorBottom WRITE setHudColorBottom)
Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor)
Q_PROPERTY(QColor trackColor READ trackColor WRITE setTrackColor)
Q_PROPERTY(QColor sweepColor READ sweepColor WRITE setSweepColor)
Q_PROPERTY(QColor shadowColor READ shadowColor WRITE setShadowColor)
Q_PROPERTY(bool isImmersive READ isImmersive WRITE setImmersive)
Q_PROPERTY(bool hudPanelVisible READ isHudPanelVisible WRITE setHudPanelVisible)
Q_PROPERTY(bool subtleOverlay READ isSubtleOverlay WRITE setSubtleOverlay)
public:
explicit LoadingOverlay(QWidget* parent = nullptr);
void start();
void stop();
void forceStop();
qreal opacity() const;
void setOpacity(qreal op);
qreal angle() const;
void setAngle(qreal a);
bool isImmersive() const;
void setImmersive(bool immersive);
bool isHudPanelVisible() const;
void setHudPanelVisible(bool visible);
bool isSubtleOverlay() const;
void setSubtleOverlay(bool subtle);
QColor overlayBgColor() const { return m_overlayBgColor; }
void setOverlayBgColor(const QColor& c) { m_overlayBgColor = c; update(); }
QColor hudColorTop() const { return m_hudColorTop; }
void setHudColorTop(const QColor& c) { m_hudColorTop = c; update(); }
QColor hudColorMid() const { return m_hudColorMid; }
void setHudColorMid(const QColor& c) { m_hudColorMid = c; update(); }
QColor hudColorBottom() const { return m_hudColorBottom; }
void setHudColorBottom(const QColor& c) { m_hudColorBottom = c; update(); }
QColor borderColor() const { return m_borderColor; }
void setBorderColor(const QColor& c) { m_borderColor = c; update(); }
QColor trackColor() const { return m_trackColor; }
void setTrackColor(const QColor& c) { m_trackColor = c; update(); }
QColor sweepColor() const { return m_sweepColor; }
void setSweepColor(const QColor& c) { m_sweepColor = c; update(); }
QColor shadowColor() const { return m_shadowColor; }
void setShadowColor(const QColor& c) { m_shadowColor = c; update(); }
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override;
private:
qreal m_opacity = 0.0;
qreal m_angle = 0.0;
bool m_isImmersive = false;
bool m_hudPanelVisible = true;
bool m_subtleOverlay = false;
QPropertyAnimation* m_rotateAnim;
QPropertyAnimation* m_fadeAnim;
QColor m_overlayBgColor = QColor(0, 0, 0, 100);
QColor m_hudColorTop = QColor(70, 70, 75, 170);
QColor m_hudColorMid = QColor(40, 40, 45, 170);
QColor m_hudColorBottom = QColor(20, 20, 20, 170);
QColor m_borderColor = QColor(255, 255, 255, 55);
QColor m_trackColor = QColor(255, 255, 255, 12);
QColor m_sweepColor = QColor(255, 255, 255, 255);
QColor m_shadowColor = QColor(0, 0, 0, 60);
};
#endif

View File

@@ -0,0 +1,272 @@
#include "mediaactionmenu.h"
#include "../managers/thememanager.h"
#include "../managers/externalplayerdetector.h"
#include "../utils/mediaidentifyutils.h"
#include "../utils/mediaitemutils.h"
#include "../utils/playlistutils.h"
#include <qembycore.h>
#include <services/manager/servermanager.h>
#include <models/profile/serverprofile.h>
#include <QAction>
#include <QIcon>
#include <QFile>
#include <QFileInfo>
#include <QSet>
#include <QVariant>
#include <config/config_keys.h>
#include <config/configstore.h>
MediaActionMenu::MediaActionMenu(const MediaItem& item, QEmbyCore* core,
QWidget *parent,
const QList<CardContextMenuAction> &allowedActions)
: QMenu(parent), m_item(item), m_core(core), m_allowedActions(allowedActions)
{
setObjectName("media-action-menu");
setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setupMenu();
}
CardContextMenuRequest MediaActionMenu::execRequest(const QPoint& globalPos)
{
return requestFromAction(exec(globalPos));
}
QAction* MediaActionMenu::addMenuAction(CardContextMenuAction action,
const QIcon& icon,
const QString& text,
const QString& stringValue)
{
auto* menuAction = new QAction(icon, text, this);
CardContextMenuRequest request;
request.action = action;
request.stringValue = stringValue;
menuAction->setData(QVariant::fromValue(request));
addAction(menuAction);
return menuAction;
}
CardContextMenuRequest MediaActionMenu::requestFromAction(const QAction* action)
{
if (!action) {
return {};
}
const QVariant requestData = action->data();
if (!requestData.canConvert<CardContextMenuRequest>()) {
return {};
}
return requestData.value<CardContextMenuRequest>();
}
void MediaActionMenu::setupMenu()
{
QString themeDir = ThemeManager::instance()->isDarkMode() ? "dark" : "light";
auto adaptiveIcon = [&themeDir](const QString& baseName) -> QIcon {
QString themePath = QString(":/svg/%1/%2").arg(themeDir, baseName);
if (QFile::exists(themePath))
return QIcon(themePath);
return ThemeManager::getAdaptiveIcon(QString(":/svg/player/%1").arg(baseName));
};
auto maybeAdd = [this](CardContextMenuAction action, const QIcon &icon,
const QString &text,
const QString &stringValue = QString()) -> QAction * {
if (!m_allowedActions.isEmpty() && !m_allowedActions.contains(action))
return nullptr;
return addMenuAction(action, icon, text, stringValue);
};
const auto activeProfile =
m_core && m_core->serverManager()
? m_core->serverManager()->activeProfile()
: ServerProfile{};
const bool canIdentifyMedia =
activeProfile.isValid() && activeProfile.isAdmin &&
MediaIdentifyUtils::canIdentify(m_item);
const bool canEditMetadata =
activeProfile.isValid() && activeProfile.isAdmin &&
!m_item.id.trimmed().isEmpty() && m_item.type != "Playlist" &&
m_item.type != "Person";
const bool canEditImages =
activeProfile.isValid() && activeProfile.isAdmin &&
!m_item.id.trimmed().isEmpty() && m_item.type != "Playlist" &&
m_item.type != "Person";
const bool canRefreshMetadata =
activeProfile.isValid() && activeProfile.isAdmin &&
!m_item.id.trimmed().isEmpty() && m_item.type != "Playlist" &&
m_item.type != "Person";
const bool canRemoveMedia =
activeProfile.isValid() && activeProfile.isAdmin &&
!m_item.id.trimmed().isEmpty() && m_item.type != "Playlist" &&
m_item.type != "Person";
const bool canDownloadMedia =
activeProfile.isValid() && activeProfile.canDownloadMedia &&
!m_item.id.trimmed().isEmpty() && m_item.canDownload;
if (m_item.type == "Playlist") {
maybeAdd(CardContextMenuAction::DeletePlaylist,
adaptiveIcon("remove.svg"), tr("Delete Playlist"));
return;
}
bool isPlayable = (m_item.mediaType == "Video" ||
m_item.type == "Movie" ||
m_item.type == "Episode" ||
m_item.type == "MusicVideo");
if (isPlayable) {
maybeAdd(CardContextMenuAction::Play, adaptiveIcon("play.svg"),
tr("Play"));
bool extEnabled = ConfigStore::instance()->get<bool>(ConfigKeys::ExtPlayerEnable, false);
if (extEnabled) {
QString currentPath = ConfigStore::instance()->get<QString>(ConfigKeys::ExtPlayerPath);
QList<DetectedPlayer> allPlayers = ExternalPlayerDetector::loadFromConfig();
QSet<QString> knownPaths;
for (const auto &p : allPlayers)
knownPaths.insert(p.path);
if (!currentPath.isEmpty() && currentPath != "custom" &&
!knownPaths.contains(currentPath) && QFileInfo::exists(currentPath)) {
allPlayers.prepend({QFileInfo(currentPath).baseName(), currentPath});
}
if (!allPlayers.isEmpty()) {
QString activePlayerPath = currentPath;
if (activePlayerPath.isEmpty() || activePlayerPath == "custom") {
activePlayerPath = allPlayers.first().path;
}
QString playerName;
for (const auto &p : allPlayers) {
if (p.path == activePlayerPath) {
playerName = p.name;
break;
}
}
if (playerName.isEmpty())
playerName = QFileInfo(activePlayerPath).baseName();
maybeAdd(CardContextMenuAction::ExternalPlay,
adaptiveIcon("external-player.svg"),
tr("Play with %1").arg(playerName),
activePlayerPath);
}
}
}
maybeAdd(CardContextMenuAction::ViewDetails, adaptiveIcon("info.svg"),
tr("View Details"));
if (canDownloadMedia) {
maybeAdd(CardContextMenuAction::Download,
adaptiveIcon("download-menu.svg"), tr("Download"));
}
if (PlaylistUtils::canAddItemToPlaylist(m_item)) {
maybeAdd(CardContextMenuAction::AddToPlaylist,
adaptiveIcon("playlist-add.svg"),
tr("Add to Playlist"));
}
if (!m_item.playlistId.trimmed().isEmpty() &&
!m_item.playlistItemId.trimmed().isEmpty()) {
maybeAdd(CardContextMenuAction::RemoveFromPlaylist,
adaptiveIcon("remove.svg"),
tr("Remove from Playlist"));
}
addSeparator();
QString favText = m_item.isFavorite() ? tr("Remove from Favorites") : tr("Add to Favorites");
QString favIconName = m_item.isFavorite() ? "heart-fill.svg" : "heart-outline.svg";
maybeAdd(CardContextMenuAction::ToggleFavorite,
adaptiveIcon(favIconName), favText);
QString playedText = m_item.userData.played ? tr("Mark as Unplayed") : tr("Mark as Played");
QString playedIconName = m_item.userData.played ? "played.svg" : "unplayed.svg";
maybeAdd(m_item.userData.played ? CardContextMenuAction::MarkUnplayed
: CardContextMenuAction::MarkPlayed,
adaptiveIcon(playedIconName), playedText);
const bool canRemoveFromResume =
MediaItemUtils::canRemoveFromResume(m_item);
bool isJellyfin = false;
if (m_core && m_core->serverManager() && m_core->serverManager()->activeProfile().isValid()) {
isJellyfin = (m_core->serverManager()->activeProfile().type == ServerProfile::Jellyfin);
}
if (canRemoveFromResume && !isJellyfin) {
maybeAdd(CardContextMenuAction::RemoveFromResume,
adaptiveIcon("remove.svg"),
tr("Remove from Continue Watching"));
}
if (canEditMetadata || canEditImages || canRefreshMetadata || canIdentifyMedia ||
canRemoveMedia) {
addSeparator();
if (canEditMetadata) {
maybeAdd(CardContextMenuAction::EditMetadata,
adaptiveIcon("edit.svg"),
tr("Edit Metadata"));
}
if (canEditImages) {
maybeAdd(CardContextMenuAction::EditImages,
adaptiveIcon("edit-images.svg"),
tr("Edit Images"));
}
if (canRefreshMetadata) {
maybeAdd(CardContextMenuAction::RefreshMetadata,
adaptiveIcon("refresh.svg"),
tr("Refresh Metadata"));
}
if (canIdentifyMedia) {
maybeAdd(CardContextMenuAction::Identify,
adaptiveIcon("search.svg"), tr("Identify"));
}
}
if (canRemoveMedia) {
maybeAdd(CardContextMenuAction::RemoveMedia,
adaptiveIcon("remove.svg"), tr("Remove Media"));
}
}

View File

@@ -0,0 +1,38 @@
#ifndef MEDIAACTIONMENU_H
#define MEDIAACTIONMENU_H
#include "cardcontextmenurequest.h"
#include <QList>
#include <QMenu>
#include <models/media/mediaitem.h>
class QAction;
class QIcon;
class QPoint;
class QEmbyCore;
class MediaActionMenu : public QMenu
{
Q_OBJECT
public:
explicit MediaActionMenu(const MediaItem& item, QEmbyCore* core,
QWidget *parent = nullptr,
const QList<CardContextMenuAction> &allowedActions = {});
CardContextMenuRequest execRequest(const QPoint& globalPos);
private:
QAction* addMenuAction(CardContextMenuAction action, const QIcon& icon,
const QString& text,
const QString& stringValue = QString());
void setupMenu();
static CardContextMenuRequest requestFromAction(const QAction* action);
MediaItem m_item;
QEmbyCore* m_core;
QList<CardContextMenuAction> m_allowedActions;
};
#endif

View File

@@ -0,0 +1,402 @@
#include "mediagridwidget.h"
#include "shimmerwidget.h"
#include "../utils/smoothscrollcontroller.h"
#include "../utils/textwraputils.h"
#include "../views/media/medialistmodel.h"
#include <QVBoxLayout>
#include <QListView>
#include <QResizeEvent>
#include <QApplication>
#include <QStyle>
#include <QScroller>
#include <QScrollerProperties>
#include <QWheelEvent>
#include <QScrollBar>
#include <QSet>
#include <QStyleOptionViewItem>
#include <algorithm>
MediaGridWidget::MediaGridWidget(QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core), m_basePadding(20), m_currentStyle(MediaCardDelegate::Poster),
m_vScrollController(nullptr)
{
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
m_listView = new QListView(this);
m_listView->setSelectionMode(QAbstractItemView::NoSelection);
m_listView->setViewMode(QListView::IconMode);
m_listView->setResizeMode(QListView::Adjust);
m_listView->setMovement(QListView::Static);
m_listView->setSpacing(0);
m_listView->setUniformItemSizes(true);
m_listView->setWrapping(true);
m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_listView->setMouseTracking(true);
m_listView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_listView->viewport()->setAttribute(Qt::WA_Hover);
m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
QScroller::grabGesture(m_listView->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_listView->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_vScrollController =
new SmoothScrollController(m_listView->verticalScrollBar(), this);
m_vScrollController->setDuration(160);
m_listView->viewport()->installEventFilter(this);
m_listModel = new MediaListModel(400, m_core, this);
m_listDelegate = new MediaCardDelegate(m_currentStyle, this);
m_listView->setModel(m_listModel);
m_listView->setItemDelegate(m_listDelegate);
layout->addWidget(m_listView);
m_shimmer = new ShimmerWidget(this);
m_shimmer->hide();
connect(m_listView, &QListView::clicked, this, [this](const QModelIndex& index) {
Q_EMIT itemClicked(m_listModel->getItem(index));
});
connect(m_listView->verticalScrollBar(), &QScrollBar::valueChanged, this,
[this](int) {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
});
connect(m_listDelegate, &MediaCardDelegate::playRequested, this, &MediaGridWidget::playRequested);
connect(m_listDelegate, &MediaCardDelegate::favoriteRequested, this, &MediaGridWidget::favoriteRequested);
connect(m_listDelegate, &MediaCardDelegate::moreMenuRequested, this, &MediaGridWidget::moreMenuRequested);
}
void MediaGridWidget::setBasePadding(int padding) {
m_basePadding = padding;
adjustGrid();
}
void MediaGridWidget::setCardStyle(MediaCardDelegate::CardStyle style) {
if (m_currentStyle == style) return;
m_currentStyle = style;
m_listDelegate->setStyle(style);
const bool preferThumb =
style == MediaCardDelegate::LibraryTile ||
style == MediaCardDelegate::EpisodeList;
m_listModel->setPreferThumb(preferThumb);
m_listModel->clearImageCache();
if (style == MediaCardDelegate::EpisodeList) {
m_listView->setViewMode(QListView::ListMode);
m_listView->setFlow(QListView::TopToBottom);
m_listView->setWrapping(false);
m_listView->setSpacing(5);
} else {
m_listView->setViewMode(QListView::IconMode);
m_listView->setFlow(QListView::LeftToRight);
m_listView->setWrapping(true);
m_listView->setSpacing(0);
}
adjustGrid();
updateVisibleImagePriority();
}
void MediaGridWidget::setLoading(bool loading)
{
if (!m_shimmer) {
return;
}
if (loading) {
QStyleOptionViewItem opt;
const QSize cardSize = m_listDelegate->sizeHint(opt, QModelIndex());
m_shimmer->setCardSize(cardSize);
m_shimmer->setShowSubtitle(false);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
m_shimmer->startAnimation();
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void MediaGridWidget::setItems(const QList<MediaItem>& items) {
m_listModel->setItems(items);
adjustGrid();
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
if (!items.isEmpty()) {
QMetaObject::invokeMethod(
this,
[this]() {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
},
Qt::QueuedConnection);
}
}
void MediaGridWidget::updateItem(const MediaItem& item) {
if (m_listModel) {
m_listModel->updateItem(item);
}
}
void MediaGridWidget::prependOrUpdateItem(const MediaItem& item, int maxItems) {
if (m_listModel) {
m_listModel->prependOrUpdateItem(item, maxItems);
QMetaObject::invokeMethod(
this,
[this]() {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
},
Qt::QueuedConnection);
}
}
void MediaGridWidget::removeItem(const QString& itemId) {
if (m_listModel) {
m_listModel->removeItem(itemId);
}
}
int MediaGridWidget::itemCount() const {
return m_listModel ? m_listModel->rowCount() : 0;
}
int MediaGridWidget::saveScrollPosition() const {
if (m_listView && m_listView->verticalScrollBar())
return m_listView->verticalScrollBar()->value();
return 0;
}
void MediaGridWidget::restoreScrollPosition(int pos) {
if (m_listView && m_listView->verticalScrollBar()) {
if (m_vScrollController) {
m_vScrollController->scrollTo(pos, false);
} else {
m_listView->verticalScrollBar()->setValue(pos);
}
}
}
void MediaGridWidget::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
adjustGrid();
if (m_shimmer && m_shimmer->isVisible()) {
m_shimmer->setGeometry(m_listView->geometry());
}
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
}
bool MediaGridWidget::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_listView->viewport() && event->type() == QEvent::ToolTip) {
return TextWrapUtils::showWrappedMediaItemToolTip(m_listView, event);
}
if (event->type() == QEvent::Wheel && obj == m_listView->viewport()) {
QWheelEvent* we = static_cast<QWheelEvent*>(event);
if (qAbs(we->angleDelta().y()) >= qAbs(we->angleDelta().x())) {
if (m_vScrollController) {
m_vScrollController->scrollByWheelEvent(we, Qt::Vertical);
}
return true;
}
}
return QWidget::eventFilter(obj, event);
}
void MediaGridWidget::notifyLoadMoreIfNeeded()
{
if (!m_listView || !m_listModel || m_listModel->rowCount() <= 0) {
return;
}
QScrollBar* vBar = m_listView->verticalScrollBar();
if (!vBar) {
return;
}
constexpr int kLoadMoreThreshold = 160;
const int remaining = vBar->maximum() - vBar->value();
if (vBar->maximum() <= 0 || remaining <= kLoadMoreThreshold) {
Q_EMIT loadMoreRequested();
}
}
void MediaGridWidget::updateVisibleImagePriority()
{
if (!m_listView || !m_listModel) {
return;
}
QWidget* viewport = m_listView->viewport();
if (!viewport) {
return;
}
QStyleOptionViewItem option;
const QSize cellSize = m_listDelegate->sizeHint(option, QModelIndex());
const int stepX = qMax(1, cellSize.width() / 2);
const int stepY = qMax(1, cellSize.height() / 2);
QSet<int> rowSet;
const QRect rect = viewport->rect();
for (int y = rect.top(); y <= rect.bottom(); y += stepY) {
for (int x = rect.left(); x <= rect.right(); x += stepX) {
const QModelIndex idx = m_listView->indexAt(QPoint(x, y));
if (idx.isValid()) {
rowSet.insert(idx.row());
}
}
}
QList<int> rows = rowSet.values();
std::sort(rows.begin(), rows.end());
m_listModel->setPriorityRows(rows);
}
void MediaGridWidget::adjustGrid() {
if (!m_listView || !m_listModel) return;
int scrollBarWidth = qApp->style()->pixelMetric(QStyle::PM_ScrollBarExtent);
int availableWidth = this->width() - scrollBarWidth;
if (availableWidth < 100) availableWidth = 100;
if (m_currentStyle == MediaCardDelegate::EpisodeList) {
int padding = m_basePadding;
m_listView->setStyleSheet(QString("QListView { background: transparent; border: none; outline: none; padding-left: %1px; padding-right: 0px; }").arg(padding));
int cellWidth = availableWidth - padding - padding;
if (cellWidth < 100) cellWidth = 100;
m_listDelegate->setTileSize(QSize(cellWidth, 160));
} else {
availableWidth -= (m_basePadding * 2);
int defaultCellWidth = 150;
if (m_currentStyle == MediaCardDelegate::LibraryTile) {
defaultCellWidth = 250;
} else if (m_currentStyle == MediaCardDelegate::Cast) {
defaultCellWidth = 140;
}
int tolerance = 5;
int cols = (availableWidth + tolerance) / defaultCellWidth;
if (cols < 1) cols = 1;
int cellWidth = availableWidth / cols;
int remainder = availableWidth - (cols * cellWidth);
int leftPad = m_basePadding + remainder / 2;
m_listView->setStyleSheet(QString("QListView { background: transparent; border: none; outline: none; padding-left: %1px; padding-right: 0px; }").arg(leftPad));
int imgWidth = cellWidth - 16;
int imgHeight = imgWidth;
if (m_currentStyle == MediaCardDelegate::Poster || m_currentStyle == MediaCardDelegate::Cast) {
imgHeight = qRound(imgWidth * 1.5);
} else if (m_currentStyle == MediaCardDelegate::LibraryTile) {
imgHeight = qRound(imgWidth * 9.0 / 16.0);
}
int cellHeight = imgHeight + 60;
m_listDelegate->setTileSize(QSize(cellWidth, cellHeight));
}
m_listView->doItemsLayout();
}

View File

@@ -0,0 +1,76 @@
#ifndef MEDIAGRIDWIDGET_H
#define MEDIAGRIDWIDGET_H
#include <QWidget>
#include <QPoint>
#include <models/media/mediaitem.h>
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class QListView;
class MediaListModel;
class ShimmerWidget;
class SmoothScrollController;
class MediaGridWidget : public QWidget {
Q_OBJECT
public:
explicit MediaGridWidget(QEmbyCore* core, QWidget* parent = nullptr);
void setItems(const QList<MediaItem>& items);
void setBasePadding(int padding);
void setCardStyle(MediaCardDelegate::CardStyle style);
void setLoading(bool loading);
void updateItem(const MediaItem& item);
void prependOrUpdateItem(const MediaItem& item, int maxItems = 0);
void removeItem(const QString& itemId);
int itemCount() const;
int saveScrollPosition() const;
void restoreScrollPosition(int pos);
Q_SIGNALS:
void itemClicked(const MediaItem& item);
void loadMoreRequested();
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& globalPos);
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void adjustGrid();
void notifyLoadMoreIfNeeded();
void updateVisibleImagePriority();
int m_basePadding;
MediaCardDelegate::CardStyle m_currentStyle;
QEmbyCore* m_core;
QListView* m_listView;
MediaListModel* m_listModel;
MediaCardDelegate* m_listDelegate;
SmoothScrollController* m_vScrollController;
ShimmerWidget* m_shimmer = nullptr;
};
#endif

View File

@@ -0,0 +1,659 @@
#include "mediaidentifydialog.h"
#include "loadingoverlay.h"
#include "moderntoast.h"
#include "../managers/thememanager.h"
#include "../utils/mediaidentifyutils.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/media/mediaservice.h>
#include <QAbstractItemView>
#include <QCoreApplication>
#include <QDebug>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPixmap>
#include <QPointer>
#include <QPushButton>
#include <QResizeEvent>
#include <QShowEvent>
#include <QSignalBlocker>
#include <QSize>
#include <QSpinBox>
#include <QVariant>
#include <QVBoxLayout>
#include <stdexcept>
#include <utility>
namespace {
constexpr int kRemoteSearchResultRole = Qt::UserRole + 300;
const QSize kIdentifyPreviewSize(220, 330);
QString buildResultDisplayText(const RemoteSearchResult& result)
{
QStringList lines;
lines.append(result.name.trimmed().isEmpty()
? QCoreApplication::translate("MediaIdentifyDialog",
"Unknown Title")
: result.name.trimmed());
const QString secondaryText = result.secondaryText();
if (!secondaryText.isEmpty()) {
lines.append(secondaryText);
}
return lines.join(QLatin1Char('\n'));
}
QString buildResultToolTip(const RemoteSearchResult& result)
{
QStringList sections;
const QString providerSummary = result.providerIdSummary();
if (!providerSummary.isEmpty()) {
sections.append(
QCoreApplication::translate("MediaIdentifyDialog",
"Provider IDs: %1")
.arg(providerSummary));
}
if (!result.albumArtistName.trimmed().isEmpty()) {
sections.append(QCoreApplication::translate("MediaIdentifyDialog",
"Album Artist: %1")
.arg(result.albumArtistName.trimmed()));
}
if (!result.artistNames.isEmpty()) {
sections.append(QCoreApplication::translate("MediaIdentifyDialog",
"Artists: %1")
.arg(result.artistNames.join(QStringLiteral(", "))));
}
if (!result.overview.trimmed().isEmpty()) {
sections.append(result.overview.trimmed());
}
return sections.join(QStringLiteral("\n\n"));
}
QLineEdit* createOptionalProviderIdEdit(QWidget* parent,
const QString& placeholderText)
{
auto* edit = new QLineEdit(parent);
edit->setObjectName("PlaylistSearchEdit");
edit->setPlaceholderText(placeholderText);
edit->setClearButtonEnabled(true);
return edit;
}
QString buildProviderIdSummary(const QJsonObject& providerIds)
{
QStringList pairs;
const QStringList keys = providerIds.keys();
for (const QString& key : keys) {
const QString value = providerIds.value(key).toString().trimmed();
if (!key.trimmed().isEmpty() && !value.isEmpty()) {
pairs.append(QStringLiteral("%1=%2").arg(key.trimmed(), value));
}
}
return pairs.join(QStringLiteral(", "));
}
}
MediaIdentifyDialog::MediaIdentifyDialog(QEmbyCore* core, MediaItem item,
QWidget* parent)
: ModernDialogBase(parent),
m_core(core),
m_item(std::move(item)),
m_remoteSearchType(MediaIdentifyUtils::identifySearchType(m_item))
{
setMinimumWidth(760);
resize(760, 560);
setTitle(tr("Identify"));
contentLayout()->setSpacing(0);
const QString itemName =
m_item.name.trimmed().isEmpty() ? tr("this item") : m_item.name.trimmed();
m_promptLabel = new QLabel(
tr("Search remote metadata matches for \"%1\" and apply the correct result.")
.arg(itemName),
this);
m_promptLabel->setObjectName("dialog-text");
m_promptLabel->setWordWrap(true);
m_promptLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_promptLabel->setCursor(Qt::IBeamCursor);
contentLayout()->addWidget(m_promptLabel);
contentLayout()->addSpacing(12);
auto* searchRow = new QHBoxLayout();
searchRow->setSpacing(12);
m_searchEdit = new QLineEdit(this);
m_searchEdit->setObjectName("PlaylistSearchEdit");
m_searchEdit->setPlaceholderText(tr("Enter title or keyword"));
m_searchEdit->setClearButtonEnabled(true);
m_searchEdit->addAction(
ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/light/search.svg")),
QLineEdit::LeadingPosition);
m_searchEdit->setText(MediaIdentifyUtils::defaultSearchText(m_item));
searchRow->addWidget(m_searchEdit, 1);
m_yearSpin = new QSpinBox(this);
m_yearSpin->setObjectName("ManageLibSpinBox");
m_yearSpin->setRange(0, 9999);
m_yearSpin->setSpecialValueText(tr("Any"));
m_yearSpin->setValue(m_item.productionYear > 0 ? m_item.productionYear : 0);
m_yearSpin->setMinimumWidth(96);
searchRow->addWidget(m_yearSpin);
m_searchButton = new QPushButton(tr("Search"), this);
m_searchButton->setObjectName("dialog-btn-primary");
m_searchButton->setCursor(Qt::PointingHandCursor);
contentLayout()->addLayout(searchRow);
auto* providerIdRow = new QHBoxLayout();
providerIdRow->setContentsMargins(0, 0, 0, 0);
providerIdRow->setSpacing(12);
m_imdbIdEdit =
createOptionalProviderIdEdit(this, tr("IMDb Id (optional)"));
providerIdRow->addWidget(m_imdbIdEdit, 1);
m_movieDbIdEdit =
createOptionalProviderIdEdit(this, tr("MovieDb Id (optional)"));
providerIdRow->addWidget(m_movieDbIdEdit, 1);
m_tvdbIdEdit =
createOptionalProviderIdEdit(this, tr("TheTVDB Id (optional)"));
providerIdRow->addWidget(m_tvdbIdEdit, 1);
providerIdRow->addWidget(m_searchButton);
contentLayout()->addSpacing(8);
contentLayout()->addLayout(providerIdRow);
contentLayout()->addSpacing(10);
m_statusLabel = new QLabel(tr("Enter a title or provider ID to search."),
this);
m_statusLabel->setObjectName("dialog-text");
m_statusLabel->setWordWrap(true);
contentLayout()->addWidget(m_statusLabel);
contentLayout()->addSpacing(8);
auto* bodyLayout = new QHBoxLayout();
bodyLayout->setSpacing(20);
m_resultListContainer = new QWidget(this);
auto* listLayout = new QVBoxLayout(m_resultListContainer);
listLayout->setContentsMargins(0, 0, 0, 0);
listLayout->setSpacing(0);
m_resultList = new QListWidget(m_resultListContainer);
m_resultList->setObjectName("ManageLibPathList");
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
m_resultList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_resultList->setAlternatingRowColors(false);
m_resultList->setWordWrap(true);
m_resultList->setUniformItemSizes(false);
m_resultList->setMinimumHeight(280);
listLayout->addWidget(m_resultList);
m_loadingOverlay = new LoadingOverlay(m_resultListContainer);
m_loadingOverlay->setHudPanelVisible(false);
m_loadingOverlay->setSubtleOverlay(true);
auto* previewPanel = new QWidget(this);
auto* previewLayout = new QVBoxLayout(previewPanel);
previewLayout->setContentsMargins(0, 0, 0, 0);
previewLayout->setSpacing(0);
m_previewLabel = new QLabel(previewPanel);
m_previewLabel->setObjectName("identify-preview-label");
m_previewLabel->setAlignment(Qt::AlignCenter);
m_previewLabel->setWordWrap(true);
m_previewLabel->setFixedSize(kIdentifyPreviewSize);
previewLayout->addWidget(m_previewLabel, 0, Qt::AlignTop | Qt::AlignHCenter);
previewLayout->addStretch();
bodyLayout->addWidget(previewPanel, 0, Qt::AlignTop);
bodyLayout->addWidget(m_resultListContainer, 1);
contentLayout()->addLayout(bodyLayout);
contentLayout()->addSpacing(24);
auto* buttonLayout = new QHBoxLayout();
buttonLayout->setSpacing(12);
buttonLayout->addStretch();
auto* cancelButton = new QPushButton(tr("Cancel"), this);
cancelButton->setObjectName("dialog-btn-cancel");
cancelButton->setCursor(Qt::PointingHandCursor);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
buttonLayout->addWidget(cancelButton);
m_applyButton = new QPushButton(tr("Apply Match"), this);
m_applyButton->setObjectName("dialog-btn-primary");
m_applyButton->setCursor(Qt::PointingHandCursor);
m_applyButton->setEnabled(false);
buttonLayout->addWidget(m_applyButton);
contentLayout()->addLayout(buttonLayout);
connect(m_searchButton, &QPushButton::clicked, this,
[this]() { triggerSearch(); });
connect(m_searchEdit, &QLineEdit::returnPressed, this,
[this]() { triggerSearch(); });
connect(m_imdbIdEdit, &QLineEdit::returnPressed, this,
[this]() { triggerSearch(); });
connect(m_movieDbIdEdit, &QLineEdit::returnPressed, this,
[this]() { triggerSearch(); });
connect(m_tvdbIdEdit, &QLineEdit::returnPressed, this,
[this]() { triggerSearch(); });
connect(m_yearSpin, qOverload<int>(&QSpinBox::valueChanged), this,
[this](int) { updateApplyButtonState(); });
connect(m_resultList, &QListWidget::itemSelectionChanged, this,
[this]() {
updateApplyButtonState();
refreshPreview();
});
connect(m_applyButton, &QPushButton::clicked, this,
[this]() { m_pendingTask = applySelectedResult(); });
updatePreviewMessage(tr("Preview unavailable"));
updateUiState();
updateLoadingOverlayGeometry();
}
void MediaIdentifyDialog::showEvent(QShowEvent* event)
{
ModernDialogBase::showEvent(event);
updateLoadingOverlayGeometry();
syncProviderEditHeights();
m_searchEdit->setFocus();
m_searchEdit->selectAll();
if (!m_loaded) {
m_loaded = true;
triggerSearch();
}
}
void MediaIdentifyDialog::resizeEvent(QResizeEvent* event)
{
ModernDialogBase::resizeEvent(event);
updateLoadingOverlayGeometry();
syncProviderEditHeights();
}
QCoro::Task<void> MediaIdentifyDialog::searchMatches(QString queryText,
int productionYear,
QJsonObject providerIds)
{
QPointer<MediaIdentifyDialog> safeThis(this);
QPointer<AdminService> adminService(m_core ? m_core->adminService() : nullptr);
queryText = queryText.trimmed();
const QString providerIdSummary = buildProviderIdSummary(providerIds);
if (!safeThis || !adminService ||
m_remoteSearchType.trimmed().isEmpty()) {
co_return;
}
if (queryText.isEmpty() && providerIds.isEmpty()) {
m_results.clear();
rebuildResultList();
updatePreviewMessage(tr("Preview unavailable"));
updateStatusText(tr("Enter a title or provider ID to search."));
m_isLoading = false;
updateUiState();
co_return;
}
m_isLoading = true;
++m_previewLoadGeneration;
updatePreviewMessage(tr("Loading preview..."));
updateStatusText(tr("Searching..."));
updateUiState();
qDebug() << "[MediaIdentifyDialog] Searching remote metadata"
<< "| itemId=" << m_item.id
<< "| itemType=" << m_item.type
<< "| remoteSearchType=" << m_remoteSearchType
<< "| queryText=" << queryText
<< "| productionYear=" << productionYear
<< "| providerIds=" << providerIdSummary;
try {
const QJsonObject searchInfo =
MediaIdentifyUtils::buildSearchInfo(m_item, queryText,
productionYear, providerIds);
const QList<RemoteSearchResult> results =
co_await adminService->searchRemoteMetadata(
m_remoteSearchType, m_item.id, searchInfo);
if (!safeThis) {
co_return;
}
safeThis->m_results = results;
safeThis->rebuildResultList();
safeThis->updateStatusText(
results.isEmpty() ? tr("No matches found")
: tr("Found %1 matches").arg(results.size()));
safeThis->m_isLoading = false;
safeThis->updateUiState();
qDebug() << "[MediaIdentifyDialog] Remote metadata search completed"
<< "| itemId=" << safeThis->m_item.id
<< "| resultCount=" << results.size();
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
safeThis->m_results.clear();
safeThis->rebuildResultList();
++safeThis->m_previewLoadGeneration;
safeThis->updatePreviewMessage(tr("Preview unavailable"));
safeThis->updateStatusText(tr("Search failed"));
safeThis->m_isLoading = false;
safeThis->updateUiState();
qWarning() << "[MediaIdentifyDialog] Remote metadata search failed"
<< "| itemId=" << safeThis->m_item.id
<< "| itemType=" << safeThis->m_item.type
<< "| error=" << e.what();
ModernToast::showMessage(
tr("Failed to search metadata: %1").arg(QString::fromUtf8(e.what())),
3000);
}
}
QCoro::Task<void> MediaIdentifyDialog::applySelectedResult()
{
QPointer<MediaIdentifyDialog> safeThis(this);
QPointer<AdminService> adminService(m_core ? m_core->adminService() : nullptr);
if (!safeThis || !adminService) {
co_return;
}
const RemoteSearchResult result = selectedResult();
if (result.name.trimmed().isEmpty()) {
co_return;
}
m_isLoading = true;
updateStatusText(tr("Applying match..."));
updateUiState();
qDebug() << "[MediaIdentifyDialog] Applying remote metadata match"
<< "| itemId=" << m_item.id
<< "| itemType=" << m_item.type
<< "| resultName=" << result.name
<< "| provider=" << result.searchProviderName
<< "| providerIds=" << result.providerIdSummary();
try {
co_await adminService->applyRemoteSearchResult(m_item.id, result);
if (!safeThis) {
co_return;
}
qDebug() << "[MediaIdentifyDialog] Remote metadata match applied"
<< "| itemId=" << safeThis->m_item.id
<< "| resultName=" << result.name;
ModernToast::showMessage(tr("Metadata updated"), 2000);
safeThis->accept();
} catch (const std::exception& e) {
if (!safeThis) {
co_return;
}
safeThis->m_isLoading = false;
safeThis->updateStatusText(tr("Apply failed"));
safeThis->updateUiState();
qWarning() << "[MediaIdentifyDialog] Failed to apply remote metadata match"
<< "| itemId=" << safeThis->m_item.id
<< "| resultName=" << result.name
<< "| error=" << e.what();
ModernToast::showMessage(
tr("Failed to apply match: %1").arg(QString::fromUtf8(e.what())),
3000);
}
}
QCoro::Task<void> MediaIdentifyDialog::loadPreviewImage(
RemoteSearchResult result, quint64 requestGeneration)
{
QPointer<MediaIdentifyDialog> safeThis(this);
QPointer<MediaService> mediaService(m_core ? m_core->mediaService() : nullptr);
if (!safeThis || !mediaService) {
co_return;
}
const QString imageUrl = result.imageUrl.trimmed();
if (imageUrl.isEmpty()) {
if (safeThis && requestGeneration == m_previewLoadGeneration) {
safeThis->updatePreviewMessage(tr("Preview unavailable"));
}
co_return;
}
try {
const QPixmap pixmap = co_await mediaService->fetchImageByUrl(imageUrl);
if (!safeThis || requestGeneration != m_previewLoadGeneration) {
co_return;
}
if (pixmap.isNull()) {
safeThis->updatePreviewMessage(tr("Preview unavailable"));
co_return;
}
safeThis->updatePreviewPixmap(pixmap);
} catch (const std::exception& e) {
if (!safeThis || requestGeneration != m_previewLoadGeneration) {
co_return;
}
qWarning() << "[MediaIdentifyDialog] Failed to load preview image"
<< "| imageUrl=" << imageUrl
<< "| error=" << e.what();
safeThis->updatePreviewMessage(tr("Preview unavailable"));
}
}
QJsonObject MediaIdentifyDialog::collectProviderIds() const
{
return MediaIdentifyUtils::buildProviderIds(
m_imdbIdEdit ? m_imdbIdEdit->text() : QString(),
m_movieDbIdEdit ? m_movieDbIdEdit->text() : QString(),
m_tvdbIdEdit ? m_tvdbIdEdit->text() : QString());
}
void MediaIdentifyDialog::syncProviderEditHeights()
{
if (!m_searchEdit) {
return;
}
m_searchEdit->ensurePolished();
const int referenceHeight =
m_searchEdit->height() > 0 ? m_searchEdit->height()
: m_searchEdit->sizeHint().height();
if (referenceHeight <= 0) {
return;
}
const QList<QLineEdit*> providerEdits = {m_imdbIdEdit, m_movieDbIdEdit,
m_tvdbIdEdit};
for (QLineEdit* edit : providerEdits) {
if (!edit) {
continue;
}
edit->setFixedHeight(referenceHeight);
}
}
RemoteSearchResult MediaIdentifyDialog::selectedResult() const
{
const QListWidgetItem* item = m_resultList ? m_resultList->currentItem() : nullptr;
if (!item) {
return {};
}
const QVariant data = item->data(kRemoteSearchResultRole);
if (!data.canConvert<RemoteSearchResult>()) {
return {};
}
return data.value<RemoteSearchResult>();
}
void MediaIdentifyDialog::triggerSearch()
{
if (!m_searchEdit || !m_yearSpin || m_isLoading) {
return;
}
m_pendingTask = searchMatches(m_searchEdit->text(), m_yearSpin->value(),
collectProviderIds());
}
void MediaIdentifyDialog::rebuildResultList()
{
if (!m_resultList) {
return;
}
QSignalBlocker blocker(m_resultList);
m_resultList->clear();
for (const RemoteSearchResult& result : std::as_const(m_results)) {
auto* item = new QListWidgetItem(buildResultDisplayText(result), m_resultList);
item->setData(kRemoteSearchResultRole, QVariant::fromValue(result));
item->setToolTip(buildResultToolTip(result));
item->setSizeHint(QSize(0, 60));
}
if (m_resultList->count() > 0) {
m_resultList->setCurrentRow(0);
}
refreshPreview();
updateApplyButtonState();
}
void MediaIdentifyDialog::refreshPreview()
{
++m_previewLoadGeneration;
const RemoteSearchResult result = selectedResult();
if (result.imageUrl.trimmed().isEmpty()) {
updatePreviewMessage(tr("Preview unavailable"));
return;
}
updatePreviewMessage(tr("Loading preview..."));
m_previewLabel->setToolTip(buildResultToolTip(result));
m_pendingPreviewTask = loadPreviewImage(result, m_previewLoadGeneration);
}
void MediaIdentifyDialog::updateLoadingOverlayGeometry()
{
if (!m_loadingOverlay || !m_resultListContainer) {
return;
}
m_loadingOverlay->setGeometry(m_resultListContainer->rect());
}
void MediaIdentifyDialog::updateUiState()
{
if (m_searchEdit) {
m_searchEdit->setEnabled(!m_isLoading);
}
if (m_yearSpin) {
m_yearSpin->setEnabled(!m_isLoading);
}
if (m_imdbIdEdit) {
m_imdbIdEdit->setEnabled(!m_isLoading);
}
if (m_movieDbIdEdit) {
m_movieDbIdEdit->setEnabled(!m_isLoading);
}
if (m_tvdbIdEdit) {
m_tvdbIdEdit->setEnabled(!m_isLoading);
}
if (m_searchButton) {
m_searchButton->setEnabled(!m_isLoading);
}
if (m_resultList) {
m_resultList->setEnabled(!m_isLoading);
}
if (m_loadingOverlay) {
updateLoadingOverlayGeometry();
if (m_isLoading) {
m_loadingOverlay->start();
} else {
m_loadingOverlay->stop();
}
}
updateApplyButtonState();
}
void MediaIdentifyDialog::updateApplyButtonState()
{
if (!m_applyButton) {
return;
}
const bool hasSelection =
m_resultList && m_resultList->currentItem() != nullptr;
m_applyButton->setEnabled(!m_isLoading && hasSelection);
}
void MediaIdentifyDialog::updatePreviewMessage(const QString& text)
{
if (!m_previewLabel) {
return;
}
m_previewLabel->setPixmap(QPixmap());
m_previewLabel->setText(text);
m_previewLabel->setToolTip(QString());
}
void MediaIdentifyDialog::updatePreviewPixmap(const QPixmap& pixmap)
{
if (!m_previewLabel) {
return;
}
const QPixmap scaled =
pixmap.scaled(kIdentifyPreviewSize, Qt::KeepAspectRatio,
Qt::SmoothTransformation);
m_previewLabel->setText(QString());
m_previewLabel->setPixmap(scaled);
}
void MediaIdentifyDialog::updateStatusText(const QString& text)
{
if (m_statusLabel) {
m_statusLabel->setText(text);
}
}

View File

@@ -0,0 +1,88 @@
#ifndef MEDIAIDENTIFYDIALOG_H
#define MEDIAIDENTIFYDIALOG_H
#include "moderndialogbase.h"
#include <models/admin/adminmodels.h>
#include <models/media/mediaitem.h>
#include <QJsonObject>
#include <QList>
#include <QPointer>
#include <QString>
#include <QtGlobal>
#include <optional>
#include <qcorotask.h>
class QLabel;
class QLineEdit;
class QListWidget;
class QListWidgetItem;
class QPixmap;
class QPushButton;
class QResizeEvent;
class QShowEvent;
class QSpinBox;
class QEmbyCore;
class QWidget;
class LoadingOverlay;
class MediaIdentifyDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit MediaIdentifyDialog(QEmbyCore* core, MediaItem item,
QWidget* parent = nullptr);
protected:
void showEvent(QShowEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
QCoro::Task<void> searchMatches(QString queryText, int productionYear,
QJsonObject providerIds);
QCoro::Task<void> applySelectedResult();
QCoro::Task<void> loadPreviewImage(RemoteSearchResult result,
quint64 requestGeneration);
QJsonObject collectProviderIds() const;
void syncProviderEditHeights();
RemoteSearchResult selectedResult() const;
void triggerSearch();
void rebuildResultList();
void refreshPreview();
void updateLoadingOverlayGeometry();
void updateUiState();
void updateApplyButtonState();
void updatePreviewMessage(const QString& text);
void updatePreviewPixmap(const QPixmap& pixmap);
void updateStatusText(const QString& text);
QEmbyCore* m_core = nullptr;
MediaItem m_item;
QString m_remoteSearchType;
QLabel* m_promptLabel = nullptr;
QLineEdit* m_searchEdit = nullptr;
QLineEdit* m_imdbIdEdit = nullptr;
QLineEdit* m_movieDbIdEdit = nullptr;
QLineEdit* m_tvdbIdEdit = nullptr;
QSpinBox* m_yearSpin = nullptr;
QPushButton* m_searchButton = nullptr;
QLabel* m_statusLabel = nullptr;
QWidget* m_resultListContainer = nullptr;
QListWidget* m_resultList = nullptr;
QLabel* m_previewLabel = nullptr;
LoadingOverlay* m_loadingOverlay = nullptr;
QPushButton* m_applyButton = nullptr;
bool m_loaded = false;
bool m_isLoading = false;
quint64 m_previewLoadGeneration = 0;
QList<RemoteSearchResult> m_results;
std::optional<QCoro::Task<void>> m_pendingTask;
std::optional<QCoro::Task<void>> m_pendingPreviewTask;
};
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
#ifndef MEDIAIMAGEEDITDIALOG_H
#define MEDIAIMAGEEDITDIALOG_H
#include "moderndialogbase.h"
#include <models/admin/adminmodels.h>
#include <QList>
#include <QString>
#include <optional>
#include <qcorotask.h>
class QLabel;
class QListWidget;
class QMimeData;
class QPixmap;
class QPushButton;
class QEvent;
class QShowEvent;
class QEmbyCore;
struct MediaImageEditTarget {
QString itemId;
QString imageItemId;
QString displayName;
QString itemType;
QString mediaType;
QString collectionType;
bool isLibrary = false;
bool isValid() const
{
return !effectiveImageItemId().trimmed().isEmpty();
}
QString effectiveImageItemId() const
{
return imageItemId.trimmed().isEmpty() ? itemId.trimmed()
: imageItemId.trimmed();
}
};
class MediaImageEditDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit MediaImageEditDialog(QEmbyCore* core, MediaImageEditTarget target,
QWidget* parent = nullptr);
bool hasChanges() const { return m_hasChanges; }
protected:
void showEvent(QShowEvent* event) override;
bool eventFilter(QObject* watched, QEvent* event) override;
private:
struct ImageSlotState {
QString imageType;
QString displayName;
QList<ItemImageInfo> images;
bool hasImage() const { return !images.isEmpty(); }
int previewImageIndex() const
{
return hasImage() ? images.first().imageIndex : -1;
}
};
struct DroppedImagePayload {
QString localFilePath;
QString remoteImageUrl;
QByteArray imageData;
QString mimeType;
bool isValid() const
{
return !localFilePath.trimmed().isEmpty() ||
!remoteImageUrl.trimmed().isEmpty() || !imageData.isEmpty();
}
};
QCoro::Task<void> reloadImages(QString preferredType = QString());
QCoro::Task<void> loadPreview(ImageSlotState slot,
quint64 requestGeneration);
QCoro::Task<void> chooseAndUploadImage();
QCoro::Task<void> promptAndUploadImageUrl();
QCoro::Task<void> saveSelectedImage();
QCoro::Task<void> uploadLocalImageFile(QString filePath);
QCoro::Task<void> uploadRemoteImage(QString imageUrl);
QCoro::Task<void> uploadImageData(ImageSlotState slot,
QByteArray imageData, QString mimeType);
QCoro::Task<void> handleDroppedImage(DroppedImagePayload payload);
QCoro::Task<void> removeSelectedImage();
const ImageSlotState* currentSlot() const;
bool canAcceptDroppedImage(const QMimeData* mimeData) const;
DroppedImagePayload parseDroppedImagePayload(const QMimeData* mimeData) const;
void rebuildTypeList(QString preferredType = QString());
void refreshPreview();
void setPreviewDropActive(bool active);
void updatePreviewMessage(const QString& text);
void updatePreviewPixmap(const QPixmap& pixmap);
void updatePreviewDetails();
void updateStatusText(const QString& text);
void updateUiState();
QEmbyCore* m_core = nullptr;
MediaImageEditTarget m_target;
QLabel* m_promptLabel = nullptr;
QLabel* m_statusLabel = nullptr;
QLabel* m_previewLabel = nullptr;
QLabel* m_previewInfoLabel = nullptr;
QListWidget* m_typeList = nullptr;
QPushButton* m_chooseButton = nullptr;
QPushButton* m_urlButton = nullptr;
QPushButton* m_downloadButton = nullptr;
QPushButton* m_removeButton = nullptr;
bool m_loaded = false;
bool m_isBusy = false;
bool m_hasChanges = false;
bool m_isPreviewDropActive = false;
quint64 m_previewLoadGeneration = 0;
QList<ItemImageInfo> m_itemImages;
QList<ImageSlotState> m_slots;
std::optional<QCoro::Task<void>> m_pendingTask;
std::optional<QCoro::Task<void>> m_pendingPreviewTask;
};
#endif

View File

@@ -0,0 +1,126 @@
#include "mediasectionwidget.h"
#include "horizontallistviewgallery.h"
#include <qembycore.h>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPointer>
#include <QListView>
MediaSectionWidget::MediaSectionWidget(const QString& title, QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core)
{
setObjectName("media-section");
setStyleSheet("QWidget#media-section { background: transparent; }");
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(40, 20, 40, 0);
layout->setSpacing(0);
auto *headerContainer = new QWidget(this);
headerContainer->setObjectName("section-header");
headerContainer->setStyleSheet("#section-header { background: transparent; }");
m_headerLayout = new QHBoxLayout(headerContainer);
m_headerLayout->setContentsMargins(0, 0, 0, 0);
m_headerLayout->setSpacing(12);
m_titleLabel = new QLabel(title, headerContainer);
m_titleLabel->setObjectName("detail-section-title");
m_headerLayout->addWidget(m_titleLabel);
m_headerLayout->addStretch();
m_gallery = new HorizontalListViewGallery(core, this);
m_gallery->listView()->setProperty("isHorizontalListView", true);
connect(m_gallery, &HorizontalListViewGallery::itemClicked, this, &MediaSectionWidget::itemClicked);
connect(m_gallery, &HorizontalListViewGallery::playRequested, this, &MediaSectionWidget::playRequested);
connect(m_gallery, &HorizontalListViewGallery::favoriteRequested, this, &MediaSectionWidget::favoriteRequested);
connect(m_gallery, &HorizontalListViewGallery::moreMenuRequested, this, &MediaSectionWidget::moreMenuRequested);
layout->addWidget(headerContainer);
layout->addWidget(m_gallery);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
hide();
}
HorizontalListViewGallery* MediaSectionWidget::gallery() const {
return m_gallery;
}
void MediaSectionWidget::setTitle(const QString& title) {
m_titleLabel->setText(title);
}
void MediaSectionWidget::setHeaderWidget(QWidget* widget) {
if (m_headerWidget && m_headerWidget != widget) {
m_headerWidget->hide();
m_headerWidget = nullptr;
}
if (widget) {
m_headerWidget = widget;
if (m_headerLayout->indexOf(m_headerWidget) < 0) {
m_headerLayout->insertWidget(1, m_headerWidget);
}
m_headerWidget->show();
}
}
void MediaSectionWidget::setCardStyle(MediaCardDelegate::CardStyle style) {
m_gallery->setCardStyle(style);
}
void MediaSectionWidget::setGalleryHeight(int height) {
m_gallery->setFixedHeight(height);
}
void MediaSectionWidget::setTileSize(const QSize &size) {
m_gallery->setTileSize(size);
}
void MediaSectionWidget::clear() {
m_gallery->setItems(QList<MediaItem>());
hide();
}
void MediaSectionWidget::setItems(const QList<MediaItem>& items) {
if (items.isEmpty()) {
hide();
} else {
m_gallery->setItems(items);
show();
}
}
void MediaSectionWidget::updateItem(const MediaItem& item) {
m_gallery->updateItem(item);
}
QCoro::Task<void> MediaSectionWidget::loadAsync(FetchFunction fetcher) {
if (!fetcher) co_return;
QPointer<MediaSectionWidget> safeThis(this);
try {
QList<MediaItem> items = co_await fetcher();
if (safeThis) {
safeThis->setItems(items);
Q_EMIT safeThis->dataLoaded(items.size());
}
} catch(...) {
if (safeThis) {
safeThis->hide();
}
}
}

View File

@@ -0,0 +1,59 @@
#ifndef MEDIASECTIONWIDGET_H
#define MEDIASECTIONWIDGET_H
#include <QWidget>
#include <functional>
#include <qcorotask.h>
#include "models/media/mediaitem.h"
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class HorizontalListViewGallery;
class QLabel;
class QHBoxLayout;
class MediaSectionWidget : public QWidget {
Q_OBJECT
public:
explicit MediaSectionWidget(const QString& title, QEmbyCore* core, QWidget* parent = nullptr);
HorizontalListViewGallery* gallery() const;
void setTitle(const QString& title);
void setCardStyle(MediaCardDelegate::CardStyle style);
void setGalleryHeight(int height);
void setTileSize(const QSize &size);
void setHeaderWidget(QWidget* widget);
void clear();
void setItems(const QList<MediaItem>& items);
void updateItem(const MediaItem& item);
using FetchFunction = std::function<QCoro::Task<QList<MediaItem>>()>;
QCoro::Task<void> loadAsync(FetchFunction fetcher);
signals:
void itemClicked(const MediaItem& item);
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& pos);
void dataLoaded(int itemCount);
private:
QEmbyCore* m_core;
QLabel* m_titleLabel;
QHBoxLayout* m_headerLayout;
QWidget* m_headerWidget = nullptr;
HorizontalListViewGallery* m_gallery;
};
#endif

View File

@@ -0,0 +1,372 @@
#include "moderncombobox.h"
#include <QAbstractItemView>
#include <QApplication>
#include <QEvent>
#include <QFontMetrics>
#include <QFrame>
#include <QKeyEvent>
#include <QListWidget>
#include <QPainter>
#include <QScrollBar>
#include <QShortcut>
#include <QStyleOption>
#include <QStyleOptionComboBox>
#include <QStyleOptionViewItem>
#include <QStylePainter>
#include <QVBoxLayout>
namespace {
constexpr int kArrowWidth = 32;
constexpr int kModernComboMinItemHeight = 36;
constexpr auto kPopupEdgeFixMarker = "/* modern-combobox-popup-edge-fix */";
constexpr auto kPopupEdgeFixQss = R"(
/* modern-combobox-popup-edge-fix */
QAbstractItemView QScrollBar:vertical {
margin: 0px;
}
)";
}
void CenterAlignDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const {
QStyleOptionViewItem opt = option;
initStyleOption(&opt, index);
opt.displayAlignment = Qt::AlignCenter;
QStyledItemDelegate::paint(painter, opt, index);
}
QSize CenterAlignDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const {
QSize base = QStyledItemDelegate::sizeHint(option, index);
base.setHeight(qMax(base.height(), kModernComboMinItemHeight));
return base;
}
ModernComboBox::ModernComboBox(QWidget *parent) : QComboBox(parent) {
setItemDelegate(new CenterAlignDelegate(this));
setSizeAdjustPolicy(QComboBox::AdjustToContents);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
connect(this, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int index) {
if (m_maxTextWidth > 0 && index >= 0) {
setToolTip(itemText(index));
} else {
setToolTip({});
}
});
polishPopupView();
}
void ModernComboBox::setMaxTextWidth(int pixels) {
m_maxTextWidth = pixels;
updateGeometry();
}
QSize ModernComboBox::sizeHint() const {
QFontMetrics fm(font());
int maxW = 60;
for (int i = 0; i < count(); ++i) {
maxW = qMax(maxW, fm.horizontalAdvance(itemText(i)));
}
if (m_maxTextWidth > 0) {
maxW = qMin(maxW, m_maxTextWidth);
}
int w = maxW + 16 + 16 + 32;
int h = QComboBox::sizeHint().height();
return QSize(w, h);
}
QSize ModernComboBox::minimumSizeHint() const { return sizeHint(); }
void ModernComboBox::showPopup() {
if (window() && window()->isFullScreen()) {
showEmbeddedPopup();
return;
}
polishPopupView();
QComboBox::showPopup();
auto *popupView = view();
QWidget *popupContainer = popupView ? popupView->window() : nullptr;
if (!popupView || !popupContainer) {
return;
}
auto *vScrollBar = popupView->verticalScrollBar();
if (vScrollBar && vScrollBar->isVisible()) {
const int targetWidth = qMax(popupContainer->width(),
width() + vScrollBar->sizeHint().width());
if (targetWidth > popupContainer->width()) {
popupContainer->resize(targetWidth, popupContainer->height());
}
}
}
void ModernComboBox::hidePopup() {
closeEmbeddedPopup();
QComboBox::hidePopup();
}
bool ModernComboBox::eventFilter(QObject *watched, QEvent *event) {
if (!event) {
return QComboBox::eventFilter(watched, event);
}
if (m_embeddedPopup && watched == m_embeddedPopup.data()) {
if (event->type() == QEvent::MouseButtonPress ||
event->type() == QEvent::MouseButtonDblClick) {
closeEmbeddedPopup();
return true;
}
}
return QComboBox::eventFilter(watched, event);
}
void ModernComboBox::showEmbeddedPopup() {
closeEmbeddedPopup();
QWidget *host = window();
if (!host || count() == 0) {
return;
}
const bool flushRightScrollBar =
property("flush-right-scrollbar").toBool();
auto *overlay = new QWidget(host);
overlay->setAttribute(Qt::WA_DeleteOnClose);
overlay->setObjectName(QStringLiteral("modernComboEmbeddedOverlay"));
overlay->setGeometry(host->rect());
overlay->installEventFilter(this);
auto *listFrame = new QFrame(overlay);
listFrame->setObjectName(QStringLiteral("modernComboEmbeddedFrame"));
listFrame->setFrameStyle(QFrame::NoFrame);
listFrame->setProperty("flush-right-scrollbar", flushRightScrollBar);
auto *frameLayout = new QVBoxLayout(listFrame);
frameLayout->setContentsMargins(0, 0, 0, 0);
frameLayout->setSpacing(0);
auto *listWidget = new QListWidget(listFrame);
listWidget->setObjectName(QStringLiteral("modernComboEmbeddedList"));
listWidget->setProperty("flush-right-scrollbar", flushRightScrollBar);
listWidget->setItemDelegate(new CenterAlignDelegate(listWidget));
listWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
listWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
listWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
listWidget->setFocusPolicy(Qt::StrongFocus);
listWidget->setFrameStyle(QFrame::NoFrame);
listWidget->setContentsMargins(0, 0, 0, 0);
for (int i = 0; i < count(); ++i) {
auto *item = new QListWidgetItem(itemText(i));
item->setData(Qt::UserRole, itemData(i));
item->setTextAlignment(Qt::AlignCenter);
listWidget->addItem(item);
}
if (currentIndex() >= 0 && currentIndex() < listWidget->count()) {
listWidget->setCurrentRow(currentIndex());
}
frameLayout->addWidget(listWidget);
constexpr int kMaxVisibleItems = 12;
constexpr int kItemPadding = 12;
const QFontMetrics fm(listWidget->font());
const int itemH = qMax(fm.height() + kItemPadding, kModernComboMinItemHeight);
const int visibleItems = qMin(count(), kMaxVisibleItems);
int popupH = itemH * visibleItems + 6;
int popupW = qMax(width(), 240);
popupH = qMin(popupH, host->height() / 2);
const QPoint comboBottomLeft = mapTo(host, QPoint(0, height()));
const QPoint comboTopLeft = mapTo(host, QPoint(0, 0));
int popupX = comboBottomLeft.x();
int popupY = comboBottomLeft.y() + 4;
if (popupY + popupH > host->height() - 10) {
popupY = comboTopLeft.y() - popupH - 4;
}
if (popupX + popupW > host->width() - 10) {
popupX = host->width() - popupW - 10;
}
popupX = qMax(10, popupX);
popupY = qMax(10, popupY);
listFrame->setGeometry(popupX, popupY, popupW, popupH);
overlay->show();
overlay->raise();
listFrame->show();
listWidget->setFocus();
if (currentIndex() >= 0 && currentIndex() < listWidget->count()) {
listWidget->scrollToItem(listWidget->item(currentIndex()),
QAbstractItemView::PositionAtCenter);
}
m_embeddedPopup = overlay;
connect(listWidget, &QListWidget::itemClicked, this,
[this, listWidget](QListWidgetItem *) {
const int row = listWidget->currentRow();
if (row >= 0 && row < count()) {
setCurrentIndex(row);
}
closeEmbeddedPopup();
});
auto *escShortcut =
new QShortcut(QKeySequence(Qt::Key_Escape), overlay,
nullptr, nullptr, Qt::WidgetWithChildrenShortcut);
connect(escShortcut, &QShortcut::activated, this,
[this]() { closeEmbeddedPopup(); });
auto *enterShortcut =
new QShortcut(QKeySequence(Qt::Key_Return), listWidget,
nullptr, nullptr, Qt::WidgetWithChildrenShortcut);
connect(enterShortcut, &QShortcut::activated, this,
[this, listWidget]() {
const int row = listWidget->currentRow();
if (row >= 0 && row < count()) {
setCurrentIndex(row);
}
closeEmbeddedPopup();
});
}
void ModernComboBox::closeEmbeddedPopup() {
if (m_embeddedPopup) {
m_embeddedPopup->removeEventFilter(this);
m_embeddedPopup->close();
m_embeddedPopup = nullptr;
}
}
void ModernComboBox::polishPopupView() {
auto *popupView = view();
if (!popupView) {
return;
}
popupView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
popupView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
QString popupStyleSheet = popupView->styleSheet();
if (!popupStyleSheet.contains(QLatin1String(kPopupEdgeFixMarker))) {
if (!popupStyleSheet.isEmpty() && !popupStyleSheet.endsWith(QLatin1Char('\n'))) {
popupStyleSheet += QLatin1Char('\n');
}
popupStyleSheet += QLatin1String(kPopupEdgeFixQss);
popupView->setStyleSheet(popupStyleSheet);
}
}
void ModernComboBox::paintEvent(QPaintEvent *) {
QStylePainter painter(this);
QStyleOptionComboBox opt;
initStyleOption(&opt);
QString text = opt.currentText;
opt.currentText = "";
painter.drawComplexControl(QStyle::CC_ComboBox, opt);
QRect textRect = rect().adjusted(0, 0, -kArrowWidth, 0);
if (m_maxTextWidth > 0) {
QFontMetrics fm(this->font());
text = fm.elidedText(text, Qt::ElideRight, textRect.width() - 8);
}
painter.setPen(opt.palette.color(QPalette::ButtonText));
painter.setFont(this->font());
painter.drawText(textRect, Qt::AlignCenter | Qt::TextSingleLine, text);
}

View File

@@ -0,0 +1,56 @@
#ifndef MODERNCOMBOBOX_H
#define MODERNCOMBOBOX_H
#include <QComboBox>
#include <QPointer>
#include <QStyledItemDelegate>
class CenterAlignDelegate : public QStyledItemDelegate {
Q_OBJECT
public:
explicit CenterAlignDelegate(QObject *parent = nullptr)
: QStyledItemDelegate(parent) {}
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
};
class ModernComboBox : public QComboBox {
Q_OBJECT
public:
explicit ModernComboBox(QWidget *parent = nullptr);
~ModernComboBox() override = default;
void setMaxTextWidth(int pixels);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
void showPopup() override;
void hidePopup() override;
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void polishPopupView();
void showEmbeddedPopup();
void closeEmbeddedPopup();
int m_maxTextWidth = 0;
QPointer<QWidget> m_embeddedPopup;
};
#endif

View File

@@ -0,0 +1,122 @@
#include "moderndialogbase.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QWKWidgets/widgetwindowagent.h>
#include <widgetframe/windowbar.h>
#include <widgetframe/windowbutton.h>
#ifdef Q_OS_WIN
#include <windows.h>
#endif
namespace {
#ifdef Q_OS_WIN
using DwmSetWindowAttributePtr =
HRESULT(WINAPI *)(HWND, DWORD, LPCVOID, DWORD);
void setWindowTransitionsDisabled(WId windowId, bool disabled)
{
static const HMODULE dwmapi = LoadLibraryW(L"dwmapi.dll");
if (!dwmapi || !windowId) {
return;
}
static const auto dwmSetWindowAttribute =
reinterpret_cast<DwmSetWindowAttributePtr>(
GetProcAddress(dwmapi, "DwmSetWindowAttribute"));
if (!dwmSetWindowAttribute) {
return;
}
constexpr DWORD kDwmwaTransitionsForcedDisabled = 3;
const BOOL value = disabled ? TRUE : FALSE;
dwmSetWindowAttribute(reinterpret_cast<HWND>(windowId),
kDwmwaTransitionsForcedDisabled,
&value, sizeof(value));
}
#endif
}
ModernDialogBase::ModernDialogBase(QWidget *parent,
bool disableNativeTransitions)
: QDialog(parent) {
setWindowFlag(Qt::WindowContextHelpButtonHint, false);
setAttribute(Qt::WA_StyledBackground, true);
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_titleBarWidget = new QWidget(this);
m_titleBarWidget->setObjectName("dialog-titlebar");
auto *titleBarLayout = new QHBoxLayout(m_titleBarWidget);
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
titleBarLayout->setContentsMargins(52, 0, 16, 0);
#else
titleBarLayout->setContentsMargins(16, 0, 0, 0);
#endif
titleBarLayout->setSpacing(0);
m_titleLabel = new QLabel(m_titleBarWidget);
m_titleLabel->setObjectName("dialog-title");
auto *agent = new QWK::WidgetWindowAgent(this);
agent->setup(this);
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
agent->setWindowAttribute("no-system-buttons", false);
agent->setWindowAttribute("macos-close-button-only", true);
#endif
#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC)
auto *closeBtn = new QWK::WindowButton(m_titleBarWidget);
closeBtn->setObjectName("dialog-close-btn");
closeBtn->setProperty("system-button", true);
connect(closeBtn, &QWK::WindowButton::clicked, this, &QDialog::reject);
#endif
titleBarLayout->addWidget(m_titleLabel);
titleBarLayout->addStretch();
#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC)
titleBarLayout->addWidget(closeBtn);
#endif
agent->setTitleBar(m_titleBarWidget);
#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC)
agent->setSystemButton(QWK::WindowAgentBase::Close, closeBtn);
#endif
#ifdef Q_OS_WIN
if (disableNativeTransitions) {
setWindowTransitionsDisabled(winId(), true);
}
#else
Q_UNUSED(disableNativeTransitions);
#endif
m_contentLayout = new QVBoxLayout();
m_contentLayout->setContentsMargins(20, 10, 20, 20);
mainLayout->addWidget(m_titleBarWidget);
mainLayout->addLayout(m_contentLayout);
}
void ModernDialogBase::setTitle(const QString &title) {
m_titleLabel->setText(title);
}

View File

@@ -0,0 +1,28 @@
#ifndef MODERNDIALOGBASE_H
#define MODERNDIALOGBASE_H
#include <QDialog>
#include <QString>
class QVBoxLayout;
class QLabel;
class QWidget;
class ModernDialogBase : public QDialog {
Q_OBJECT
public:
explicit ModernDialogBase(QWidget *parent = nullptr,
bool disableNativeTransitions = false);
void setTitle(const QString &title);
protected:
QVBoxLayout* contentLayout() const { return m_contentLayout; }
private:
QLabel *m_titleLabel;
QVBoxLayout *m_contentLayout;
QWidget *m_titleBarWidget;
};
#endif

View File

@@ -0,0 +1,198 @@
#include "modernmenubutton.h"
#include <QAction>
#include <QLabel>
#include <QStyle>
#include <QVBoxLayout>
#include <QWidget>
#include <QWidgetAction>
namespace {
QWidget *createStreamMenuItemWidget(const QString &firstLineText,
const QString &secondLine) {
auto *host = new QWidget();
host->setObjectName("stream-menu-item-host");
host->setAttribute(Qt::WA_StyledBackground, true);
auto *hostLayout = new QVBoxLayout(host);
hostLayout->setContentsMargins(0, 1, 0, 1);
hostLayout->setSpacing(0);
auto *container = new QWidget(host);
container->setObjectName("stream-menu-item");
container->setAttribute(Qt::WA_StyledBackground, true);
auto *layout = new QVBoxLayout(container);
layout->setContentsMargins(14, 6, 28, 6);
layout->setSpacing(secondLine.isEmpty() ? 0 : 2);
auto *line1 = new QLabel(firstLineText, container);
line1->setObjectName("stream-menu-line1");
layout->addWidget(line1);
if (!secondLine.isEmpty()) {
auto *line2 = new QLabel(secondLine, container);
line2->setObjectName("stream-menu-line2");
layout->addWidget(line2);
}
hostLayout->addWidget(container);
return host;
}
QWidget *streamMenuVisualWidget(QWidgetAction *action) {
if (!action || !action->defaultWidget()) {
return nullptr;
}
return action->defaultWidget()->findChild<QWidget *>(
"stream-menu-item", Qt::FindChildrenRecursively);
}
void refreshStreamMenuItemState(QAction *action, bool selected) {
action->setChecked(selected);
auto *widgetAction = qobject_cast<QWidgetAction *>(action);
if (!widgetAction) {
return;
}
auto *visualWidget = streamMenuVisualWidget(widgetAction);
if (!visualWidget) {
return;
}
visualWidget->setProperty("checked", selected);
visualWidget->style()->unpolish(visualWidget);
visualWidget->style()->polish(visualWidget);
visualWidget->update();
const auto labels = visualWidget->findChildren<QLabel *>();
for (auto *label : labels) {
label->style()->unpolish(label);
label->style()->polish(label);
label->update();
}
}
}
ModernMenuButton::ModernMenuButton(QWidget *parent)
: QPushButton(parent), m_currentIndex(-1)
{
m_menu = new QMenu(this);
setObjectName("immersive-stream-btn");
m_menu->setObjectName("immersive-stream-menu");
m_menu->setProperty("customItemHighlight", true);
setStyleSheet("QPushButton::menu-indicator { image: none; }");
m_menu->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
m_menu->setAttribute(Qt::WA_TranslucentBackground, true);
m_menu->setAutoFillBackground(false);
setCursor(Qt::PointingHandCursor);
connect(m_menu, &QMenu::triggered, this, &ModernMenuButton::onActionTriggered);
connect(this, &QPushButton::clicked, this, [this]() {
if (m_actions.isEmpty()) {
return;
}
m_menu->adjustSize();
QPoint pos = mapToGlobal(QPoint(width() / 2, height() + 4));
pos.setX(pos.x() - m_menu->sizeHint().width() / 2);
m_menu->exec(pos);
});
}
void ModernMenuButton::clear()
{
m_menu->clear();
m_actions.clear();
m_currentIndex = -1;
setText("");
setToolTip("");
}
void ModernMenuButton::addItem(const QString& title, const QString& subtitle,
const QString& secondLine, const QVariant& userData)
{
QString firstLineText = title;
if (!subtitle.isEmpty()) {
firstLineText += " | " + subtitle;
}
auto *widgetAction = new QWidgetAction(m_menu);
widgetAction->setDefaultWidget(
createStreamMenuItemWidget(firstLineText, secondLine));
widgetAction->setProperty("shortTitle", title);
widgetAction->setData(m_actions.size());
widgetAction->setProperty("realData", userData);
widgetAction->setProperty(
"fullText", secondLine.isEmpty() ? firstLineText
: firstLineText + "\n" + secondLine);
widgetAction->setCheckable(true);
m_menu->addAction(widgetAction);
m_actions.append(widgetAction);
if (m_actions.size() == 1) {
setCurrentIndex(0);
}
}
void ModernMenuButton::setCurrentIndex(int index)
{
if (index >= 0 && index < m_actions.size()) {
m_currentIndex = index;
updateAppearance();
} else if (index == -1) {
m_currentIndex = -1;
updateAppearance();
}
}
QVariant ModernMenuButton::currentData() const
{
if (m_currentIndex >= 0 && m_currentIndex < m_actions.size()) {
return m_actions.at(m_currentIndex)->property("realData");
}
return QVariant();
}
void ModernMenuButton::onActionTriggered(QAction *action)
{
int index = action->data().toInt();
if (index != m_currentIndex) {
m_currentIndex = index;
updateAppearance();
Q_EMIT currentIndexChanged(m_currentIndex);
} else {
action->setChecked(true);
}
}
void ModernMenuButton::updateAppearance()
{
for (int i = 0; i < m_actions.size(); ++i) {
refreshStreamMenuItemState(m_actions.at(i), i == m_currentIndex);
}
if (m_currentIndex >= 0 && m_currentIndex < m_actions.size()) {
QAction* currentAct = m_actions.at(m_currentIndex);
QString shortText = currentAct->property("shortTitle").toString();
setText(shortText + "");
setToolTip(currentAct->property("fullText").toString());
} else {
setText("");
setToolTip("");
}
}

View File

@@ -0,0 +1,38 @@
#ifndef MODERNMENUBUTTON_H
#define MODERNMENUBUTTON_H
#include <QPushButton>
#include <QMenu>
#include <QVariant>
#include <QList>
class ModernMenuButton : public QPushButton {
Q_OBJECT
public:
explicit ModernMenuButton(QWidget *parent = nullptr);
void clear();
void addItem(const QString& title, const QString& subtitle,
const QString& secondLine = QString(),
const QVariant& userData = QVariant());
void setCurrentIndex(int index);
int currentIndex() const { return m_currentIndex; }
QVariant currentData() const;
int count() const { return m_actions.size(); }
Q_SIGNALS:
void currentIndexChanged(int index);
private Q_SLOTS:
void onActionTriggered(QAction *action);
private:
void updateAppearance();
QMenu* m_menu;
QList<QAction*> m_actions;
int m_currentIndex;
};
#endif

View File

@@ -0,0 +1,116 @@
#include "modernmenuscrollbar.h"
#include <QPainter>
#include <algorithm>
namespace {
constexpr int kHandleNormalWidth = 4;
constexpr int kHandleHoverWidth = 8;
constexpr int kHandleMinHeight = 28;
constexpr int kTrackVerticalPadding = 3;
constexpr int kHandleAlphaNormal = 115;
constexpr int kHandleAlphaActive = 178;
}
ModernMenuScrollBar::ModernMenuScrollBar(Qt::Orientation orientation, QWidget *parent)
: QScrollBar(orientation, parent) {
setAttribute(Qt::WA_OpaquePaintEvent, false);
}
ModernMenuScrollBar::ModernMenuScrollBar(QWidget *parent)
: QScrollBar(parent) {
setAttribute(Qt::WA_OpaquePaintEvent, false);
}
void ModernMenuScrollBar::enterEvent(QEnterEvent *event) {
m_hovered = true;
update();
QScrollBar::enterEvent(event);
}
void ModernMenuScrollBar::leaveEvent(QEvent *event) {
m_hovered = false;
update();
QScrollBar::leaveEvent(event);
}
QRect ModernMenuScrollBar::computeHandleRect() const {
if (orientation() != Qt::Vertical) {
return {};
}
const int range = maximum() - minimum();
const int trackTop = kTrackVerticalPadding;
const int trackHeight = height() - 2 * kTrackVerticalPadding;
if (range <= 0 || trackHeight <= 0) {
return {};
}
const int page = pageStep();
int handleHeight = (page > 0)
? static_cast<int>(static_cast<qint64>(trackHeight) * page / (range + page))
: kHandleMinHeight;
handleHeight = std::clamp(handleHeight, kHandleMinHeight, trackHeight);
const int availableTrack = trackHeight - handleHeight;
int handleY = trackTop;
if (availableTrack > 0) {
handleY = trackTop + static_cast<int>(
static_cast<qint64>(sliderPosition() - minimum()) * availableTrack / range);
}
const bool active = m_hovered || isSliderDown();
const int handleW = active ? kHandleHoverWidth : kHandleNormalWidth;
const int handleX = (width() - handleW) / 2;
return {handleX, handleY, handleW, handleHeight};
}
void ModernMenuScrollBar::paintEvent(QPaintEvent *event) {
Q_UNUSED(event);
const QRect r = computeHandleRect();
if (r.isEmpty()) {
return;
}
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
const bool active = isSliderDown() || m_hovered;
const QColor handleColor(255, 255, 255, active ? kHandleAlphaActive : kHandleAlphaNormal);
painter.setBrush(handleColor);
const qreal radius = r.width() / 2.0;
painter.drawRoundedRect(QRectF(r), radius, radius);
}

View File

@@ -0,0 +1,45 @@
#ifndef MODERNMENUSCROLLBAR_H
#define MODERNMENUSCROLLBAR_H
#include <QScrollBar>
#include <QPaintEvent>
class ModernMenuScrollBar : public QScrollBar {
Q_OBJECT
public:
explicit ModernMenuScrollBar(Qt::Orientation orientation, QWidget *parent = nullptr);
explicit ModernMenuScrollBar(QWidget *parent = nullptr);
~ModernMenuScrollBar() override = default;
protected:
void paintEvent(QPaintEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
private:
QRect computeHandleRect() const;
bool m_hovered = false;
};
#endif

View File

@@ -0,0 +1,216 @@
#include "modernmessagebox.h"
#include "modernswitch.h"
#include "../managers/thememanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QPixmap>
#include <QShowEvent>
#include <QSizePolicy>
#include <QTimer>
namespace {
constexpr int kMessageTextMinWidth = 360;
constexpr int kMessageTextMaxWidth = 520;
constexpr int kMessageTextVerticalSlack = 18;
}
ModernMessageBox::ModernMessageBox(QWidget *parent) : ModernDialogBase(parent) {}
bool ModernMessageBox::question(QWidget *parent, const QString &title, const QString &text, const QString &yesText, const QString &noText, ButtonType yesType, IconType iconType) {
ModernMessageBox box(parent);
box.setupUi(title, text, yesText, noText, yesType, iconType);
box.exec();
return box.m_result;
}
bool ModernMessageBox::questionWithToggle(QWidget *parent,
const QString &title,
const QString &text,
const QString &toggleText,
bool *toggleChecked,
const QString &yesText,
const QString &noText,
ButtonType yesType,
IconType iconType) {
ModernMessageBox box(parent);
box.setupUi(title, text, yesText, noText, yesType, iconType, toggleText,
toggleChecked ? *toggleChecked : false);
box.exec();
if (toggleChecked) {
*toggleChecked = box.m_toggleChecked;
}
return box.m_result;
}
void ModernMessageBox::information(QWidget *parent, const QString &title, const QString &text, const QString &okText) {
ModernMessageBox box(parent);
box.setupUi(title, text, okText, "", Primary, Info);
box.exec();
}
void ModernMessageBox::warning(QWidget *parent, const QString &title, const QString &text, const QString &okText) {
ModernMessageBox box(parent);
box.setupUi(title, text, okText, "", Primary, Warning);
box.exec();
}
void ModernMessageBox::critical(QWidget *parent, const QString &title, const QString &text, const QString &okText) {
ModernMessageBox box(parent);
box.setupUi(title, text, okText, "", Danger, Error);
box.exec();
}
void ModernMessageBox::setupUi(const QString &title,
const QString &text,
const QString &yesText,
const QString &noText,
ButtonType yesType,
IconType iconType,
const QString &toggleText,
bool toggleChecked) {
setTitle(title);
m_toggleChecked = toggleChecked;
auto *bodyLayout = new QHBoxLayout();
bodyLayout->setSpacing(16);
if (iconType != NoIcon) {
auto *iconLabel = new QLabel(this);
iconLabel->setFixedSize(40, 40);
QString iconPath;
QColor iconColor;
if (iconType == Question || iconType == Info) {
iconPath = QStringLiteral(":/svg/light/question.svg");
iconColor = ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#93C5FD"))
: QColor(QStringLiteral("#2563EB"));
} else if (iconType == Warning) {
iconPath = QStringLiteral(":/svg/light/warning.svg");
iconColor = ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#FCD34D"))
: QColor(QStringLiteral("#D97706"));
} else if (iconType == Error) {
iconPath = QStringLiteral(":/svg/light/error.svg");
iconColor = ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#FCA5A5"))
: QColor(QStringLiteral("#DC2626"));
}
iconLabel->setPixmap(
ThemeManager::getAdaptiveIcon(iconPath, iconColor).pixmap(40, 40));
auto *iconVLayout = new QVBoxLayout();
iconVLayout->addWidget(iconLabel);
iconVLayout->addStretch();
bodyLayout->addLayout(iconVLayout);
}
auto *contentVLayout = new QVBoxLayout();
contentVLayout->setContentsMargins(0, 0, 0, 0);
contentVLayout->setSpacing(14);
m_textLabel = new QLabel(text, this);
m_textLabel->setObjectName("dialog-text");
m_textLabel->setTextFormat(Qt::PlainText);
m_textLabel->setWordWrap(true);
m_textLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_textLabel->setMinimumWidth(kMessageTextMinWidth);
m_textLabel->setMaximumWidth(kMessageTextMaxWidth);
m_textLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
contentVLayout->addWidget(m_textLabel);
if (!toggleText.trimmed().isEmpty()) {
auto *toggleRow = new QWidget(this);
toggleRow->setObjectName("dialog-option-row");
auto *toggleLayout = new QHBoxLayout(toggleRow);
toggleLayout->setContentsMargins(0, 0, 0, 0);
toggleLayout->setSpacing(10);
auto *toggleLabel = new QLabel(toggleText, toggleRow);
toggleLabel->setObjectName("dialog-option-label");
toggleLayout->addWidget(toggleLabel);
auto *toggleSwitch = new ModernSwitch(toggleRow);
toggleSwitch->setChecked(toggleChecked);
connect(toggleSwitch, &ModernSwitch::toggled, this,
[this](bool checked) { m_toggleChecked = checked; });
toggleLayout->addWidget(toggleSwitch);
toggleLayout->addStretch();
contentVLayout->addWidget(toggleRow);
}
bodyLayout->addLayout(contentVLayout, 1);
contentLayout()->addLayout(bodyLayout);
contentLayout()->addSpacing(24);
auto *btnLayout = new QHBoxLayout();
btnLayout->addStretch();
btnLayout->setSpacing(12);
if (!noText.isEmpty()) {
auto *noBtn = new QPushButton(noText, this);
noBtn->setObjectName("dialog-btn-cancel");
noBtn->setCursor(Qt::PointingHandCursor);
connect(noBtn, &QPushButton::clicked, this, [this]() {
m_result = false;
reject();
});
btnLayout->addWidget(noBtn);
}
auto *yesBtn = new QPushButton(yesText, this);
yesBtn->setCursor(Qt::PointingHandCursor);
yesBtn->setObjectName(yesType == Danger ? "dialog-btn-danger" : "dialog-btn-primary");
connect(yesBtn, &QPushButton::clicked, this, [this]() {
m_result = true;
accept();
});
btnLayout->addWidget(yesBtn);
contentLayout()->addLayout(btnLayout);
updateTextLabelHeight();
QTimer::singleShot(0, this, [this]() { updateTextLabelHeight(); });
}
void ModernMessageBox::showEvent(QShowEvent *event) {
ModernDialogBase::showEvent(event);
updateTextLabelHeight();
}
void ModernMessageBox::updateTextLabelHeight() {
if (!m_textLabel) {
return;
}
m_textLabel->ensurePolished();
int targetWidth = m_textLabel->width();
if (targetWidth <= 0) {
targetWidth = m_textLabel->minimumWidth();
}
targetWidth = qBound(kMessageTextMinWidth, targetWidth, kMessageTextMaxWidth);
const int labelHeight = m_textLabel->heightForWidth(targetWidth);
const int fallbackHeight = m_textLabel->sizeHint().height();
const int targetHeight =
qMax(labelHeight, fallbackHeight) + kMessageTextVerticalSlack;
if (m_textLabel->minimumHeight() != targetHeight) {
m_textLabel->setMinimumHeight(targetHeight);
}
m_textLabel->updateGeometry();
adjustSize();
}

View File

@@ -0,0 +1,74 @@
#ifndef MODERNMESSAGEBOX_H
#define MODERNMESSAGEBOX_H
#include "moderndialogbase.h"
#include <QString>
class QLabel;
class QShowEvent;
class ModernMessageBox : public ModernDialogBase {
Q_OBJECT
public:
enum ButtonType { Primary, Danger };
enum IconType { NoIcon, Info, Question, Warning, Error };
explicit ModernMessageBox(QWidget *parent = nullptr);
static bool question(QWidget *parent,
const QString &title,
const QString &text,
const QString &yesText = "Yes",
const QString &noText = "Cancel",
ButtonType yesType = Primary,
IconType iconType = Question);
static bool questionWithToggle(QWidget *parent,
const QString &title,
const QString &text,
const QString &toggleText,
bool *toggleChecked,
const QString &yesText = "Yes",
const QString &noText = "Cancel",
ButtonType yesType = Primary,
IconType iconType = Question);
static void information(QWidget *parent,
const QString &title,
const QString &text,
const QString &okText = "OK");
static void warning(QWidget *parent,
const QString &title,
const QString &text,
const QString &okText = "OK");
static void critical(QWidget *parent,
const QString &title,
const QString &text,
const QString &okText = "OK");
protected:
void showEvent(QShowEvent *event) override;
private:
void setupUi(const QString &title,
const QString &text,
const QString &yesText,
const QString &noText,
ButtonType yesType,
IconType iconType,
const QString &toggleText = QString(),
bool toggleChecked = false);
void updateTextLabelHeight();
QLabel *m_textLabel = nullptr;
bool m_result = false;
bool m_toggleChecked = false;
};
#endif

View File

@@ -0,0 +1,14 @@
#include "modernnumberinput.h"
#include <QAbstractSpinBox>
ModernNumberInput::ModernNumberInput(QWidget *parent)
: QSpinBox(parent)
{
setObjectName("ModernNumberInput");
setRange(0, 10000);
setAlignment(Qt::AlignCenter);
setButtonSymbols(QAbstractSpinBox::NoButtons);
setKeyboardTracking(false);
setFixedSize(118, 34);
}

View File

@@ -0,0 +1,14 @@
#ifndef MODERNNUMBERINPUT_H
#define MODERNNUMBERINPUT_H
#include <QSpinBox>
class ModernNumberInput : public QSpinBox
{
Q_OBJECT
public:
explicit ModernNumberInput(QWidget *parent = nullptr);
};
#endif

View File

@@ -0,0 +1,248 @@
#include "modernscrollpanel.h"
#include "modernmenuscrollbar.h"
#include <QGraphicsDropShadowEffect>
#include <QPainter>
#include <QEvent>
#include <QFont>
#include <QFontMetrics>
#include <QScrollBar>
namespace {
constexpr int kMenuItemMinHeight = 42;
constexpr int kMenuItemVerticalPadding = 18;
constexpr int kMenuItemSpacing = 2;
constexpr int kScrollAreaFrameReserve = 2;
int menuItemHeight(const QFont &font)
{
const QFontMetrics fm(font);
return qMax(kMenuItemMinHeight, fm.height() + kMenuItemVerticalPadding);
}
}
ModernScrollPanel::ModernScrollPanel(QWidget *parent) : QFrame(parent), m_maxContentWidth(0) {
setObjectName("modernScrollMenuOuter");
setWindowFlags(Qt::Widget | Qt::FramelessWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_StyledBackground, true);
auto *shadow = new QGraphicsDropShadowEffect(this);
shadow->setBlurRadius(20);
shadow->setColor(QColor(0, 0, 0, 150));
shadow->setOffset(0, 4);
setGraphicsEffect(shadow);
m_mainLayout = new QVBoxLayout(this);
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(0);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setObjectName("modernMenuScrollArea");
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
if (auto *vp = m_scrollArea->viewport()) {
vp->setObjectName("modernMenuViewport");
vp->setAutoFillBackground(false);
vp->setAttribute(Qt::WA_StyledBackground, true);
}
auto *vBar = new ModernMenuScrollBar(Qt::Vertical, m_scrollArea);
vBar->setObjectName("modernMenuScrollBar");
m_scrollArea->setVerticalScrollBar(vBar);
m_container = new QWidget(m_scrollArea);
m_container->setObjectName("modernMenuContainer");
m_container->setAttribute(Qt::WA_StyledBackground, true);
m_layout = new QVBoxLayout(m_container);
m_layout->setContentsMargins(4, 4, 4, 4);
m_layout->setSpacing(kMenuItemSpacing);
m_scrollArea->setWidget(m_container);
m_mainLayout->addWidget(m_scrollArea);
}
void ModernScrollPanel::addItem(const QString &text, const QVariant &userData, bool isSelected) {
auto *btn = new QPushButton(m_container);
btn->setObjectName("modernMenuItemBtn");
QPixmap pixmap(18, 18);
pixmap.fill(Qt::transparent);
if (isSelected) {
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QColor(255, 255, 255, 255));
QFont font = painter.font();
font.setPixelSize(14);
font.setBold(true);
painter.setFont(font);
painter.drawText(pixmap.rect(), Qt::AlignCenter, "");
}
btn->setIcon(QIcon(pixmap));
btn->setText(text);
btn->setToolTip(text);
btn->setCursor(Qt::PointingHandCursor);
btn->setFocusPolicy(Qt::NoFocus);
btn->setFixedHeight(menuItemHeight(btn->font()));
QFontMetrics fm(btn->font());
m_maxContentWidth = qMax(m_maxContentWidth, fm.horizontalAdvance(text));
m_items.append({btn, text});
connect(btn, &QPushButton::clicked, this, [this, userData, text]() {
emit itemTriggered(userData, text);
});
m_layout->addWidget(btn);
}
void ModernScrollPanel::finalizeLayout(int maxHeight, int maxWidth) {
m_layout->addStretch();
m_container->adjustSize();
int contentHeight = m_container->sizeHint().height();
if (contentHeight < 10) {
contentHeight = 40;
}
m_container->setMinimumHeight(contentHeight);
const int panelContentHeight = contentHeight + kScrollAreaFrameReserve;
int finalHeight = qMin(panelContentHeight, maxHeight);
bool needsVScroll = (panelContentHeight > maxHeight);
if (needsVScroll) {
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
} else {
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
}
int scrollBarBuffer = needsVScroll ? 12 : 0;
int requiredWidth = m_maxContentWidth + 45 + scrollBarBuffer;
int minWidth = 130;
int finalWidth = qBound(minWidth, requiredWidth, maxWidth);
int textAvailableWidth = finalWidth - 45 - scrollBarBuffer;
if (textAvailableWidth < 20) {
textAvailableWidth = 20;
}
for (const auto& item : m_items) {
QFontMetrics fm(item.btn->font());
QString elidedText = fm.elidedText(item.fullText, Qt::ElideRight, textAvailableWidth);
item.btn->setText(elidedText);
}
m_scrollArea->setFixedSize(finalWidth, finalHeight);
this->setFixedSize(finalWidth, finalHeight);
}
void ModernScrollPanel::wheelEvent(QWheelEvent *event) {
QFrame::wheelEvent(event);
event->accept();
}
void ModernScrollPanel::paintEvent(QPaintEvent *event) {
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setBrush(QColor(30, 30, 30, 242));
QPen pen(QColor(255, 255, 255, 26));
pen.setWidth(1);
painter.setPen(pen);
QRectF rf(rect().x() + 0.5, rect().y() + 0.5,
rect().width() - 1.0, rect().height() - 1.0);
painter.drawRoundedRect(rf, 6.0, 6.0);
}

View File

@@ -0,0 +1,49 @@
#ifndef MODERNSCROLLPANEL_H
#define MODERNSCROLLPANEL_H
#include <QFrame>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QPushButton>
#include <QVariant>
#include <QString>
#include <QList>
#include <QWheelEvent>
#include <QPaintEvent>
class ModernScrollPanel : public QFrame {
Q_OBJECT
public:
explicit ModernScrollPanel(QWidget *parent = nullptr);
~ModernScrollPanel() override = default;
void addItem(const QString &text, const QVariant &userData, bool isSelected = false);
void finalizeLayout(int maxHeight, int maxWidth = 250);
signals:
void itemTriggered(const QVariant &userData, const QString &text);
protected:
void paintEvent(QPaintEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
struct MenuItem {
QPushButton* btn;
QString fullText;
};
QVBoxLayout *m_mainLayout;
QScrollArea *m_scrollArea;
QWidget *m_container;
QVBoxLayout *m_layout;
QList<MenuItem> m_items;
int m_maxContentWidth;
};
#endif

View File

@@ -0,0 +1,142 @@
#include "modernslider.h"
#include <QPainter>
#include <QPainterPath>
ModernSlider::ModernSlider(Qt::Orientation orientation, QWidget *parent)
: QSlider(orientation, parent) {
setCursor(Qt::PointingHandCursor);
setMouseTracking(true);
setAttribute(Qt::WA_Hover, true);
}
void ModernSlider::setBufferValue(int value) {
if (m_bufferValue != value) {
m_bufferValue = value;
update();
}
}
int ModernSlider::bufferValue() const {
return m_bufferValue;
}
void ModernSlider::mousePressEvent(QMouseEvent *ev) {
if (ev->button() == Qt::LeftButton) {
m_isPressed = true;
const int edgePadding = qMax(m_normalHandleRadius, m_activeHandleRadius);
const double trackLeft = edgePadding;
const double trackWidth = width() - 2 * edgePadding;
if (orientation() == Qt::Horizontal) {
double pos = (ev->pos().x() - trackLeft) / trackWidth;
pos = qBound(0.0, pos, 1.0);
setValue(pos * (maximum() - minimum()) + minimum());
emit sliderMoved(value());
} else {
const double trackTop = edgePadding;
const double trackHeight = height() - 2 * edgePadding;
double pos = 1.0 - ((ev->pos().y() - trackTop) / trackHeight);
pos = qBound(0.0, pos, 1.0);
setValue(pos * (maximum() - minimum()) + minimum());
emit sliderMoved(value());
}
ev->accept();
update();
}
QSlider::mousePressEvent(ev);
}
void ModernSlider::mouseReleaseEvent(QMouseEvent *ev) {
if (ev->button() == Qt::LeftButton) {
m_isPressed = false;
update();
}
QSlider::mouseReleaseEvent(ev);
}
void ModernSlider::mouseMoveEvent(QMouseEvent *ev) {
QSlider::mouseMoveEvent(ev);
}
bool ModernSlider::event(QEvent *ev) {
if (ev->type() == QEvent::Enter) {
m_isHovered = true;
update();
} else if (ev->type() == QEvent::Leave) {
m_isHovered = false;
update();
}
return QSlider::event(ev);
}
void ModernSlider::paintEvent(QPaintEvent *ev) {
Q_UNUSED(ev);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int trackHeight =
(m_isHovered || m_isPressed) ? m_hoverTrackHeight : m_normalTrackHeight;
const int handleRadius =
(m_isHovered || m_isPressed) ? m_activeHandleRadius : m_normalHandleRadius;
const int edgePadding = qMax(m_normalHandleRadius, m_activeHandleRadius);
QRectF trackRect;
if (orientation() == Qt::Horizontal) {
trackRect = QRectF(edgePadding, (height() - trackHeight) / 2.0,
width() - 2 * edgePadding, trackHeight);
} else {
trackRect = QRectF((width() - trackHeight) / 2.0, edgePadding,
trackHeight, height() - 2 * edgePadding);
}
painter.setPen(Qt::NoPen);
painter.setBrush(m_trackColor);
painter.drawRoundedRect(trackRect, trackHeight / 2.0, trackHeight / 2.0);
double range = maximum() - minimum();
double valueRatio =
range > 0 ? static_cast<double>(value() - minimum()) / range : 0.0;
double bufferRatio = range > 0
? static_cast<double>(m_bufferValue - minimum()) / range
: 0.0;
valueRatio = qBound(0.0, valueRatio, 1.0);
bufferRatio = qBound(0.0, bufferRatio, 1.0);
if (bufferRatio > 0) {
QRectF bufferRect = trackRect;
if (orientation() == Qt::Horizontal) {
bufferRect.setWidth(trackRect.width() * bufferRatio);
} else {
bufferRect.setTop(trackRect.bottom() - trackRect.height() * bufferRatio);
}
painter.setBrush(m_bufferColor);
painter.drawRoundedRect(bufferRect, trackHeight / 2.0, trackHeight / 2.0);
}
if (valueRatio > 0) {
QRectF playedRect = trackRect;
if (orientation() == Qt::Horizontal) {
playedRect.setWidth(trackRect.width() * valueRatio);
} else {
playedRect.setTop(trackRect.bottom() - trackRect.height() * valueRatio);
}
painter.setBrush(m_themeColor);
painter.drawRoundedRect(playedRect, trackHeight / 2.0, trackHeight / 2.0);
}
QPointF handleCenter;
if (orientation() == Qt::Horizontal) {
handleCenter = QPointF(trackRect.left() + trackRect.width() * valueRatio,
height() / 2.0);
} else {
handleCenter =
QPointF(width() / 2.0,
trackRect.bottom() - trackRect.height() * valueRatio);
}
painter.setBrush(m_handleColor);
painter.drawEllipse(handleCenter, handleRadius, handleRadius);
}

View File

@@ -0,0 +1,88 @@
#ifndef MODERNSLIDER_H
#define MODERNSLIDER_H
#include <QSlider>
#include <QMouseEvent>
#include <QPaintEvent>
#include <QEvent>
#include <QColor>
#include <QtGlobal>
class ModernSlider : public QSlider {
Q_OBJECT
Q_PROPERTY(QColor themeColor READ themeColor WRITE setThemeColor)
Q_PROPERTY(QColor trackColor READ trackColor WRITE setTrackColor)
Q_PROPERTY(QColor bufferColor READ bufferColor WRITE setBufferColor)
Q_PROPERTY(QColor handleColor READ handleColor WRITE setHandleColor)
Q_PROPERTY(int normalTrackHeight READ normalTrackHeight WRITE setNormalTrackHeight)
Q_PROPERTY(int hoverTrackHeight READ hoverTrackHeight WRITE setHoverTrackHeight)
Q_PROPERTY(int normalHandleRadius READ normalHandleRadius WRITE setNormalHandleRadius)
Q_PROPERTY(int activeHandleRadius READ activeHandleRadius WRITE setActiveHandleRadius)
public:
explicit ModernSlider(Qt::Orientation orientation, QWidget *parent = nullptr);
void setBufferValue(int value);
int bufferValue() const;
QColor themeColor() const { return m_themeColor; }
void setThemeColor(const QColor &c) { m_themeColor = c; update(); }
QColor trackColor() const { return m_trackColor; }
void setTrackColor(const QColor &c) { m_trackColor = c; update(); }
QColor bufferColor() const { return m_bufferColor; }
void setBufferColor(const QColor &c) { m_bufferColor = c; update(); }
QColor handleColor() const { return m_handleColor; }
void setHandleColor(const QColor &c) { m_handleColor = c; update(); }
int normalTrackHeight() const { return m_normalTrackHeight; }
void setNormalTrackHeight(int value) {
m_normalTrackHeight = qMax(1, value);
update();
}
int hoverTrackHeight() const { return m_hoverTrackHeight; }
void setHoverTrackHeight(int value) {
m_hoverTrackHeight = qMax(1, value);
update();
}
int normalHandleRadius() const { return m_normalHandleRadius; }
void setNormalHandleRadius(int value) {
m_normalHandleRadius = qMax(1, value);
update();
}
int activeHandleRadius() const { return m_activeHandleRadius; }
void setActiveHandleRadius(int value) {
m_activeHandleRadius = qMax(1, value);
update();
}
protected:
void mousePressEvent(QMouseEvent *ev) override;
void mouseReleaseEvent(QMouseEvent *ev) override;
void mouseMoveEvent(QMouseEvent *ev) override;
bool event(QEvent *ev) override;
void paintEvent(QPaintEvent *ev) override;
private:
int m_bufferValue = 0;
bool m_isHovered = false;
bool m_isPressed = false;
QColor m_themeColor = QColor("#3B82F6");
QColor m_trackColor = QColor(255, 255, 255, 30);
QColor m_bufferColor = QColor(255, 255, 255, 80);
QColor m_handleColor = QColor("#FFFFFF");
int m_normalTrackHeight = 2;
int m_hoverTrackHeight = 4;
int m_normalHandleRadius = 4;
int m_activeHandleRadius = 6;
};
#endif

View File

@@ -0,0 +1,119 @@
#include "modernsortbutton.h"
#include <QAction>
ModernSortButton::ModernSortButton(QWidget *parent)
: QPushButton(parent), m_currentIndex(-1), m_descending(true)
{
m_menu = new QMenu(this);
setObjectName("modern-sort-btn");
setCursor(Qt::PointingHandCursor);
connect(m_menu, &QMenu::triggered, this, &ModernSortButton::onActionTriggered);
connect(this, &QPushButton::clicked, this, [this]() {
m_menu->adjustSize();
QPoint pos = mapToGlobal(QPoint(width() / 2, height() + 6));
pos.setX(pos.x() - m_menu->sizeHint().width() / 2);
m_menu->exec(pos);
});
}
void ModernSortButton::setSortItems(const QStringList& items)
{
m_options.clear();
m_menu->clear();
for (const QString& item : items) {
addSortOption(item, item);
}
}
void ModernSortButton::addSortOption(const QString& name, const QString& key)
{
m_options.append({name, key});
QAction* action = m_menu->addAction(name);
action->setData(static_cast<int>(m_options.size() - 1));
action->setCheckable(true);
if (m_options.size() == 1) {
m_currentIndex = 0;
m_descending = true;
updateAppearance();
}
}
QString ModernSortButton::currentSortKey() const
{
if (m_currentIndex >= 0 && m_currentIndex < m_options.size()) {
return m_options.at(m_currentIndex).key;
}
return QString();
}
bool ModernSortButton::isAscending() const
{
return !m_descending;
}
void ModernSortButton::setCurrentIndex(int index)
{
if (index >= 0 && index < m_options.size() && m_currentIndex != index) {
m_currentIndex = index;
updateAppearance();
}
}
void ModernSortButton::setDescending(bool descending)
{
if (m_descending != descending) {
m_descending = descending;
updateAppearance();
}
}
void ModernSortButton::onActionTriggered(QAction *action)
{
int index = action->data().toInt();
if (index == m_currentIndex) {
m_descending = !m_descending;
} else {
m_currentIndex = index;
m_descending = true;
}
updateAppearance();
Q_EMIT sortChanged(currentSortKey(), isAscending());
}
void ModernSortButton::updateAppearance()
{
const auto actionsList = m_menu->actions();
for (int i = 0; i < actionsList.size(); ++i) {
QAction* act = actionsList.at(i);
if (i == m_currentIndex) {
act->setChecked(true);
act->setText(m_options.at(i).name + (m_descending ? "" : ""));
} else {
act->setChecked(false);
act->setText(m_options.at(i).name);
}
}
if (m_currentIndex >= 0 && m_currentIndex < m_options.size()) {
setText(m_options.at(m_currentIndex).name + (m_descending ? "" : ""));
}
}

View File

@@ -0,0 +1,53 @@
#ifndef MODERNSORTBUTTON_H
#define MODERNSORTBUTTON_H
#include <QPushButton>
#include <QMenu>
#include <QStringList>
#include <QList>
struct SortOption {
QString name;
QString key;
};
class ModernSortButton : public QPushButton {
Q_OBJECT
public:
explicit ModernSortButton(QWidget *parent = nullptr);
void setSortItems(const QStringList& items);
void addSortOption(const QString& name, const QString& key);
QString currentSortKey() const;
bool isAscending() const;
int currentIndex() const { return m_currentIndex; }
bool isDescending() const { return m_descending; }
void setCurrentIndex(int index);
void setDescending(bool descending);
Q_SIGNALS:
void sortChanged(const QString& sortKey, bool ascending);
private Q_SLOTS:
void onActionTriggered(QAction *action);
private:
void updateAppearance();
QMenu* m_menu;
QList<SortOption> m_options;
int m_currentIndex;
bool m_descending;
};
#endif

View File

@@ -0,0 +1,116 @@
#include "modernswitch.h"
#include <QPainterPath>
ModernSwitch::ModernSwitch(QWidget* parent)
: QAbstractButton(parent),
m_offset(0.0),
m_trackColorActive(QColor("#00A4FF")),
m_trackColorInactive(QColor("#555555")),
m_thumbColor(QColor("#FFFFFF")),
m_isHovered(false)
{
setCheckable(true);
setCursor(Qt::PointingHandCursor);
m_animation = new QPropertyAnimation(this, "offset", this);
m_animation->setDuration(150);
m_animation->setEasingCurve(QEasingCurve::InOutSine);
connect(this, &ModernSwitch::toggled, this, &ModernSwitch::updateAnimation);
}
ModernSwitch::~ModernSwitch() = default;
qreal ModernSwitch::offset() const { return m_offset; }
void ModernSwitch::setOffset(qreal offset) {
m_offset = offset;
update();
}
QColor ModernSwitch::trackColorActive() const { return m_trackColorActive; }
void ModernSwitch::setTrackColorActive(const QColor& color) {
m_trackColorActive = color;
update();
}
QColor ModernSwitch::trackColorInactive() const { return m_trackColorInactive; }
void ModernSwitch::setTrackColorInactive(const QColor& color) {
m_trackColorInactive = color;
update();
}
QColor ModernSwitch::thumbColor() const { return m_thumbColor; }
void ModernSwitch::setThumbColor(const QColor& color) {
m_thumbColor = color;
update();
}
QSize ModernSwitch::sizeHint() const {
return QSize(44, 24);
}
void ModernSwitch::paintEvent(QPaintEvent* ) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
const QRectF trackRect(0.5, 0.5, width() - 1.0, height() - 1.0);
const qreal radius = trackRect.height() / 2.0;
QColor trackColor = isChecked() ? m_trackColorActive : m_trackColorInactive;
if (m_isHovered) {
trackColor = trackColor.lighter(110);
}
painter.setBrush(trackColor);
painter.drawRoundedRect(trackRect, radius, radius);
constexpr qreal thumbMargin = 3.0;
const qreal thumbDiameter = trackRect.height() - thumbMargin * 2.0;
const qreal thumbTravel = trackRect.width() - thumbDiameter - thumbMargin * 2.0;
const qreal thumbX = trackRect.left() + thumbMargin + m_offset * thumbTravel;
const qreal thumbY = trackRect.top() + thumbMargin;
painter.setBrush(m_thumbColor);
painter.drawEllipse(QRectF(thumbX, thumbY, thumbDiameter, thumbDiameter));
}
void ModernSwitch::nextCheckState() {
QAbstractButton::nextCheckState();
}
void ModernSwitch::checkStateSet() {
QAbstractButton::checkStateSet();
if (signalsBlocked()) {
m_animation->stop();
setOffset(isChecked() ? 1.0 : 0.0);
}
}
void ModernSwitch::enterEvent(QEnterEvent* event) {
m_isHovered = true;
update();
QAbstractButton::enterEvent(event);
}
void ModernSwitch::leaveEvent(QEvent* event) {
m_isHovered = false;
update();
QAbstractButton::leaveEvent(event);
}
void ModernSwitch::updateAnimation(bool checked) {
m_animation->stop();
m_animation->setStartValue(m_offset);
m_animation->setEndValue(checked ? 1.0 : 0.0);
m_animation->start();
}

View File

@@ -0,0 +1,59 @@
#ifndef MODERNSWITCH_H
#define MODERNSWITCH_H
#include <QAbstractButton>
#include <QPropertyAnimation>
#include <QColor>
#include <QPainter>
#include <QEvent>
class ModernSwitch : public QAbstractButton {
Q_OBJECT
Q_PROPERTY(qreal offset READ offset WRITE setOffset)
Q_PROPERTY(QColor trackColorActive READ trackColorActive WRITE setTrackColorActive)
Q_PROPERTY(QColor trackColorInactive READ trackColorInactive WRITE setTrackColorInactive)
Q_PROPERTY(QColor thumbColor READ thumbColor WRITE setThumbColor)
public:
explicit ModernSwitch(QWidget* parent = nullptr);
~ModernSwitch() override;
qreal offset() const;
void setOffset(qreal offset);
QColor trackColorActive() const;
void setTrackColorActive(const QColor& color);
QColor trackColorInactive() const;
void setTrackColorInactive(const QColor& color);
QColor thumbColor() const;
void setThumbColor(const QColor& color);
QSize sizeHint() const override;
protected:
void paintEvent(QPaintEvent* event) override;
void nextCheckState() override;
void checkStateSet() override;
void enterEvent(QEnterEvent* event) override;
void leaveEvent(QEvent* event) override;
private slots:
void updateAnimation(bool checked);
private:
qreal m_offset;
QPropertyAnimation* m_animation;
QColor m_trackColorActive;
QColor m_trackColorInactive;
QColor m_thumbColor;
bool m_isHovered;
};
#endif

View File

@@ -0,0 +1,547 @@
#include "moderntaginput.h"
#include "tagpresetoptionrow.h"
#include "../managers/thememanager.h"
#include <QAction>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QMenu>
#include <QPushButton>
#include <QResizeEvent>
#include <QScrollArea>
#include <QShowEvent>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
ModernTagInput::ModernTagInput(QWidget *parent)
: QFrame(parent) {
setObjectName("TagInputContainer");
setFrameShape(QFrame::NoFrame);
setFixedHeight(32);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_mainLayout = new QHBoxLayout(this);
m_mainLayout->setContentsMargins(4, 0, 2, 0);
m_mainLayout->setSpacing(2);
m_tagArea = new QHBoxLayout();
m_tagArea->setContentsMargins(0, 0, 0, 0);
m_tagArea->setSpacing(3);
m_mainLayout->addLayout(m_tagArea);
m_lineEdit = new QLineEdit(this);
m_lineEdit->setObjectName("TagInputInlineEdit");
m_lineEdit->setPlaceholderText(tr("Type & Enter..."));
m_lineEdit->setFrame(false);
m_lineEdit->setMinimumWidth(60);
m_lineEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_mainLayout->addWidget(m_lineEdit, 1);
m_buttonSpacer = new QWidget(this);
m_buttonSpacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_buttonSpacer->hide();
m_mainLayout->addWidget(m_buttonSpacer, 1);
connect(m_lineEdit, &QLineEdit::returnPressed, this, [this]() {
QString text = m_lineEdit->text().trimmed();
if (!text.isEmpty()) {
addCustomTag(text);
m_lineEdit->clear();
}
});
connect(m_lineEdit, &QLineEdit::textChanged, this,
[this]() { updateClearButtonVisibility(); });
m_clearBtn = new QToolButton(this);
m_clearBtn->setObjectName("TagInputClearBtn");
m_clearBtn->setCursor(Qt::PointingHandCursor);
m_clearBtn->setFixedSize(12, 12);
m_clearBtn->setToolTip(tr("Clear all"));
m_mainLayout->addWidget(m_clearBtn);
connect(m_clearBtn, &QToolButton::clicked, this, &ModernTagInput::clearAll);
m_moreBtn = new QToolButton(this);
m_moreBtn->setObjectName("TagInputMoreBtn");
m_moreBtn->setCursor(Qt::PointingHandCursor);
m_moreBtn->setFixedSize(20, 20);
m_moreBtn->setToolTip(tr("Presets"));
m_mainLayout->addWidget(m_moreBtn);
connect(m_moreBtn, &QToolButton::clicked, this, &ModernTagInput::showPresetMenu);
connect(ThemeManager::instance(), &ThemeManager::themeChanged,
this, [this]() {
rebuildTags();
});
updateClearButtonVisibility();
}
void ModernTagInput::addPreset(const QString &label, const QString &value) {
m_presets.append({label, value});
}
void ModernTagInput::clearPresets() {
m_presets.clear();
}
void ModernTagInput::setValue(const QString &commaSeparated) {
m_selectedValues.clear();
if (!commaSeparated.isEmpty()) {
const auto parts = commaSeparated.split(',', Qt::SkipEmptyParts);
for (const auto &p : parts) {
const QString value = p.trimmed();
if (!value.isEmpty() && !m_selectedValues.contains(value)) {
m_selectedValues.append(value);
}
}
}
rebuildTags();
}
QString ModernTagInput::value() const {
return m_selectedValues.join(",");
}
void ModernTagInput::setEditable(bool editable) {
if (m_isEditable == editable) {
return;
}
m_isEditable = editable;
if (!m_isEditable) {
m_lineEdit->clear();
}
m_lineEdit->setVisible(m_isEditable);
m_buttonSpacer->setVisible(!m_isEditable);
updateClearButtonVisibility();
rebuildTags();
}
bool ModernTagInput::isEditable() const {
return m_isEditable;
}
void ModernTagInput::setMaxPopupHeight(int height) {
m_maxPopupHeight = qMax(100, height);
}
void ModernTagInput::setPopupMode(PopupMode mode) {
m_popupMode = mode;
}
void ModernTagInput::setPopupThreshold(int threshold) {
m_popupThreshold = qMax(1, threshold);
}
QSize ModernTagInput::sizeHint() const {
return QSize(260, 32);
}
QSize ModernTagInput::minimumSizeHint() const {
return QSize(180, 32);
}
void ModernTagInput::resizeEvent(QResizeEvent *event) {
QFrame::resizeEvent(event);
if (event->size().width() != event->oldSize().width()) {
rebuildTags();
}
}
void ModernTagInput::showEvent(QShowEvent *event) {
QFrame::showEvent(event);
QTimer::singleShot(0, this, [this]() { rebuildTags(); });
}
void ModernTagInput::rebuildTags() {
if (m_isRebuilding) {
return;
}
m_isRebuilding = true;
while (m_tagArea->count() > 0) {
QLayoutItem *item = m_tagArea->takeAt(0);
if (item->widget()) {
delete item->widget();
}
delete item;
}
updateClearButtonVisibility();
const int maxChipWidth = availableChipWidth();
int usedWidth = 0;
int hiddenCount = 0;
for (int i = 0; i < m_selectedValues.size(); ++i) {
const QString &value = m_selectedValues.at(i);
auto *chip = createChipButton(displayTextForValue(value));
const int chipSpacing = m_tagArea->count() > 0 ? m_tagArea->spacing() : 0;
const int chipWidth = chip->sizeHint().width();
const int remainingAfterCurrent = m_selectedValues.size() - i - 1;
int summaryWidth = 0;
if (remainingAfterCurrent > 0) {
auto *summaryChip = createChipButton(
QString("+%1").arg(remainingAfterCurrent), "TagChipSummary");
summaryWidth = m_tagArea->spacing() + summaryChip->sizeHint().width();
delete summaryChip;
}
const int projectedWidth = usedWidth + chipSpacing + chipWidth + summaryWidth;
if (m_tagArea->count() > 0 && projectedWidth > maxChipWidth) {
delete chip;
hiddenCount = m_selectedValues.size() - i;
break;
}
connect(chip, &QPushButton::clicked, this, [this, value]() {
m_selectedValues.removeAll(value);
scheduleRebuild(true);
});
m_tagArea->addWidget(chip);
usedWidth += chipSpacing + chipWidth;
}
if (hiddenCount > 0) {
auto *summaryChip =
createChipButton(QString("+%1").arg(hiddenCount), "TagChipSummary");
summaryChip->setToolTip(selectionToolTip());
connect(summaryChip, &QPushButton::clicked, this,
&ModernTagInput::showPresetMenu);
m_tagArea->addWidget(summaryChip);
}
setToolTip(selectionToolTip());
if (m_mainLayout) {
m_mainLayout->invalidate();
}
if (layout()) {
layout()->activate();
}
updateGeometry();
update();
m_isRebuilding = false;
}
void ModernTagInput::scheduleRebuild(bool shouldEmitChange) {
if (shouldEmitChange) {
m_emitChangeQueued = true;
}
if (m_isRebuildQueued) {
return;
}
m_isRebuildQueued = true;
QTimer::singleShot(0, this, [this]() {
m_isRebuildQueued = false;
rebuildTags();
if (m_emitChangeQueued) {
m_emitChangeQueued = false;
this->emitChange();
}
});
}
void ModernTagInput::addCustomTag(const QString &text) {
if (!m_isEditable) {
return;
}
if (m_selectedValues.contains(text)) {
return;
}
m_selectedValues.append(text);
scheduleRebuild(true);
}
void ModernTagInput::showPresetMenu() {
switch (m_popupMode) {
case ForceCompact:
showPresetCompactMenu();
return;
case ForcePopup:
showPresetPopup();
return;
case Auto:
default:
if (m_presets.size() <= m_popupThreshold) {
showPresetCompactMenu();
} else {
showPresetPopup();
}
return;
}
}
void ModernTagInput::showPresetCompactMenu() {
auto *menu = new QMenu(this);
menu->setObjectName("TagPresetMenu");
for (const auto &preset : m_presets) {
auto *action = menu->addAction(preset.label);
action->setCheckable(true);
action->setChecked(m_selectedValues.contains(preset.value));
connect(action, &QAction::toggled, this,
[this, val = preset.value](bool checked) {
if (checked && !m_selectedValues.contains(val)) {
m_selectedValues.append(val);
} else if (!checked) {
m_selectedValues.removeAll(val);
}
scheduleRebuild(true);
});
}
const QStringList customValues = customSelectedValues();
if (!customValues.isEmpty()) {
if (!m_presets.isEmpty()) {
menu->addSeparator();
}
for (const QString &value : customValues) {
auto *action = menu->addAction(displayTextForValue(value));
action->setCheckable(true);
action->setChecked(true);
connect(action, &QAction::toggled, this,
[this, value](bool checked) {
if (checked && !m_selectedValues.contains(value)) {
m_selectedValues.append(value);
} else if (!checked) {
m_selectedValues.removeAll(value);
}
scheduleRebuild(true);
});
}
}
menu->popup(m_moreBtn->mapToGlobal(QPoint(0, m_moreBtn->height() + 2)));
connect(menu, &QMenu::aboutToHide, menu, &QMenu::deleteLater);
}
void ModernTagInput::showPresetPopup() {
auto *popup = new QFrame(this, Qt::Popup);
popup->setObjectName("TagPresetPopup");
popup->setAttribute(Qt::WA_DeleteOnClose);
popup->setFrameShape(QFrame::NoFrame);
auto *popupLayout = new QVBoxLayout(popup);
popupLayout->setContentsMargins(0, 6, 0, 6);
popupLayout->setSpacing(4);
auto *searchRow = new QWidget(popup);
auto *searchRowLayout = new QHBoxLayout(searchRow);
searchRowLayout->setContentsMargins(6, 0, 6, 0);
searchRowLayout->setSpacing(0);
auto *searchEdit = new QLineEdit(searchRow);
searchEdit->setObjectName("TagPresetSearchEdit");
searchEdit->setPlaceholderText(tr("Search..."));
searchEdit->setClearButtonEnabled(true);
searchRowLayout->addWidget(searchEdit);
popupLayout->addWidget(searchRow);
auto *scrollArea = new QScrollArea(popup);
scrollArea->setObjectName("TagPresetScrollArea");
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setMaximumHeight(m_maxPopupHeight);
auto *listWidget = new QWidget();
auto *listLayout = new QVBoxLayout(listWidget);
listLayout->setContentsMargins(6, 0, 6, 0);
listLayout->setSpacing(0);
const QStringList customValues = customSelectedValues();
QList<TagPresetOptionRow *> optionRows;
optionRows.reserve(m_presets.size() + customValues.size());
for (const auto &preset : m_presets) {
auto *optionRow = new TagPresetOptionRow(preset.label, listWidget);
optionRow->setChecked(m_selectedValues.contains(preset.value));
connect(optionRow->checkBox(), &QCheckBox::toggled, this,
[this, val = preset.value](bool checked) {
if (checked && !m_selectedValues.contains(val)) {
m_selectedValues.append(val);
} else if (!checked) {
m_selectedValues.removeAll(val);
}
scheduleRebuild(true);
});
listLayout->addWidget(optionRow);
optionRows.append(optionRow);
}
for (const QString &value : customValues) {
auto *optionRow =
new TagPresetOptionRow(displayTextForValue(value), listWidget);
optionRow->setChecked(true);
connect(optionRow->checkBox(), &QCheckBox::toggled, this,
[this, value](bool checked) {
if (checked && !m_selectedValues.contains(value)) {
m_selectedValues.append(value);
} else if (!checked) {
m_selectedValues.removeAll(value);
}
scheduleRebuild(true);
});
listLayout->addWidget(optionRow);
optionRows.append(optionRow);
}
listLayout->addStretch(1);
scrollArea->setWidget(listWidget);
popupLayout->addWidget(scrollArea);
connect(searchEdit, &QLineEdit::textChanged, popup,
[optionRows](const QString &filter) {
const QString keyword = filter.trimmed();
for (auto *optionRow : optionRows) {
if (keyword.isEmpty()) {
optionRow->setVisible(true);
} else {
optionRow->setVisible(
optionRow->text().contains(keyword, Qt::CaseInsensitive));
}
}
});
popup->adjustSize();
const int popupWidth = qMax(popup->sizeHint().width(), this->width());
popup->setFixedWidth(popupWidth);
QPoint globalPos = mapToGlobal(QPoint(width() - popupWidth, height() + 2));
popup->move(globalPos);
popup->show();
searchEdit->setFocus();
}
void ModernTagInput::clearAll() {
m_selectedValues.clear();
if (m_isEditable) {
m_lineEdit->clear();
}
scheduleRebuild(true);
}
void ModernTagInput::emitChange() {
emit valueChanged(value());
}
void ModernTagInput::updateClearButtonVisibility() {
m_clearBtn->setVisible(!m_selectedValues.isEmpty() ||
(m_isEditable && !m_lineEdit->text().isEmpty()));
}
QString ModernTagInput::displayTextForValue(const QString &value) const {
for (const auto &preset : m_presets) {
if (preset.value == value) {
return preset.label;
}
}
return value;
}
QStringList ModernTagInput::customSelectedValues() const {
QStringList customValues;
for (const QString &value : m_selectedValues) {
bool isPreset = false;
for (const auto &preset : m_presets) {
if (preset.value == value) {
isPreset = true;
break;
}
}
if (!isPreset && !customValues.contains(value)) {
customValues.append(value);
}
}
return customValues;
}
QString ModernTagInput::selectionToolTip() const {
QStringList labels;
labels.reserve(m_selectedValues.size());
for (const QString &value : m_selectedValues) {
labels.append(displayTextForValue(value));
}
return labels.join(", ");
}
int ModernTagInput::availableChipWidth() const {
if (!m_mainLayout) {
return 0;
}
const QMargins margins = m_mainLayout->contentsMargins();
int reservedWidth = margins.left() + margins.right();
if (m_isEditable && m_lineEdit->isVisible()) {
reservedWidth += m_lineEdit->minimumWidth();
}
reservedWidth += m_moreBtn->width() > 0 ? m_moreBtn->width()
: m_moreBtn->sizeHint().width();
reservedWidth += m_mainLayout->spacing();
if (m_isEditable && m_lineEdit->isVisible()) {
reservedWidth += m_mainLayout->spacing();
}
if (m_clearBtn->isVisible()) {
reservedWidth += m_clearBtn->width() > 0 ? m_clearBtn->width()
: m_clearBtn->sizeHint().width();
reservedWidth += m_mainLayout->spacing();
}
return qMax(0, contentsRect().width() - reservedWidth);
}
QPushButton *ModernTagInput::createChipButton(const QString &text,
const QString &objectName) {
auto *chip = new QPushButton(text, this);
chip->setObjectName(objectName);
chip->setCursor(Qt::PointingHandCursor);
chip->setFixedHeight(22);
chip->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
chip->ensurePolished();
chip->setFixedWidth(chip->sizeHint().width());
return chip;
}

View File

@@ -0,0 +1,102 @@
#ifndef MODERNTAGINPUT_H
#define MODERNTAGINPUT_H
#include <QFrame>
#include <QSize>
#include <QStringList>
class QHBoxLayout;
class QLineEdit;
class QMenu;
class QPushButton;
class QResizeEvent;
class QShowEvent;
class QToolButton;
class QWidget;
class ModernTagInput : public QFrame {
Q_OBJECT
public:
enum PopupMode {
Auto,
ForceCompact,
ForcePopup
};
explicit ModernTagInput(QWidget *parent = nullptr);
void addPreset(const QString &label, const QString &value);
void clearPresets();
void setValue(const QString &commaSeparated);
QString value() const;
void setEditable(bool editable);
void setMaxPopupHeight(int height);
void setPopupMode(PopupMode mode);
void setPopupThreshold(int threshold);
bool isEditable() const;
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
signals:
void valueChanged(const QString &newValue);
protected:
void resizeEvent(QResizeEvent *event) override;
void showEvent(QShowEvent *event) override;
private:
void rebuildTags();
void scheduleRebuild(bool shouldEmitChange = false);
void addCustomTag(const QString &text);
void showPresetMenu();
void showPresetCompactMenu();
void showPresetPopup();
void clearAll();
void emitChange();
void updateClearButtonVisibility();
QString displayTextForValue(const QString &value) const;
QStringList customSelectedValues() const;
QString selectionToolTip() const;
int availableChipWidth() const;
QPushButton *createChipButton(const QString &text,
const QString &objectName = "TagChipInline");
struct PresetItem {
QString label;
QString value;
};
QHBoxLayout *m_mainLayout = nullptr;
QHBoxLayout *m_tagArea;
QLineEdit *m_lineEdit;
QWidget *m_buttonSpacer = nullptr;
QToolButton *m_clearBtn;
QToolButton *m_moreBtn;
QList<PresetItem> m_presets;
QStringList m_selectedValues;
bool m_isEditable = true;
bool m_isRebuilding = false;
bool m_isRebuildQueued = false;
bool m_emitChangeQueued = false;
int m_maxPopupHeight = 320;
int m_popupThreshold = 10;
PopupMode m_popupMode = Auto;
};
#endif

View File

@@ -0,0 +1,348 @@
#include "moderntoast.h"
#include "../utils/textwraputils.h"
#include <QApplication>
#include <QGraphicsDropShadowEffect>
#include <QGuiApplication>
#include <QScreen>
#include <QPainter>
#include <QStyleOption>
#include <QEasingCurve>
namespace {
constexpr int kToastFontPixelSize = 14;
constexpr int kToastHorizontalPadding = 18;
constexpr int kToastVerticalPadding = 10;
constexpr int kToastBottomMargin = 120;
constexpr int kToastMinimumWidth = 64;
constexpr int kToastMinimumTextWidth = 120;
constexpr int kToastAbsoluteMaxWidth = 560;
constexpr qreal kToastRelativeMaxWidth = 0.58;
}
QPointer<ModernToast> ModernToast::s_instance = nullptr;
QWidget* ModernToast::getMainWindow() {
QWidget *bestWin = nullptr;
int maxArea = 0;
for (QWidget *w : QApplication::topLevelWidgets()) {
if (w->isVisible() && w->windowType() == Qt::Window) {
int area = w->width() * w->height();
if (area > maxArea) {
maxArea = area;
bestWin = w;
}
}
}
return bestWin;
}
void ModernToast::showMessage(const QString &msg, int durationMs) {
if (!s_instance) {
s_instance = new ModernToast(getMainWindow());
}
s_instance->showWithAnimation(msg, durationMs);
}
ModernToast::ModernToast(QWidget *parent)
: QWidget(parent), m_textScale(1.0), m_comboCount(0), m_isCrtFadingOut(false)
{
setWindowFlags(Qt::ToolTip | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus | Qt::WindowTransparentForInput);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
setMinimumSize(0, 0);
auto *shadow = new QGraphicsDropShadowEffect(this);
shadow->setBlurRadius(20);
shadow->setColor(QColor(0, 0, 0, 160));
shadow->setOffset(0, 4);
setGraphicsEffect(shadow);
setWindowOpacity(0.0);
m_showOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_showOpacity->setDuration(200);
m_showOpacity->setEasingCurve(QEasingCurve::OutQuad);
m_showSlide = new QPropertyAnimation(this, "geometry", this);
m_showSlide->setDuration(350);
QEasingCurve slideCurve(QEasingCurve::OutBack);
slideCurve.setOvershoot(1.2);
m_showSlide->setEasingCurve(slideCurve);
m_showGroup = new QParallelAnimationGroup(this);
m_showGroup->addAnimation(m_showOpacity);
m_showGroup->addAnimation(m_showSlide);
m_crtSquashY = new QPropertyAnimation(this, "geometry", this);
m_crtSquashY->setDuration(160);
m_crtSquashY->setEasingCurve(QEasingCurve::InCubic);
m_crtSquashX = new QPropertyAnimation(this, "geometry", this);
m_crtSquashX->setDuration(120);
m_crtSquashX->setEasingCurve(QEasingCurve::InExpo);
m_hideOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_hideOpacity->setDuration(120);
m_hideOpacity->setEasingCurve(QEasingCurve::InCubic);
m_crtPhase2Group = new QParallelAnimationGroup(this);
m_crtPhase2Group->addAnimation(m_crtSquashX);
m_crtPhase2Group->addAnimation(m_hideOpacity);
m_hideGroup = new QSequentialAnimationGroup(this);
m_hideGroup->addAnimation(m_crtSquashY);
m_hideGroup->addAnimation(m_crtPhase2Group);
connect(m_hideGroup, &QSequentialAnimationGroup::finished, this, [this]() {
hide();
m_comboCount = 0;
m_isCrtFadingOut = false;
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
});
m_stayTimer = new QTimer(this);
m_stayTimer->setSingleShot(true);
connect(m_stayTimer, &QTimer::timeout, this, [this]() {
m_isCrtFadingOut = true;
update();
if (graphicsEffect()) {
graphicsEffect()->setEnabled(false);
}
QRect startGeo = geometry();
QRect lineGeo(startGeo.x(), startGeo.y() + startGeo.height() / 2 - 1, startGeo.width(), 2);
QRect dotGeo(startGeo.x() + startGeo.width() / 2 - 1, startGeo.y() + startGeo.height() / 2 - 1, 2, 2);
m_crtSquashY->setStartValue(startGeo);
m_crtSquashY->setEndValue(lineGeo);
m_crtSquashX->setStartValue(lineGeo);
m_crtSquashX->setEndValue(dotGeo);
m_hideOpacity->setStartValue(windowOpacity());
m_hideOpacity->setEndValue(0.0);
m_hideGroup->start();
});
QEasingCurve expandCurve(QEasingCurve::OutBack);
expandCurve.setOvershoot(2.5);
m_shellExpand = new QPropertyAnimation(this, "geometry", this);
m_shellExpand->setDuration(160);
m_shellExpand->setEasingCurve(expandCurve);
m_textExpand = new QPropertyAnimation(this, "textScale", this);
m_textExpand->setDuration(160);
m_textExpand->setEasingCurve(expandCurve);
m_expandGroup = new QParallelAnimationGroup(this);
m_expandGroup->addAnimation(m_shellExpand);
m_expandGroup->addAnimation(m_textExpand);
QEasingCurve shrinkCurve(QEasingCurve::OutCubic);
m_shellShrink = new QPropertyAnimation(this, "geometry", this);
m_shellShrink->setDuration(220);
m_shellShrink->setEasingCurve(shrinkCurve);
m_textShrink = new QPropertyAnimation(this, "textScale", this);
m_textShrink->setDuration(220);
m_textShrink->setEasingCurve(shrinkCurve);
m_shrinkGroup = new QParallelAnimationGroup(this);
m_shrinkGroup->addAnimation(m_shellShrink);
m_shrinkGroup->addAnimation(m_textShrink);
m_popSequentialGroup = new QSequentialAnimationGroup(this);
m_popSequentialGroup->addAnimation(m_expandGroup);
m_popSequentialGroup->addAnimation(m_shrinkGroup);
}
ModernToast::~ModernToast() {}
void ModernToast::setTextScale(qreal scale) {
m_textScale = scale;
update();
}
QRect ModernToast::resolveAnchorRect() const
{
QWidget *mainWin = parentWidget();
if (!mainWin) {
mainWin = getMainWindow();
}
if (mainWin) {
return mainWin->geometry();
}
if (QScreen *screen = QGuiApplication::primaryScreen()) {
return screen->geometry();
}
return QRect(0, 0, 1280, 720);
}
QRect ModernToast::textRect() const
{
return rect().adjusted(kToastHorizontalPadding, kToastVerticalPadding,
-kToastHorizontalPadding, -kToastVerticalPadding);
}
QSize ModernToast::wrappedTextSize(const QFont& font, int maxTextWidth) const
{
return TextWrapUtils::measureWrappedPlainText(m_message, font,
maxTextWidth);
}
void ModernToast::paintEvent(QPaintEvent *event) {
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
QStyleOption opt;
opt.initFrom(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);
if (m_isCrtFadingOut) {
return;
}
painter.setFont(this->font());
QColor textColor = opt.palette.color(QPalette::WindowText);
painter.setPen(textColor);
QPointF center = rect().center();
painter.translate(center);
painter.scale(m_textScale, m_textScale);
painter.translate(-center);
painter.drawText(textRect(),
Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextDontClip,
m_wrappedMessage);
}
void ModernToast::showWithAnimation(const QString &msg, int durationMs) {
m_message = msg;
QFont currentFont = this->font();
currentFont.setPixelSize(kToastFontPixelSize);
this->setFont(currentFont);
const QRect anchorRect = resolveAnchorRect();
const int availableWidth = qMax(kToastMinimumWidth,
anchorRect.width() - 48);
const int preferredMaxWidth =
qRound(anchorRect.width() * kToastRelativeMaxWidth);
const int maxToastWidth =
qMin(availableWidth,
qMax(220, qMin(preferredMaxWidth, kToastAbsoluteMaxWidth)));
const int maxTextWidth =
qMax(kToastMinimumTextWidth,
maxToastWidth - kToastHorizontalPadding * 2);
m_wrappedMessage =
TextWrapUtils::wrapPlainText(msg, currentFont, maxTextWidth);
const QSize wrappedSize = wrappedTextSize(currentFont, maxTextWidth);
const int targetW = qMax(
kToastMinimumWidth,
qMin(maxToastWidth, wrappedSize.width() + kToastHorizontalPadding * 2));
const int targetH =
qMax(currentFont.pixelSize() + kToastVerticalPadding * 2,
wrappedSize.height() + kToastVerticalPadding * 2);
const int px = anchorRect.x() + (anchorRect.width() - targetW) / 2;
const int py =
anchorRect.y() + anchorRect.height() - targetH - kToastBottomMargin;
m_baseGeometry = QRect(px, py, targetW, targetH);
show();
bool isFullyHidden = (windowOpacity() == 0.0 && m_showGroup->state() != QAbstractAnimation::Running);
bool isFadingOut = (m_hideGroup->state() == QAbstractAnimation::Running);
if (isFullyHidden || isFadingOut) {
m_comboCount = 0;
m_hideGroup->stop();
m_isCrtFadingOut = false;
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
m_popSequentialGroup->stop();
setTextScale(1.0);
QRect slideStartGeo = m_baseGeometry.translated(0, 20);
setGeometry(slideStartGeo);
m_showSlide->setStartValue(slideStartGeo);
m_showSlide->setEndValue(m_baseGeometry);
m_showOpacity->setStartValue(windowOpacity());
m_showOpacity->setEndValue(1.0);
m_showGroup->start();
} else {
m_comboCount++;
qreal dynamicScale = 1.15 + qMin(m_comboCount * 0.05, 0.3);
int dx = qRound(targetW * (dynamicScale - 1.0) / 2.0) + 2;
int dy = qRound(targetH * (dynamicScale - 1.0) / 2.0) + 2;
m_showGroup->stop();
setWindowOpacity(1.0);
m_popSequentialGroup->stop();
setTextScale(1.0);
setGeometry(m_baseGeometry);
QRect expandedRect = m_baseGeometry.adjusted(-dx, -dy, dx, dy);
m_shellExpand->setStartValue(m_baseGeometry);
m_shellExpand->setEndValue(expandedRect);
m_textExpand->setStartValue(m_textScale);
m_textExpand->setEndValue(dynamicScale);
m_shellShrink->setStartValue(expandedRect);
m_shellShrink->setEndValue(m_baseGeometry);
m_textShrink->setStartValue(dynamicScale);
m_textShrink->setEndValue(1.0);
m_popSequentialGroup->start();
}
m_stayTimer->start(durationMs);
}

Some files were not shown because too many files have changed in this diff Show More