diff --git a/resources/icons/ui/volume-up.png b/resources/icons/ui/volume-up.png new file mode 100644 index 00000000..4a42643f Binary files /dev/null and b/resources/icons/ui/volume-up.png differ diff --git a/resources/qml/CommunitiesList.qml b/resources/qml/CommunitiesList.qml index 6a2c642c..0a8587b3 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 eb6db291..c738e5b4 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,201 +21,88 @@ 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 - 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) + height: (type == MtxEvent.VideoMessage ? tooHigh ? timelineRoot.height / divisor : tempHeight : 80) + fileInfoLabel.height + width: type == MtxEvent.VideoMessage ? tooHigh ? (timelineRoot.height / divisor) / proportionalHeight : tempWidth : 250 - Column { - id: content + MxcMedia { + id: mxcmedia - width: parent.width - 24 - anchors.centerIn: parent + // 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 + } - Rectangle { - id: videoContainer + 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 + color: type == MtxEvent.VideoMessage ? Nheko.colors.window : "transparent" + width: parent.width + height: parent.height - fileInfoLabel.height - visible: type == MtxEvent.VideoMessage - height: tooHigh ? timelineView.height / divisor : tempHeight - width: tooHigh ? (timelineView.height / divisor) / proportionalHeight : tempWidth + TapHandler { + onTapped: mediaControls.showControls() + } - Image { + Image { + anchors.fill: parent + source: thumbnailUrl.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + + VideoOutput { + id: videoOutput + + visible: type == MtxEvent.VideoMessage + clip: true anchors.fill: parent - source: thumbnailUrl.replace("mxc://", "image://MxcImage/") - asynchronous: true - fillMode: Image.PreserveAspectFit - - VideoOutput { - anchors.fill: parent - fillMode: VideoOutput.PreserveAspectFit - flushMode: VideoOutput.FirstFrame - source: mxcmedia - } - - } - - } - - RowLayout { - width: parent.width - - Text { - id: positionText - - 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 - } - - Text { - id: durationText - - text: "--:--:--" - color: Nheko.colors.text - } - - } - - 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 - } - - Text { - Layout.fillWidth: true - text: filesize - textFormat: Text.PlainText - elide: Text.ElideRight - color: Nheko.colors.text - } - + fillMode: VideoOutput.PreserveAspectFit + source: mxcmedia + flushMode: VideoOutput.FirstFrame } } } + 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 + } + + // 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 f551bae9..1bfdae84 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 00000000..23e22f51 --- /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 00000000..7216e552 --- /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 00000000..143b603d --- /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 831a723d..a2ce7514 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 66b77205..a60f4ab0 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -3,6 +3,7 @@ icons/ui/at-solid.svg icons/ui/volume-off-indicator.png icons/ui/volume-off-indicator@2x.png + icons/ui/volume-up.png icons/ui/black-bubble-speech.png icons/ui/black-bubble-speech@2x.png icons/ui/do-not-disturb-rounded-sign.png @@ -179,9 +180,11 @@ qml/dialogs/UserProfile.qml qml/emoji/EmojiPicker.qml qml/emoji/StickerPicker.qml + qml/ui/NhekoSlider.qml qml/ui/Ripple.qml qml/ui/Spinner.qml qml/ui/animations/BlinkAnimation.qml + qml/ui/media/MediaControls.qml qml/voip/ActiveCallBar.qml qml/voip/CallDevices.qml qml/voip/CallInvite.qml diff --git a/src/ui/MxcMediaProxy.cpp b/src/ui/MxcMediaProxy.cpp index db8c0f1f..df0298da 100644 --- a/src/ui/MxcMediaProxy.cpp +++ b/src/ui/MxcMediaProxy.cpp @@ -13,6 +13,11 @@ #include #include +#if defined(Q_OS_MACOS) +// TODO (red_sky): Remove for Qt6. See other ifdef below +#include +#endif + #include "EventAccessors.h" #include "Logging.h" #include "MatrixClient.h" @@ -75,7 +80,7 @@ MxcMediaProxy::startDownload() QPointer 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(); }); };