chore: baseline usable version
This commit is contained in:
183
src/qEmbyApp/CMakeLists.txt
Normal file
183
src/qEmbyApp/CMakeLists.txt
Normal 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()
|
||||
709
src/qEmbyApp/components/addtoplaylistdialog.cpp
Normal file
709
src/qEmbyApp/components/addtoplaylistdialog.cpp
Normal 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();
|
||||
}
|
||||
80
src/qEmbyApp/components/addtoplaylistdialog.h
Normal file
80
src/qEmbyApp/components/addtoplaylistdialog.h
Normal 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
|
||||
558
src/qEmbyApp/components/adduserdialog.cpp
Normal file
558
src/qEmbyApp/components/adduserdialog.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
68
src/qEmbyApp/components/adduserdialog.h
Normal file
68
src/qEmbyApp/components/adduserdialog.h
Normal 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
|
||||
202
src/qEmbyApp/components/adminusercard.cpp
Normal file
202
src/qEmbyApp/components/adminusercard.cpp
Normal 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);
|
||||
}
|
||||
32
src/qEmbyApp/components/adminusercard.h
Normal file
32
src/qEmbyApp/components/adminusercard.h
Normal 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
|
||||
40
src/qEmbyApp/components/cardcontextmenurequest.h
Normal file
40
src/qEmbyApp/components/cardcontextmenurequest.h
Normal 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
|
||||
113
src/qEmbyApp/components/collapsiblesection.cpp
Normal file
113
src/qEmbyApp/components/collapsiblesection.cpp
Normal 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();
|
||||
}
|
||||
29
src/qEmbyApp/components/collapsiblesection.h
Normal file
29
src/qEmbyApp/components/collapsiblesection.h
Normal 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
|
||||
306
src/qEmbyApp/components/collectioncard.cpp
Normal file
306
src/qEmbyApp/components/collectioncard.cpp
Normal 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);
|
||||
}
|
||||
66
src/qEmbyApp/components/collectioncard.h
Normal file
66
src/qEmbyApp/components/collectioncard.h
Normal 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
|
||||
335
src/qEmbyApp/components/collectiongrid.cpp
Normal file
335
src/qEmbyApp/components/collectiongrid.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
67
src/qEmbyApp/components/collectiongrid.h
Normal file
67
src/qEmbyApp/components/collectiongrid.h
Normal 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
|
||||
462
src/qEmbyApp/components/danmakuoptionslider.cpp
Normal file
462
src/qEmbyApp/components/danmakuoptionslider.cpp
Normal 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);
|
||||
}
|
||||
84
src/qEmbyApp/components/danmakuoptionslider.h
Normal file
84
src/qEmbyApp/components/danmakuoptionslider.h
Normal 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
|
||||
887
src/qEmbyApp/components/danmakuserverdialog.cpp
Normal file
887
src/qEmbyApp/components/danmakuserverdialog.cpp
Normal 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;
|
||||
}
|
||||
76
src/qEmbyApp/components/danmakuserverdialog.h
Normal file
76
src/qEmbyApp/components/danmakuserverdialog.h
Normal 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
|
||||
230
src/qEmbyApp/components/danmakuserverlistitemwidget.cpp
Normal file
230
src/qEmbyApp/components/danmakuserverlistitemwidget.cpp
Normal 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();
|
||||
}
|
||||
49
src/qEmbyApp/components/danmakuserverlistitemwidget.h
Normal file
49
src/qEmbyApp/components/danmakuserverlistitemwidget.h
Normal 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
|
||||
560
src/qEmbyApp/components/dashboardsectionorderstrip.cpp
Normal file
560
src/qEmbyApp/components/dashboardsectionorderstrip.cpp
Normal 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();
|
||||
}
|
||||
78
src/qEmbyApp/components/dashboardsectionorderstrip.h
Normal file
78
src/qEmbyApp/components/dashboardsectionorderstrip.h
Normal 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
|
||||
97
src/qEmbyApp/components/dashboardsectionorderwidget.cpp
Normal file
97
src/qEmbyApp/components/dashboardsectionorderwidget.cpp
Normal 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);
|
||||
}
|
||||
30
src/qEmbyApp/components/dashboardsectionorderwidget.h
Normal file
30
src/qEmbyApp/components/dashboardsectionorderwidget.h
Normal 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
|
||||
450
src/qEmbyApp/components/detailactionwidget.cpp
Normal file
450
src/qEmbyApp/components/detailactionwidget.cpp
Normal 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);
|
||||
}
|
||||
63
src/qEmbyApp/components/detailactionwidget.h
Normal file
63
src/qEmbyApp/components/detailactionwidget.h
Normal 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
|
||||
494
src/qEmbyApp/components/detailbottominfowidget.cpp
Normal file
494
src/qEmbyApp/components/detailbottominfowidget.cpp
Normal 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();
|
||||
}
|
||||
67
src/qEmbyApp/components/detailbottominfowidget.h
Normal file
67
src/qEmbyApp/components/detailbottominfowidget.h
Normal 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
|
||||
91
src/qEmbyApp/components/detailcontentwidget.cpp
Normal file
91
src/qEmbyApp/components/detailcontentwidget.cpp
Normal 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);
|
||||
}
|
||||
39
src/qEmbyApp/components/detailcontentwidget.h
Normal file
39
src/qEmbyApp/components/detailcontentwidget.h
Normal 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
|
||||
27
src/qEmbyApp/components/detailtagbutton.cpp
Normal file
27
src/qEmbyApp/components/detailtagbutton.cpp
Normal 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;
|
||||
}
|
||||
19
src/qEmbyApp/components/detailtagbutton.h
Normal file
19
src/qEmbyApp/components/detailtagbutton.h
Normal 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
|
||||
932
src/qEmbyApp/components/downloadmanagerdialog.cpp
Normal file
932
src/qEmbyApp/components/downloadmanagerdialog.cpp
Normal 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);
|
||||
}
|
||||
79
src/qEmbyApp/components/downloadmanagerdialog.h
Normal file
79
src/qEmbyApp/components/downloadmanagerdialog.h
Normal 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
|
||||
122
src/qEmbyApp/components/editablevaluelabel.cpp
Normal file
122
src/qEmbyApp/components/editablevaluelabel.cpp
Normal 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());
|
||||
}
|
||||
}
|
||||
41
src/qEmbyApp/components/editablevaluelabel.h
Normal file
41
src/qEmbyApp/components/editablevaluelabel.h
Normal 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
|
||||
49
src/qEmbyApp/components/elidedlabel.cpp
Normal file
49
src/qEmbyApp/components/elidedlabel.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/qEmbyApp/components/elidedlabel.h
Normal file
30
src/qEmbyApp/components/elidedlabel.h
Normal 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
|
||||
300
src/qEmbyApp/components/embycodecinfodialog.cpp
Normal file
300
src/qEmbyApp/components/embycodecinfodialog.cpp
Normal 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(", "));
|
||||
}
|
||||
30
src/qEmbyApp/components/embycodecinfodialog.h
Normal file
30
src/qEmbyApp/components/embycodecinfodialog.h
Normal 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
|
||||
797
src/qEmbyApp/components/embycodecparametersdialog.cpp
Normal file
797
src/qEmbyApp/components/embycodecparametersdialog.cpp
Normal 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();
|
||||
}
|
||||
89
src/qEmbyApp/components/embycodecparametersdialog.h
Normal file
89
src/qEmbyApp/components/embycodecparametersdialog.h
Normal 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
|
||||
54
src/qEmbyApp/components/fetcherrowwidget.cpp
Normal file
54
src/qEmbyApp/components/fetcherrowwidget.cpp
Normal 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); }
|
||||
36
src/qEmbyApp/components/fetcherrowwidget.h
Normal file
36
src/qEmbyApp/components/fetcherrowwidget.h
Normal 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
|
||||
225
src/qEmbyApp/components/flowlayout.cpp
Normal file
225
src/qEmbyApp/components/flowlayout.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/qEmbyApp/components/flowlayout.h
Normal file
43
src/qEmbyApp/components/flowlayout.h
Normal 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
|
||||
27
src/qEmbyApp/components/galleryoverlayfilter.cpp
Normal file
27
src/qEmbyApp/components/galleryoverlayfilter.cpp
Normal 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);
|
||||
}
|
||||
19
src/qEmbyApp/components/galleryoverlayfilter.h
Normal file
19
src/qEmbyApp/components/galleryoverlayfilter.h
Normal 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;
|
||||
};
|
||||
515
src/qEmbyApp/components/horizontallistviewgallery.cpp
Normal file
515
src/qEmbyApp/components/horizontallistviewgallery.cpp
Normal 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);
|
||||
}
|
||||
97
src/qEmbyApp/components/horizontallistviewgallery.h
Normal file
97
src/qEmbyApp/components/horizontallistviewgallery.h
Normal 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
|
||||
255
src/qEmbyApp/components/horizontalwidgetgallery.cpp
Normal file
255
src/qEmbyApp/components/horizontalwidgetgallery.cpp
Normal 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());
|
||||
}
|
||||
57
src/qEmbyApp/components/horizontalwidgetgallery.h
Normal file
57
src/qEmbyApp/components/horizontalwidgetgallery.h
Normal 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
|
||||
88
src/qEmbyApp/components/libraryactionmenu.cpp
Normal file
88
src/qEmbyApp/components/libraryactionmenu.cpp
Normal 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);
|
||||
}
|
||||
31
src/qEmbyApp/components/libraryactionmenu.h
Normal file
31
src/qEmbyApp/components/libraryactionmenu.h
Normal 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
|
||||
26
src/qEmbyApp/components/librarycontextmenurequest.h
Normal file
26
src/qEmbyApp/components/librarycontextmenurequest.h
Normal 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
|
||||
3148
src/qEmbyApp/components/librarycreatedlg.cpp
Normal file
3148
src/qEmbyApp/components/librarycreatedlg.cpp
Normal file
File diff suppressed because it is too large
Load Diff
273
src/qEmbyApp/components/librarycreatedlg.h
Normal file
273
src/qEmbyApp/components/librarycreatedlg.h
Normal 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
|
||||
286
src/qEmbyApp/components/librarycreatedlg_p.cpp
Normal file
286
src/qEmbyApp/components/librarycreatedlg_p.cpp
Normal 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;
|
||||
}
|
||||
47
src/qEmbyApp/components/librarycreatedlg_p.h
Normal file
47
src/qEmbyApp/components/librarycreatedlg_p.h
Normal 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
|
||||
61
src/qEmbyApp/components/libraryeditdlg.cpp
Normal file
61
src/qEmbyApp/components/libraryeditdlg.cpp
Normal 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;
|
||||
}
|
||||
29
src/qEmbyApp/components/libraryeditdlg.h
Normal file
29
src/qEmbyApp/components/libraryeditdlg.h
Normal 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
|
||||
1289
src/qEmbyApp/components/librarymetadataeditdialog.cpp
Normal file
1289
src/qEmbyApp/components/librarymetadataeditdialog.cpp
Normal file
File diff suppressed because it is too large
Load Diff
102
src/qEmbyApp/components/librarymetadataeditdialog.h
Normal file
102
src/qEmbyApp/components/librarymetadataeditdialog.h
Normal 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
|
||||
48
src/qEmbyApp/components/licensesdialog.cpp
Normal file
48
src/qEmbyApp/components/licensesdialog.cpp
Normal 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);
|
||||
}
|
||||
18
src/qEmbyApp/components/licensesdialog.h
Normal file
18
src/qEmbyApp/components/licensesdialog.h
Normal 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
|
||||
185
src/qEmbyApp/components/loadingoverlay.cpp
Normal file
185
src/qEmbyApp/components/loadingoverlay.cpp
Normal 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();
|
||||
}
|
||||
102
src/qEmbyApp/components/loadingoverlay.h
Normal file
102
src/qEmbyApp/components/loadingoverlay.h
Normal 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
|
||||
272
src/qEmbyApp/components/mediaactionmenu.cpp
Normal file
272
src/qEmbyApp/components/mediaactionmenu.cpp
Normal 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"));
|
||||
}
|
||||
}
|
||||
38
src/qEmbyApp/components/mediaactionmenu.h
Normal file
38
src/qEmbyApp/components/mediaactionmenu.h
Normal 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
|
||||
402
src/qEmbyApp/components/mediagridwidget.cpp
Normal file
402
src/qEmbyApp/components/mediagridwidget.cpp
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
76
src/qEmbyApp/components/mediagridwidget.h
Normal file
76
src/qEmbyApp/components/mediagridwidget.h
Normal 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
|
||||
659
src/qEmbyApp/components/mediaidentifydialog.cpp
Normal file
659
src/qEmbyApp/components/mediaidentifydialog.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
88
src/qEmbyApp/components/mediaidentifydialog.h
Normal file
88
src/qEmbyApp/components/mediaidentifydialog.h
Normal 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
|
||||
1525
src/qEmbyApp/components/mediaimageeditdialog.cpp
Normal file
1525
src/qEmbyApp/components/mediaimageeditdialog.cpp
Normal file
File diff suppressed because it is too large
Load Diff
132
src/qEmbyApp/components/mediaimageeditdialog.h
Normal file
132
src/qEmbyApp/components/mediaimageeditdialog.h
Normal 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
|
||||
126
src/qEmbyApp/components/mediasectionwidget.cpp
Normal file
126
src/qEmbyApp/components/mediasectionwidget.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/qEmbyApp/components/mediasectionwidget.h
Normal file
59
src/qEmbyApp/components/mediasectionwidget.h
Normal 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
|
||||
372
src/qEmbyApp/components/moderncombobox.cpp
Normal file
372
src/qEmbyApp/components/moderncombobox.cpp
Normal 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);
|
||||
}
|
||||
56
src/qEmbyApp/components/moderncombobox.h
Normal file
56
src/qEmbyApp/components/moderncombobox.h
Normal 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
|
||||
122
src/qEmbyApp/components/moderndialogbase.cpp
Normal file
122
src/qEmbyApp/components/moderndialogbase.cpp
Normal 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);
|
||||
}
|
||||
28
src/qEmbyApp/components/moderndialogbase.h
Normal file
28
src/qEmbyApp/components/moderndialogbase.h
Normal 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
|
||||
198
src/qEmbyApp/components/modernmenubutton.cpp
Normal file
198
src/qEmbyApp/components/modernmenubutton.cpp
Normal 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("");
|
||||
}
|
||||
}
|
||||
38
src/qEmbyApp/components/modernmenubutton.h
Normal file
38
src/qEmbyApp/components/modernmenubutton.h
Normal 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
|
||||
116
src/qEmbyApp/components/modernmenuscrollbar.cpp
Normal file
116
src/qEmbyApp/components/modernmenuscrollbar.cpp
Normal 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);
|
||||
}
|
||||
45
src/qEmbyApp/components/modernmenuscrollbar.h
Normal file
45
src/qEmbyApp/components/modernmenuscrollbar.h
Normal 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
|
||||
216
src/qEmbyApp/components/modernmessagebox.cpp
Normal file
216
src/qEmbyApp/components/modernmessagebox.cpp
Normal 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();
|
||||
}
|
||||
74
src/qEmbyApp/components/modernmessagebox.h
Normal file
74
src/qEmbyApp/components/modernmessagebox.h
Normal 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
|
||||
14
src/qEmbyApp/components/modernnumberinput.cpp
Normal file
14
src/qEmbyApp/components/modernnumberinput.cpp
Normal 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);
|
||||
}
|
||||
14
src/qEmbyApp/components/modernnumberinput.h
Normal file
14
src/qEmbyApp/components/modernnumberinput.h
Normal 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
|
||||
248
src/qEmbyApp/components/modernscrollpanel.cpp
Normal file
248
src/qEmbyApp/components/modernscrollpanel.cpp
Normal 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);
|
||||
}
|
||||
49
src/qEmbyApp/components/modernscrollpanel.h
Normal file
49
src/qEmbyApp/components/modernscrollpanel.h
Normal 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
|
||||
142
src/qEmbyApp/components/modernslider.cpp
Normal file
142
src/qEmbyApp/components/modernslider.cpp
Normal 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);
|
||||
}
|
||||
88
src/qEmbyApp/components/modernslider.h
Normal file
88
src/qEmbyApp/components/modernslider.h
Normal 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
|
||||
119
src/qEmbyApp/components/modernsortbutton.cpp
Normal file
119
src/qEmbyApp/components/modernsortbutton.cpp
Normal 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 ? " ↓" : " ↑"));
|
||||
}
|
||||
}
|
||||
53
src/qEmbyApp/components/modernsortbutton.h
Normal file
53
src/qEmbyApp/components/modernsortbutton.h
Normal 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
|
||||
116
src/qEmbyApp/components/modernswitch.cpp
Normal file
116
src/qEmbyApp/components/modernswitch.cpp
Normal 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();
|
||||
}
|
||||
59
src/qEmbyApp/components/modernswitch.h
Normal file
59
src/qEmbyApp/components/modernswitch.h
Normal 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
|
||||
547
src/qEmbyApp/components/moderntaginput.cpp
Normal file
547
src/qEmbyApp/components/moderntaginput.cpp
Normal 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;
|
||||
}
|
||||
102
src/qEmbyApp/components/moderntaginput.h
Normal file
102
src/qEmbyApp/components/moderntaginput.h
Normal 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
|
||||
348
src/qEmbyApp/components/moderntoast.cpp
Normal file
348
src/qEmbyApp/components/moderntoast.cpp
Normal 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
Reference in New Issue
Block a user