From 56f5ef2611debfbd86b419f2dc7e51efd59ab31b Mon Sep 17 00:00:00 2001 From: James Graham Date: Sat, 2 Sep 2023 16:43:05 +0000 Subject: [PATCH] MediaSizeHelper Create a media size helper and use it to force video and images to be the correct size even in replies. --- autotests/CMakeLists.txt | 6 + autotests/mediasizehelpertest.cpp | 72 ++++++++ src/CMakeLists.txt | 2 + src/main.cpp | 2 + src/mediasizehelper.cpp | 163 ++++++++++++++++++ src/mediasizehelper.h | 103 +++++++++++ src/neochatconfig.kcfg | 8 + src/qml/Component/Timeline/ImageDelegate.qml | 51 +----- src/qml/Component/Timeline/ReplyComponent.qml | 30 ++-- .../Component/Timeline/TimelineContainer.qml | 1 + src/qml/Component/Timeline/VideoDelegate.qml | 56 +----- 11 files changed, 386 insertions(+), 108 deletions(-) create mode 100644 autotests/mediasizehelpertest.cpp create mode 100644 src/mediasizehelper.cpp create mode 100644 src/mediasizehelper.h diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index c32990d90..76c5582d5 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -20,3 +20,9 @@ ecm_add_test( LINK_LIBRARIES neochat Qt::Test TEST_NAME delegatesizehelpertest ) + +ecm_add_test( + mediasizehelpertest.cpp + LINK_LIBRARIES neochat Qt::Test + TEST_NAME mediasizehelpertest +) diff --git a/autotests/mediasizehelpertest.cpp b/autotests/mediasizehelpertest.cpp new file mode 100644 index 000000000..0f662d832 --- /dev/null +++ b/autotests/mediasizehelpertest.cpp @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include +#include +#include +#include + +#include + +#include "mediasizehelper.h" +#include "neochatconfig.h" + +class MediaSizeHelperTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void uninitialized(); + + void limits_data(); + void limits(); +}; + +void MediaSizeHelperTest::uninitialized() +{ + MediaSizeHelper mediasizehelper; + QCOMPARE(mediasizehelper.currentSize(), QSize(540, qRound(qreal(NeoChatConfig::self()->mediaMaxWidth()) / qreal(16.0) * qreal(9.0)))); +} + +void MediaSizeHelperTest::limits_data() +{ + QTest::addColumn("mediaWidth"); + QTest::addColumn("mediaHeight"); + QTest::addColumn("contentMaxWidth"); + QTest::addColumn("contentMaxHeight"); + QTest::addColumn("currentSize"); + + QTest::newRow("media smaller than content limits") << qreal(200) << qreal(150) << qreal(400) << qreal(900) << QSize(200, 150); + QTest::newRow("media smaller than max limits") << qreal(200) << qreal(150) << qreal(-1) << qreal(-1) << QSize(200, 150); + + QTest::newRow("limit by max width") << qreal(600) << qreal(50) << qreal(-1) << qreal(-1) << QSize(540, qRound(qreal(540) / (qreal(600) / qreal(50)))); + QTest::newRow("limit by max height") << qreal(50) << qreal(600) << qreal(-1) << qreal(-1) << QSize(qRound(qreal(540) * (qreal(50) / qreal(600))), 540); + + QTest::newRow("limit by content width") << qreal(600) << qreal(50) << qreal(300) << qreal(-1) << QSize(300, qRound(qreal(300) / (qreal(600) / qreal(50)))); + QTest::newRow("limit by content height") << qreal(50) << qreal(600) << qreal(-1) << qreal(300) << QSize(qRound(qreal(300) * (qreal(50) / qreal(600))), 300); + + QTest::newRow("limit by content width tall media") + << qreal(400) << qreal(600) << qreal(100) << qreal(400) << QSize(100, qRound(qreal(100) / (qreal(400) / qreal(600)))); + QTest::newRow("limit by content height wide media") + << qreal(1000) << qreal(600) << qreal(400) << qreal(100) << QSize(qRound(qreal(100) * (qreal(1000) / qreal(600))), 100); +} + +void MediaSizeHelperTest::limits() +{ + QFETCH(qreal, mediaWidth); + QFETCH(qreal, mediaHeight); + QFETCH(qreal, contentMaxWidth); + QFETCH(qreal, contentMaxHeight); + QFETCH(QSize, currentSize); + + MediaSizeHelper mediasizehelper; + mediasizehelper.setMediaWidth(mediaWidth); + mediasizehelper.setMediaHeight(mediaHeight); + mediasizehelper.setContentMaxWidth(contentMaxWidth); + mediasizehelper.setContentMaxHeight(contentMaxHeight); + + QCOMPARE(mediasizehelper.currentSize(), currentSize); +} + +QTEST_GUILESS_MAIN(MediaSizeHelperTest) +#include "mediasizehelpertest.moc" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d207725df..76259f187 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -132,6 +132,8 @@ add_library(neochat STATIC jobs/neochatdeletedevicejob.h jobs/neochatchangepasswordjob.cpp jobs/neochatchangepasswordjob.h + mediasizehelper.cpp + mediasizehelper.h ) ecm_qt_declare_logging_category(neochat diff --git a/src/main.cpp b/src/main.cpp index 6e6caaf7f..d78058262 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -53,6 +53,7 @@ #include "logger.h" #include "login.h" #include "matriximageprovider.h" +#include "mediasizehelper.h" #include "models/accountemoticonmodel.h" #include "models/customemojimodel.h" #include "models/devicesmodel.h" @@ -278,6 +279,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "AccountEmoticonModel"); qmlRegisterType("org.kde.neochat", 1, 0, "EmoticonFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "DelegateSizeHelper"); + qmlRegisterType("org.kde.neochat", 1, 0, "MediaSizeHelper"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationKind", "ENUM"_ls); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationSection", "ENUM"_ls); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"_ls); diff --git a/src/mediasizehelper.cpp b/src/mediasizehelper.cpp new file mode 100644 index 000000000..225f82976 --- /dev/null +++ b/src/mediasizehelper.cpp @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "mediasizehelper.h" + +#include "neochatconfig.h" + +MediaSizeHelper::MediaSizeHelper(QObject *parent) + : QObject(parent) +{ +} + +qreal MediaSizeHelper::contentMaxWidth() const +{ + return m_contentMaxWidth; +} + +void MediaSizeHelper::setContentMaxWidth(qreal contentMaxWidth) +{ + if (contentMaxWidth < 0.0 || qFuzzyCompare(contentMaxWidth, 0.0)) { + m_contentMaxWidth = -1.0; + Q_EMIT contentMaxWidthChanged(); + Q_EMIT currentSizeChanged(); + return; + } + if (qFuzzyCompare(contentMaxWidth, m_contentMaxWidth)) { + return; + } + m_contentMaxWidth = contentMaxWidth; + Q_EMIT contentMaxWidthChanged(); + Q_EMIT currentSizeChanged(); +} + +qreal MediaSizeHelper::contentMaxHeight() const +{ + return m_contentMaxHeight; +} + +void MediaSizeHelper::setContentMaxHeight(qreal contentMaxHeight) +{ + if (contentMaxHeight < 0.0 || qFuzzyCompare(contentMaxHeight, 0.0)) { + m_contentMaxHeight = -1.0; + Q_EMIT contentMaxHeightChanged(); + Q_EMIT currentSizeChanged(); + return; + } + if (qFuzzyCompare(contentMaxHeight, m_contentMaxHeight)) { + return; + } + m_contentMaxHeight = contentMaxHeight; + Q_EMIT contentMaxHeightChanged(); + Q_EMIT currentSizeChanged(); +} + +qreal MediaSizeHelper::mediaWidth() const +{ + return m_mediaWidth; +} + +void MediaSizeHelper::setMediaWidth(qreal mediaWidth) +{ + if (mediaWidth < 0.0 || qFuzzyCompare(mediaWidth, 0.0)) { + m_mediaWidth = -1.0; + Q_EMIT mediaWidthChanged(); + Q_EMIT currentSizeChanged(); + return; + } + if (qFuzzyCompare(mediaWidth, m_mediaWidth)) { + return; + } + m_mediaWidth = mediaWidth; + Q_EMIT mediaWidthChanged(); + Q_EMIT currentSizeChanged(); +} + +qreal MediaSizeHelper::mediaHeight() const +{ + return m_mediaHeight; +} + +void MediaSizeHelper::setMediaHeight(qreal mediaHeight) +{ + if (mediaHeight < 0.0 || qFuzzyCompare(mediaHeight, 0.0)) { + m_mediaHeight = -1.0; + Q_EMIT mediaHeightChanged(); + Q_EMIT currentSizeChanged(); + return; + } + if (qFuzzyCompare(mediaHeight, m_mediaHeight)) { + return; + } + m_mediaHeight = mediaHeight; + Q_EMIT mediaHeightChanged(); + Q_EMIT currentSizeChanged(); +} + +qreal MediaSizeHelper::resolvedMediaWidth() const +{ + if (m_mediaWidth > 0.0) { + return m_mediaWidth; + } + return widthLimit(); +} + +qreal MediaSizeHelper::resolvedMediaHeight() const +{ + if (m_mediaHeight > 0.0) { + return m_mediaHeight; + } + return widthLimit() / 16.0 * 9.0; +} + +qreal MediaSizeHelper::aspectRatio() const +{ + return resolvedMediaWidth() / resolvedMediaHeight(); +} + +bool MediaSizeHelper::limitWidth() const +{ + // If actual data isn't available we'll be using a placeholder that is width + // limited so return true. + if (m_mediaWidth < 0.0 || m_mediaHeight < 0.0) { + return true; + } + return m_mediaWidth >= m_mediaHeight; +} + +qreal MediaSizeHelper::widthLimit() const +{ + if (m_contentMaxWidth < 0.0) { + return NeoChatConfig::self()->mediaMaxWidth(); + } + return std::min(m_contentMaxWidth, qreal(NeoChatConfig::self()->mediaMaxWidth())); +} + +qreal MediaSizeHelper::heightLimit() const +{ + if (m_contentMaxHeight < 0.0) { + return NeoChatConfig::self()->mediaMaxHeight(); + } + return std::min(m_contentMaxHeight, qreal(NeoChatConfig::self()->mediaMaxHeight())); +} + +QSize MediaSizeHelper::currentSize() const +{ + if (limitWidth()) { + qreal width = std::min(widthLimit(), resolvedMediaWidth()); + qreal height = width / aspectRatio(); + if (height > heightLimit()) { + return QSize(qRound(heightLimit() * aspectRatio()), qRound(heightLimit())); + } + return QSize(qRound(width), qRound(height)); + } else { + qreal height = std::min(heightLimit(), resolvedMediaHeight()); + qreal width = height * aspectRatio(); + if (width > widthLimit()) { + return QSize(qRound(widthLimit()), qRound(widthLimit() / aspectRatio())); + } + return QSize(qRound(width), qRound(height)); + } +} + +#include "moc_mediasizehelper.cpp" diff --git a/src/mediasizehelper.h b/src/mediasizehelper.h new file mode 100644 index 000000000..f384b9377 --- /dev/null +++ b/src/mediasizehelper.h @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +/** + * @class MediaSizeHelper + * + * A class to help calculate the current width of a media item within a chat delegate. + * + * The only realistic way to guarantee that a media item (e.g. an image or video) + * is the correct size in QML is to calculate the size manually. + * + * The rules for this component work as follows: + * - The output will always try to keep the media size if no limits are breached. + * - If no media width is set, the current size will be a placeholder at a 16:9 ratio + * calcualated from either the configured max width or the contentMaxWidth, whichever + * is smaller (if the contentMaxWidth isn't set, the configured max width is used). + * - The aspect ratio of the media will always be maintained if set (otherwise 16:9). + * - The current size will never be larger than any of the limits in either direction. + * - If any limit is breached the image size will be reduced while maintaining aspect + * ration, i.e. no stretching or squashing. This can mean that the width or height + * is reduced even if that parameter doesn't breach the limit itself. + */ +class MediaSizeHelper : public QObject +{ + Q_OBJECT + + /** + * @brief The maximum width (in px) the media can be. + * + * This is the upper limit placed upon the media by the delegate. + */ + Q_PROPERTY(qreal contentMaxWidth READ contentMaxWidth WRITE setContentMaxWidth NOTIFY contentMaxWidthChanged) + + /** + * @brief The maximum height (in px) the media can be. + * + * This is the upper limit placed upon the media by the delegate. + */ + Q_PROPERTY(qreal contentMaxHeight READ contentMaxHeight WRITE setContentMaxHeight NOTIFY contentMaxHeightChanged) + + /** + * @brief The base width (in px) of the media. + */ + Q_PROPERTY(qreal mediaWidth READ mediaWidth WRITE setMediaWidth NOTIFY mediaWidthChanged) + + /** + * @brief The base height (in px) of the media. + */ + Q_PROPERTY(qreal mediaHeight READ mediaHeight WRITE setMediaHeight NOTIFY mediaHeightChanged) + + /** + * @brief The size (in px) of the component based on the current input. + * + * Will always try to return a value even if some of the inputs are not set to + * account for being called before the parameters are intialised. For any parameters + * not set these will just be left out of the calcs. + * + * If no input values are provided a default placeholder value will be returned. + */ + Q_PROPERTY(QSize currentSize READ currentSize NOTIFY currentSizeChanged) + +public: + explicit MediaSizeHelper(QObject *parent = nullptr); + + qreal contentMaxWidth() const; + void setContentMaxWidth(qreal contentMaxWidth); + + qreal contentMaxHeight() const; + void setContentMaxHeight(qreal contentMaxHeight); + + qreal mediaWidth() const; + void setMediaWidth(qreal mediaWidth); + + qreal mediaHeight() const; + void setMediaHeight(qreal mediaHeight); + + QSize currentSize() const; + +Q_SIGNALS: + void contentMaxWidthChanged(); + void contentMaxHeightChanged(); + void mediaWidthChanged(); + void mediaHeightChanged(); + void currentSizeChanged(); + +private: + qreal m_contentMaxWidth = -1.0; + qreal m_contentMaxHeight = -1.0; + qreal m_mediaWidth = -1.0; + qreal m_mediaHeight = -1.0; + + qreal resolvedMediaWidth() const; + qreal resolvedMediaHeight() const; + qreal aspectRatio() const; + bool limitWidth() const; + qreal widthLimit() const; + qreal heightLimit() const; +}; diff --git a/src/neochatconfig.kcfg b/src/neochatconfig.kcfg index c1a33da08..a52526808 100644 --- a/src/neochatconfig.kcfg +++ b/src/neochatconfig.kcfg @@ -107,6 +107,14 @@ true + + + 540 + + + + 540 + diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index b56cbaefc..310e5aad2 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -57,48 +57,8 @@ TimelineContainer { innerObject: Item { id: imageContainer - - property var imageWidth: { - if (root.mediaInfo.width > 0) { - return root.mediaInfo.width; - } else { - return root.contentMaxWidth; - } - } - property var imageHeight: { - if (root.mediaInfo.height > 0) { - return root.mediaInfo.height; - } else { - // Default to a 16:9 placeholder - return root.contentMaxWidth / 16 * 9; - } - } - - readonly property var aspectRatio: imageWidth / imageHeight - /** - * Whether the image should be limited by height or width. - * We need to prevent excessively tall as well as excessively wide media. - * - * @note In the case of a tie the media is width limited. - */ - readonly property bool limitWidth: imageWidth >= imageHeight - - readonly property size maxSize: { - if (limitWidth) { - let width = Math.min(root.contentMaxWidth, root.maxWidth); - let height = width / aspectRatio; - return Qt.size(width, height); - } else { - let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio); - let width = height * aspectRatio; - return Qt.size(width, height); - } - } - - Layout.maximumWidth: maxSize.width - Layout.maximumHeight: maxSize.height - Layout.preferredWidth: imageWidth - Layout.preferredHeight: imageHeight + Layout.preferredWidth: mediaSizeHelper.currentSize.width + Layout.preferredHeight: mediaSizeHelper.currentSize.height property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item @@ -192,5 +152,12 @@ TimelineContainer { if (UrlHelper.openUrl(root.progressInfo.localPath)) return; if (UrlHelper.openUrl(root.progressInfo.localDir)) return; } + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.contentMaxWidth + mediaWidth: root.mediaInfo.width + mediaHeight: root.mediaInfo.height + } } } diff --git a/src/qml/Component/Timeline/ReplyComponent.qml b/src/qml/Component/Timeline/ReplyComponent.qml index 43d0e8abd..6944f8396 100644 --- a/src/qml/Component/Timeline/ReplyComponent.qml +++ b/src/qml/Component/Timeline/ReplyComponent.qml @@ -67,6 +67,8 @@ Item { */ required property var mediaInfo + required property real contentMaxWidth + /** * @brief The reply has been clicked. */ @@ -167,27 +169,17 @@ Item { id: imageComponent Image { id: image - - property var imageWidth: { - if (root.mediaInfo.width > 0) { - return root.mediaInfo.width; - } else { - return sourceSize.width; - } - } - property var imageHeight: { - if (root.mediaInfo.height > 0) { - return root.mediaInfo.height; - } else { - return sourceSize.height; - } - } - - readonly property var aspectRatio: imageWidth / imageHeight - - height: width / aspectRatio + width: mediaSizeHelper.currentSize.width + height: mediaSizeHelper.currentSize.height fillMode: Image.PreserveAspectFit source: root.mediaInfo.source + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.contentMaxWidth - verticalBorder.width - mainLayout.columnSpacing + mediaWidth: root.mediaInfo.width + mediaHeight: root.mediaInfo.height + } } } Component { diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index d6371431d..9c6855d79 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -487,6 +487,7 @@ ColumnLayout { type: root.reply.type display: root.reply.display mediaInfo: root.replyMediaInfo + contentMaxWidth: bubbleSizeHelper.currentWidth } Connections { diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index d14c88990..fe071f7d1 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -69,53 +69,8 @@ TimelineContainer { innerObject: Video { id: vid - - property var videoWidth: { - if (root.mediaInfo.width > 0) { - return root.mediaInfo.width; - } else if (metaData.resolution && metaData.resolution.width) { - return metaData.resolution.width; - } else { - return root.contentMaxWidth; - } - } - property var videoHeight: { - if (root.mediaInfo.height > 0) { - return root.mediaInfo.height; - } else if (metaData.resolution && metaData.resolution.height) { - return metaData.resolution.height; - } else { - // Default to a 16:9 placeholder - return root.contentMaxWidth / 16 * 9; - } - } - - readonly property var aspectRatio: videoWidth / videoHeight - /** - * Whether the video should be limited by height or width. - * We need to prevent excessively tall as well as excessively wide media. - * - * @note In the case of a tie the media is width limited. - */ - readonly property bool limitWidth: videoWidth >= videoHeight - - readonly property size maxSize: { - if (limitWidth) { - let width = Math.min(root.contentMaxWidth, root.maxWidth); - let height = width / aspectRatio; - return Qt.size(width, height); - } else { - let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio); - let width = height * aspectRatio; - return Qt.size(width, height); - } - } - - Layout.maximumWidth: maxSize.width - Layout.maximumHeight: maxSize.height - - Layout.preferredWidth: videoWidth - Layout.preferredHeight: videoHeight + Layout.preferredWidth: mediaSizeHelper.currentSize.width + Layout.preferredHeight: mediaSizeHelper.currentSize.height fillMode: VideoOutput.PreserveAspectFit @QTMULTIMEDIA_VIDEO_FLUSHMODE@ @@ -383,6 +338,13 @@ TimelineContainer { root.downloadAndPlay() } } + + MediaSizeHelper { + id: mediaSizeHelper + contentMaxWidth: root.contentMaxWidth + mediaWidth: root.mediaInfo.width + mediaHeight: root.mediaInfo.height + } } function downloadAndPlay() {