diff --git a/resources/icons/ui/volume-up.png b/resources/icons/ui/volume-up.png new file mode 100644 index 0000000000000000000000000000000000000000..4a42643f23ae3a55142a0ba0973ccfc5e33ba514 --- /dev/null +++ b/resources/icons/ui/volume-up.png @@ -0,0 +1,5 @@ +塒NG + +��� IHDR��� �������陆"���bKGD������牻��IDATH壟紫媿a鹣黗f4d黊搪,淝菷蕩伉的婒7X)"憪銰)V睷�Y事幦趏�J�嬿y籫n揠�;镥咱=}蟳烇鱵唧<瞎粗�0呏儳樌~�k壣糗Jⅵe貚Mu骘溺 �.簺%锬�4�/�9n^W�!焷�0�$��LB�o仑綅愊耐*�"�!�跜l?Rl箅!锫�葖瘁F葯榔?bGrp6炙 +筣@^�淬x葅�乲L�-虅G5Hj=Q朗獣楼恵!�'綞撲Q繠�'靭�*贋�os>�8W唨锅�3a祰�#a�澞�2�糔�7� +_騉匋 -雦~営娠篇淇9K縸e^m�*K繰霮䎱<N�3}�,伡鱡�U^穸愾0齰`E驘S�6V賳砊夺W�!k�gse�8�.囓�?鹜蝎礕�;S忊�轛qm馽!v鴒涔M�:�bk摖|鮱]枪5覑�5$�6€穉鞛F萻k�%墨@~�蜍颚春鈰l棭M�觝J店秥D my#謨'埠榄曫讖階f�>����IEND瓸`� \ No newline at end of file diff --git a/resources/qml/CommunitiesList.qml b/resources/qml/CommunitiesList.qml index 6a2c642c689803dd8839dd393d243761e7de28da..0a8587b3dc743baa06f257253545edcae9038eaa 100644 --- a/resources/qml/CommunitiesList.qml +++ b/resources/qml/CommunitiesList.qml @@ -56,15 +56,13 @@ Page { property color bubbleBackground: Nheko.colors.highlight property color bubbleText: Nheko.colors.highlightedText - background: Rectangle { - color: backgroundColor - } - height: avatarSize + 2 * Nheko.paddingMedium width: ListView.view.width state: "normal" ToolTip.visible: hovered && collapsed ToolTip.text: model.tooltip + onClicked: Communities.setCurrentTagId(model.id) + onPressAndHold: communityContextMenu.show(model.id) states: [ State { name: "highlight" @@ -108,9 +106,6 @@ Page { } - onClicked: Communities.setCurrentTagId(model.id) - onPressAndHold: communityContextMenu.show(model.id) - RowLayout { spacing: Nheko.paddingMedium anchors.fill: parent @@ -149,6 +144,10 @@ Page { } + background: Rectangle { + color: backgroundColor + } + } } diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml index eb6db29110f8ea3066a8998aa6e1f5d387cecc90..c738e5b4dae7c56892c01cb419e3e9775cdcffd4 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -3,14 +3,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later import "../" +import "../ui/media" import QtMultimedia 5.15 import QtQuick 2.15 import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.2 +import QtQuick.Layouts 1.15 import im.nheko 1.0 -Rectangle { - id: bg +Item { + id: content required property double proportionalHeight required property int type @@ -20,199 +21,86 @@ Rectangle { required property string url required property string body required property string filesize + property double tempWidth: Math.min(parent ? parent.width : undefined, originalWidth < 1 ? 400 : originalWidth) + property double tempHeight: tempWidth * proportionalHeight + property double divisor: isReply ? 4 : 2 + property bool tooHigh: tempHeight > timelineRoot.height / divisor + + height: (type == MtxEvent.VideoMessage ? tooHigh ? timelineRoot.height / divisor : tempHeight : 80) + fileInfoLabel.height + width: type == MtxEvent.VideoMessage ? tooHigh ? (timelineRoot.height / divisor) / proportionalHeight : tempWidth : 250 + + MxcMedia { + id: mxcmedia + + // TODO: Show error in overlay or so? + onError: console.log(error) + roomm: room + // desiredVolume is a float from 0.0 -> 1.0, MediaPlayer volume is an int from 0 to 100 + // this value automatically gets clamped for us between these two values. + volume: mediaControls.desiredVolume * 100 + muted: mediaControls.muted + } - radius: 10 - color: Nheko.colors.alternateBase - height: Math.round(content.height + 24) - width: parent ? parent.width : undefined - ListView.onPooled: height = 4 - ListView.onReused: height = Math.round(content.height + 24) - - Column { - id: content - - width: parent.width - 24 - anchors.centerIn: parent - - Rectangle { - id: videoContainer - - property double tempWidth: Math.min(parent ? parent.width : undefined, originalWidth < 1 ? 400 : originalWidth) - property double tempHeight: tempWidth * proportionalHeight - property double divisor: isReply ? 4 : 2 - property bool tooHigh: tempHeight > timelineView.height / divisor - - visible: type == MtxEvent.VideoMessage - height: tooHigh ? timelineView.height / divisor : tempHeight - width: tooHigh ? (timelineView.height / divisor) / proportionalHeight : tempWidth - - Image { - anchors.fill: parent - source: thumbnailUrl.replace("mxc://", "image://MxcImage/") - asynchronous: true - fillMode: Image.PreserveAspectFit + Rectangle { + id: videoContainer - VideoOutput { - anchors.fill: parent - fillMode: VideoOutput.PreserveAspectFit - flushMode: VideoOutput.FirstFrame - source: mxcmedia - } - - } + color: type == MtxEvent.VideoMessage ? Nheko.colors.window : "transparent" + width: parent.width + height: parent.height - fileInfoLabel.height + TapHandler { + onTapped: mediaControls.showControls() } - RowLayout { - width: parent.width - - Text { - id: positionText + Image { + anchors.fill: parent + source: thumbnailUrl.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit - text: "--:--:--" - color: Nheko.colors.text - } - - Slider { - id: progress - - //indeterminate: true - function updatePositionTexts() { - function formatTime(date) { - var hh = date.getUTCHours(); - var mm = date.getUTCMinutes(); - var ss = date.getSeconds(); - if (hh < 10) - hh = "0" + hh; - - if (mm < 10) - mm = "0" + mm; - - if (ss < 10) - ss = "0" + ss; - - return hh + ":" + mm + ":" + ss; - } - - positionText.text = formatTime(new Date(mxcmedia.position)); - durationText.text = formatTime(new Date(mxcmedia.duration)); - } - - Layout.fillWidth: true - value: mxcmedia.position - from: 0 - to: mxcmedia.duration - onMoved: mxcmedia.position = value - onValueChanged: updatePositionTexts() - palette: Nheko.colors - } + VideoOutput { + id: videoOutput - Text { - id: durationText - - text: "--:--:--" - color: Nheko.colors.text + visible: type == MtxEvent.VideoMessage + clip: true + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + source: mxcmedia + flushMode: VideoOutput.FirstFrame } } - RowLayout { - width: parent.width - spacing: 15 - - ImageButton { - id: button - - Layout.alignment: Qt.AlignVCenter - //color: Nheko.colors.window - //radius: 22 - height: 32 - width: 32 - z: 3 - image: ":/icons/icons/ui/arrow-pointing-down.png" - onClicked: { - switch (button.state) { - case "": - mxcmedia.eventId = eventId; - break; - case "stopped": - mxcmedia.play(); - console.log("play"); - button.state = "playing"; - break; - case "playing": - mxcmedia.pause(); - console.log("pause"); - button.state = "stopped"; - break; - } - } - states: [ - State { - name: "stopped" - - PropertyChanges { - target: button - image: ":/icons/icons/ui/play-sign.png" - } - - }, - State { - name: "playing" - - PropertyChanges { - target: button - image: ":/icons/icons/ui/pause-symbol.png" - } - - } - ] - - CursorShape { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - } - - MxcMedia { - id: mxcmedia - - roomm: room - onError: console.log(errorString) - onMediaStatusChanged: { - if (status == MxcMedia.LoadedMedia) { - progress.updatePositionTexts(); - button.state = "stopped"; - } - } - onStateChanged: { - if (state == MxcMedia.StoppedState) - button.state = "stopped"; - - } - } - - } - - ColumnLayout { - id: col + } - Text { - Layout.fillWidth: true - text: body - elide: Text.ElideRight - color: Nheko.colors.text - } + MediaControls { + id: mediaControls + + anchors.left: content.left + anchors.right: content.right + anchors.bottom: fileInfoLabel.top + playingVideo: type == MtxEvent.VideoMessage + positionValue: mxcmedia.position + duration: mxcmedia.duration + mediaLoaded: mxcmedia.loaded + mediaState: mxcmedia.state + onPositionChanged: mxcmedia.position = position + onPlayPauseActivated: mxcmedia.state == MediaPlayer.PlayingState ? mxcmedia.pause() : mxcmedia.play() + onLoadActivated: mxcmedia.eventId = eventId + } - Text { - Layout.fillWidth: true - text: filesize - textFormat: Text.PlainText - elide: Text.ElideRight - color: Nheko.colors.text - } + // information about file name and file size + Label { + id: fileInfoLabel - } + anchors.bottom: content.bottom + text: body + " [" + filesize + "]" + textFormat: Text.PlainText + elide: Text.ElideRight + color: Nheko.colors.text + background: Rectangle { + color: Nheko.colors.base } } diff --git a/resources/qml/dialogs/ReadReceipts.qml b/resources/qml/dialogs/ReadReceipts.qml index f551bae9d3a7262bf3f81ef028293b434b9e53bf..1bfdae84984693ad6facbf064d8081b4c493fc96 100644 --- a/resources/qml/dialogs/ReadReceipts.qml +++ b/resources/qml/dialogs/ReadReceipts.qml @@ -66,9 +66,6 @@ ApplicationWindow { hoverEnabled: true ToolTip.visible: hovered ToolTip.text: model.mxid - background: Rectangle { - color: readReceiptsRoot.color - } RowLayout { id: receiptLayout @@ -113,6 +110,10 @@ ApplicationWindow { cursorShape: Qt.PointingHandCursor } + background: Rectangle { + color: readReceiptsRoot.color + } + } } diff --git a/resources/qml/ui/NhekoSlider.qml b/resources/qml/ui/NhekoSlider.qml new file mode 100644 index 0000000000000000000000000000000000000000..23e22f51b3e957f403d135c97bcbc46dfe22d41e --- /dev/null +++ b/resources/qml/ui/NhekoSlider.qml @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import im.nheko 1.0 + +Slider { + id: control + + property color progressColor: Nheko.colors.highlight + property bool alwaysShowSlider: true + property int sliderRadius: 16 + + value: 0 + implicitHeight: sliderRadius + padding: 0 + + background: Rectangle { + x: control.leftPadding + handle.width / 2 + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: control.sliderRadius / 4 + width: control.availableWidth - handle.width + height: implicitHeight + radius: height / 2 + color: Nheko.colors.buttonText + + Rectangle { + width: control.visualPosition * parent.width + height: parent.height + color: control.progressColor + radius: 2 + } + + } + + handle: Rectangle { + x: control.leftPadding + control.visualPosition * background.width + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: control.sliderRadius + implicitHeight: control.sliderRadius + radius: control.sliderRadius / 2 + color: control.progressColor + visible: Settings.mobileMode || control.alwaysShowSlider || control.hovered || control.pressed + border.color: control.progressColor + } + +} diff --git a/resources/qml/ui/media/MediaControls.qml b/resources/qml/ui/media/MediaControls.qml new file mode 100644 index 0000000000000000000000000000000000000000..7216e5529736ae77a22654823e6f237b3607b764 --- /dev/null +++ b/resources/qml/ui/media/MediaControls.qml @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "../" +import "../../" +import QtMultimedia 5.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import im.nheko 1.0 + +Rectangle { + id: control + + property alias desiredVolume: volumeSlider.desiredVolume + property bool muted: false + property bool playingVideo: false + property var mediaState + property bool mediaLoaded: false + property var duration + property var positionValue: 0 + property var position + property bool shouldShowControls: !playingVideo || playerMouseArea.shouldShowControls || volumeSlider.state == "shown" + + signal playPauseActivated() + signal loadActivated() + + function showControls() { + controlHideTimer.restart(); + } + + function durationToString(duration) { + function maybeZeroPrepend(time) { + return (time < 10) ? "0" + time.toString() : time.toString(); + } + + var totalSeconds = Math.floor(duration / 1000); + var seconds = totalSeconds % 60; + var minutes = (Math.floor(totalSeconds / 60)) % 60; + var hours = (Math.floor(totalSeconds / (60 * 24))) % 24; + // Always show minutes and don't prepend zero into the leftmost element + var ss = maybeZeroPrepend(seconds); + var mm = (hours > 0) ? maybeZeroPrepend(minutes) : minutes.toString(); + var hh = hours.toString(); + if (hours < 1) + return mm + ":" + ss; + + return hh + ":" + mm + ":" + ss; + } + + color: { + var wc = Nheko.colors.alternateBase; + return Qt.rgba(wc.r, wc.g, wc.b, 0.5); + } + opacity: control.shouldShowControls ? 1 : 0 + height: controlLayout.implicitHeight + + HoverHandler { + id: playerMouseArea + + property bool shouldShowControls: hovered || controlHideTimer.running || control.mediaState != MediaPlayer.PlayingState + + onHoveredChanged: showControls() + } + + ColumnLayout { + id: controlLayout + + enabled: control.shouldShowControls + spacing: 0 + anchors.bottom: control.bottom + anchors.left: control.left + anchors.right: control.right + + NhekoSlider { + Layout.fillWidth: true + Layout.leftMargin: Nheko.paddingSmall + Layout.rightMargin: Nheko.paddingSmall + enabled: control.mediaLoaded + value: control.positionValue + onMoved: control.position = value + from: 0 + to: control.duration + alwaysShowSlider: false + } + + RowLayout { + Layout.margins: Nheko.paddingSmall + spacing: Nheko.paddingSmall + Layout.fillWidth: true + + // Cache/Play/pause button + ImageButton { + id: playbackStateImage + + Layout.alignment: Qt.AlignLeft + buttonTextColor: Nheko.colors.text + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + image: { + if (control.mediaLoaded) { + if (control.mediaState == MediaPlayer.PlayingState) + return ":/icons/icons/ui/pause-symbol.png"; + else + return ":/icons/icons/ui/play-sign.png"; + } else { + return ":/icons/icons/ui/arrow-pointing-down.png"; + } + } + onClicked: control.mediaLoaded ? control.playPauseActivated() : control.loadActivated() + } + + ImageButton { + id: volumeButton + + Layout.alignment: Qt.AlignLeft + buttonTextColor: Nheko.colors.text + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + image: { + if (control.muted || control.desiredVolume <= 0) + return ":/icons/icons/ui/volume-off-indicator.png"; + else + return ":/icons/icons/ui/volume-up.png"; + } + onClicked: control.muted = !control.muted + } + + NhekoSlider { + id: volumeSlider + + property real desiredVolume: QtMultimedia.convertVolume(volumeSlider.value, QtMultimedia.LogarithmicVolumeScale, QtMultimedia.LinearVolumeScale) + + state: "" + Layout.alignment: Qt.AlignLeft + Layout.preferredWidth: 0 + opacity: 0 + orientation: Qt.Horizontal + value: 1 + onDesiredVolumeChanged: { + control.muted = !(desiredVolume > 0); + } + transitions: [ + Transition { + from: "" + to: "shown" + + SequentialAnimation { + PauseAnimation { + duration: 50 + } + + NumberAnimation { + duration: 100 + properties: "opacity" + easing.type: Easing.InQuad + } + + } + + NumberAnimation { + properties: "Layout.preferredWidth" + duration: 150 + } + + }, + Transition { + from: "shown" + to: "" + + SequentialAnimation { + PauseAnimation { + duration: 100 + } + + ParallelAnimation { + NumberAnimation { + duration: 100 + properties: "opacity" + easing.type: Easing.InQuad + } + + NumberAnimation { + properties: "Layout.preferredWidth" + duration: 150 + } + + } + + } + + } + ] + + states: State { + name: "shown" + when: Settings.mobileMode || volumeButton.hovered || volumeSlider.hovered || volumeSlider.pressed + + PropertyChanges { + target: volumeSlider + Layout.preferredWidth: 100 + } + + PropertyChanges { + target: volumeSlider + opacity: 1 + } + + } + + } + + Label { + Layout.alignment: Qt.AlignRight + text: (!control.mediaLoaded) ? "-- / --" : (durationToString(control.positionValue) + " / " + durationToString(control.duration)) + color: Nheko.colors.text + } + + Item { + Layout.fillWidth: true + } + + } + + } + + // For hiding controls on stationary cursor + Timer { + id: controlHideTimer + + interval: 1500 //ms + repeat: false + } + + // Fade controls in/out + Behavior on opacity { + OpacityAnimator { + duration: 100 + } + + } + +} diff --git a/resources/qml/ui/media/qmldir b/resources/qml/ui/media/qmldir new file mode 100644 index 0000000000000000000000000000000000000000..143b603da9b0e270813128751cb96021ac3505b1 --- /dev/null +++ b/resources/qml/ui/media/qmldir @@ -0,0 +1,3 @@ +module im.nheko.UI.Media +VolumeSlider 1.0 VolumeSlider.qml +MediaControls 1.0 MediaControls.qml \ No newline at end of file diff --git a/resources/qml/ui/qmldir b/resources/qml/ui/qmldir index 831a723dca42e74db49ea007efd23cdf01acb6a5..a2ce7514b70056a7ff847c9acf49dc1a4e5f28e2 100644 --- a/resources/qml/ui/qmldir +++ b/resources/qml/ui/qmldir @@ -1,3 +1,4 @@ module im.nheko.UI +NhekoSlider 1.0 NhekoSlider.qml Ripple 1.0 Ripple.qml Spinner 1.0 Spinner.qml \ No newline at end of file diff --git a/resources/res.qrc b/resources/res.qrc index 66b77205c4d63f61ae11a4b1469399f24665fc17..a60f4ab03760988b7b598475cbc41f837794adb1 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -3,6 +3,7 @@ <file>icons/ui/at-solid.svg</file> <file>icons/ui/volume-off-indicator.png</file> <file>icons/ui/volume-off-indicator@2x.png</file> + <file>icons/ui/volume-up.png</file> <file>icons/ui/black-bubble-speech.png</file> <file>icons/ui/black-bubble-speech@2x.png</file> <file>icons/ui/do-not-disturb-rounded-sign.png</file> @@ -179,9 +180,11 @@ <file>qml/dialogs/UserProfile.qml</file> <file>qml/emoji/EmojiPicker.qml</file> <file>qml/emoji/StickerPicker.qml</file> + <file>qml/ui/NhekoSlider.qml</file> <file>qml/ui/Ripple.qml</file> <file>qml/ui/Spinner.qml</file> <file>qml/ui/animations/BlinkAnimation.qml</file> + <file>qml/ui/media/MediaControls.qml</file> <file>qml/voip/ActiveCallBar.qml</file> <file>qml/voip/CallDevices.qml</file> <file>qml/voip/CallInvite.qml</file> diff --git a/src/ui/MxcMediaProxy.cpp b/src/ui/MxcMediaProxy.cpp index db8c0f1f36e24de21fc1487191e25be6a7a90032..df0298dab895cc633f502097d40b934c209e27c3 100644 --- a/src/ui/MxcMediaProxy.cpp +++ b/src/ui/MxcMediaProxy.cpp @@ -13,6 +13,11 @@ #include <QStandardPaths> #include <QUrl> +#if defined(Q_OS_MACOS) +// TODO (red_sky): Remove for Qt6. See other ifdef below +#include <QTemporaryFile> +#endif + #include "EventAccessors.h" #include "Logging.h" #include "MatrixClient.h" @@ -75,7 +80,7 @@ MxcMediaProxy::startDownload() QPointer<MxcMediaProxy> self = this; - auto processBuffer = [this, encryptionInfo, filename, self](QIODevice &device) { + auto processBuffer = [this, encryptionInfo, filename, self, suffix](QIODevice &device) { if (!self) return; @@ -90,10 +95,34 @@ MxcMediaProxy::startDownload() buffer.open(QIODevice::ReadOnly); buffer.reset(); - QTimer::singleShot(0, this, [this, filename] { + QTimer::singleShot(0, this, [this, filename, suffix, encryptionInfo] { +#if defined(Q_OS_MACOS) + if (encryptionInfo) { + // macOS has issues reading from a buffer in setMedia for whatever reason. + // Instead, write the buffer to a temporary file and read from that. + // This should be fixed in Qt6, so update this when we do that! + // TODO: REMOVE IN QT6 + QTemporaryFile tempFile; + tempFile.setFileTemplate(tempFile.fileTemplate() + QLatin1Char('.') + suffix); + tempFile.open(); + tempFile.write(buffer.data()); + tempFile.close(); + nhlog::ui()->debug("Playing media from temp buffer file: {}. Remove in QT6!", + filename.filePath().toStdString()); + this->setMedia(QUrl::fromLocalFile(tempFile.fileName())); + } else { + nhlog::ui()->info( + "Playing buffer with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen()); + this->setMedia(QUrl::fromLocalFile(filename.filePath())); + } +#else + Q_UNUSED(suffix) + Q_UNUSED(encryptionInfo) + nhlog::ui()->info( "Playing buffer with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen()); this->setMedia(QMediaContent(filename.fileName()), &buffer); +#endif emit loadedChanged(); }); };