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/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml index fd764d52..3face74d 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -9,9 +9,7 @@ import QtQuick.Controls 2.1 import QtQuick.Layouts 1.2 import im.nheko 1.0 -Rectangle { - id: bg - +ColumnLayout { required property double proportionalHeight required property int type required property int originalWidth @@ -21,205 +19,367 @@ Rectangle { required property string body required property string filesize - 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) + 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() - 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 - - VideoOutput { - anchors.fill: parent - fillMode: VideoOutput.PreserveAspectFit - source: media - } - - } - - } - - 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(media.position)); - durationText.text = formatTime(new Date(media.duration)); - } - - Layout.fillWidth: true - value: media.position - from: 0 - to: media.duration - onMoved: media.seek(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 "": - room.cacheMedia(eventId); - break; - case "stopped": - media.play(); - console.log("play"); - button.state = "playing"; - break; - case "playing": - media.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 - } - - MediaPlayer { - id: media - - onError: console.log(errorString) - onStatusChanged: { - if (status == MediaPlayer.Loaded) - progress.updatePositionTexts(); - - } - onStopped: button.state = "stopped" - } - - Connections { - target: room - onMediaCached: { - if (mxcUrl == url) { - media.source = cacheUrl; - button.state = "stopped"; - console.log("media loaded: " + mxcUrl + " at " + cacheUrl); - } - console.log("media cached: " + mxcUrl + " at " + cacheUrl); - } - } - - } - - 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 - } - - } - - } + if (hours < 1) + return mm + ":" + ss + return hh + ":" + mm + ":" + ss + } + id: content + Layout.maximumWidth: parent? parent.width: undefined + MediaPlayer { + id: media + // TODO: Show error in overlay or so? + onError: console.log(errorString) + volume: volumeSlider.desiredVolume } + Connections { + property bool mediaCached: false + + id: mediaCachedObserver + target: room + onMediaCached: { + if (mxcUrl == url) { + mediaCached = true + media.source = "file://" + cacheUrl + console.log("media loaded: " + mxcUrl + " at " + cacheUrl) + } + console.log("media cached: " + mxcUrl + " at " + cacheUrl) + } + } + + Rectangle { + id: videoContainer + visible: type == MtxEvent.VideoMessage + //property double tempWidth: Math.min(parent ? parent.width : undefined, model.data.width < 1 ? 400 : /////model.data.width) + // property double tempWidth: (model.data.width < 1) ? 400 : model.data.width + // property double tempHeight: tempWidth * model.data.proportionalHeight + //property double tempWidth: Math.min(parent ? parent.width : undefined, originalWidth < 1 ? 400 : originalWidth) + 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 + + Layout.maximumWidth: Layout.preferredWidth + Layout.preferredHeight: tooHigh ? timelineRoot.height / divisor : tempHeight + Layout.preferredWidth: tooHigh ? (timelineRoot.height / divisor) / proportionalHeight : tempWidth + Image { + anchors.fill: parent + source: thumbnailUrl.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + // Button and window colored overlay to cache media + Rectangle { + // Display over video controls + z: videoOutput.z + 1 + visible: !mediaCachedObserver.mediaCached + anchors.fill: parent + color: Nheko.colors.window + opacity: 0.5 + Image { + property color buttonColor: (cacheVideoArea.containsMouse) ? Nheko.colors.highlight : + Nheko.colors.text + + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + source: "image://colorimage/:/icons/icons/ui/arrow-pointing-down.png?"+buttonColor + } + MouseArea { + id: cacheVideoArea + anchors.fill: parent + hoverEnabled: true + enabled: !mediaCachedObserver.mediaCached + onClicked: room.cacheMedia(eventId) + } + } + VideoOutput { + id: videoOutput + clip: true + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + source: media + // TODO: once we can use Qt 5.12, use HoverHandler + MouseArea { + id: playerMouseArea + // Toggle play state on clicks + onClicked: { + if (controlRect.shouldShowControls && + !controlRect.contains(mapToItem(controlRect, mouseX, mouseY))) { + (media.playbackState == MediaPlayer.PlayingState) ? + media.pause() : + media.play() + } + } + Rectangle { + id: controlRect + property int controlHeight: 25 + property bool shouldShowControls: playerMouseArea.shouldShowControls || + volumeSliderRect.visible + + anchors.bottom: playerMouseArea.bottom + // Window color with 128/255 alpha + color: { + var wc = Nheko.colors.window + return Qt.rgba(wc.r, wc.g, wc.b, 0.5) + } + height: 40 + width: playerMouseArea.width + opacity: shouldShowControls ? 1 : 0 + // Fade controls in/out + Behavior on opacity { + OpacityAnimator { + duration: 100 + } + } + + RowLayout { + anchors.fill: parent + width: parent.width + // Play/pause button + Image { + id: playbackStateImage + fillMode: Image.PreserveAspectFit + Layout.preferredHeight: controlRect.controlHeight + Layout.alignment: Qt.AlignVCenter + property color controlColor: (playbackStateArea.containsMouse) ? + Nheko.colors.highlight : Nheko.colors.text + + source: (media.playbackState == MediaPlayer.PlayingState) ? + "image://colorimage/:/icons/icons/ui/pause-symbol.png?"+controlColor : + "image://colorimage/:/icons/icons/ui/play-sign.png?"+controlColor + MouseArea { + id: playbackStateArea + + anchors.fill: parent + hoverEnabled: true + onClicked: { + (media.playbackState == MediaPlayer.PlayingState) ? + media.pause() : + media.play() + } + } + } + Label { + text: (!mediaCachedObserver.mediaCached) ? "-/-" : + durationToString(media.position) + "/" + durationToString(media.duration) + } + + Slider { + Layout.fillWidth: true + Layout.minimumWidth: 50 + height: controlRect.controlHeight + value: media.position + onMoved: media.seek(value) + from: 0 + to: media.duration + } + // Volume slider activator + Image { + property color controlColor: (volumeImageArea.containsMouse) ? + Nheko.colors.highlight : Nheko.colors.text + + // TODO: add icons for different volume levels + id: volumeImage + source: (media.volume > 0 && !media.muted) ? + "image://colorimage/:/icons/icons/ui/volume-up.png?"+ controlColor : + "image://colorimage/:/icons/icons/ui/volume-off-indicator.png?"+ controlColor + Layout.rightMargin: 5 + Layout.preferredHeight: controlRect.controlHeight + fillMode: Image.PreserveAspectFit + MouseArea { + id: volumeImageArea + anchors.fill: parent + hoverEnabled: true + onClicked: media.muted = !media.muted + onExited: volumeSliderHideTimer.start() + onPositionChanged: volumeSliderHideTimer.start() + // For hiding volume slider after a while + Timer { + id: volumeSliderHideTimer + interval: 1500 + repeat: false + running: false + } + } + Rectangle { + id: volumeSliderRect + opacity: (visible) ? 1 : 0 + Behavior on opacity { + OpacityAnimator { + duration: 100 + } + } + // TODO: figure out a better way to put the slider popup above controlRect + anchors.bottom: volumeImage.top + anchors.bottomMargin: 10 + anchors.horizontalCenter: volumeImage.horizontalCenter + color: { + var wc = Nheko.colors.window + return Qt.rgba(wc.r, wc.g, wc.b, 0.5) + } + /* TODO: base width on the slider width (some issue with it not having a geometry + when using the width here?) */ + width: volumeImage.width * 0.7 + radius: volumeSlider.width / 2 + height: controlRect.height * 2 //100 + visible: volumeImageArea.containsMouse || + volumeSliderHideTimer.running || + volumeSliderRectMouseArea.containsMouse + Slider { + // Desired value to avoid loop onMoved -> media.volume -> value -> onMoved... + property real desiredVolume: 1 + + // TODO: the slider is slightly off-center on the left for some reason... + id: volumeSlider + from: 0 + to: 1 + value: (media.muted) ? 0 : + QtMultimedia.convertVolume(desiredVolume, + QtMultimedia.LinearVolumeScale, + QtMultimedia.LogarithmicVolumeScale) + anchors.fill: parent + anchors.bottomMargin: parent.height * 0.1 + anchors.topMargin: parent.height * 0.1 + anchors.horizontalCenter: parent.horizontalCenter + orientation: Qt.Vertical + onMoved: desiredVolume = QtMultimedia.convertVolume(value, + QtMultimedia.LogarithmicVolumeScale, + QtMultimedia.LinearVolumeScale) + /* This would be better handled in 'media', but it has some issue with listening + to this signal */ + onDesiredVolumeChanged: media.muted = !(desiredVolume > 0) + } + // Used for resetting the timer on mouse moves on volumeSliderRect + MouseArea { + id: volumeSliderRectMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onExited: volumeSliderHideTimer.start() + + onClicked: mouse.accepted = false + onPressed: mouse.accepted = false + onReleased: mouse.accepted = false + onPressAndHold: mouse.accepted = false + onPositionChanged: { + mouse.accepted = false + volumeSliderHideTimer.start() + } + } + } + } + + } + } + // This breaks separation of concerns but this same thing doesn't work when called from controlRect... + property bool shouldShowControls: (containsMouse && controlHideTimer.running) || + (media.playbackState != MediaPlayer.PlayingState) || + controlRect.contains(mapToItem(controlRect, mouseX, mouseY)) + + // For hiding controls on stationary cursor + Timer { + id: controlHideTimer + interval: 1500 //ms + repeat: false + } + + hoverEnabled: true + onPositionChanged: controlHideTimer.start() + + x: videoOutput.contentRect.x + y: videoOutput.contentRect.y + width: videoOutput.contentRect.width + height: videoOutput.contentRect.height + propagateComposedEvents: true + } + } + } + } + // Audio player + // TODO: share code with the video player + Rectangle { + id: audioControlRect + + visible: type != MtxEvent.VideoMessage + property int controlHeight: 25 + Layout.preferredHeight: 40 + RowLayout { + anchors.fill: parent + width: parent.width + // Play/pause button + Image { + id: audioPlaybackStateImage + fillMode: Image.PreserveAspectFit + Layout.preferredHeight: controlRect.controlHeight + Layout.alignment: Qt.AlignVCenter + property color controlColor: (audioPlaybackStateArea.containsMouse) ? + Nheko.colors.highlight : Nheko.colors.text + + source: { + if (!mediaCachedObserver.mediaCached) + return "image://colorimage/:/icons/icons/ui/arrow-pointing-down.png?"+controlColor + return (media.playbackState == MediaPlayer.PlayingState) ? + "image://colorimage/:/icons/icons/ui/pause-symbol.png?"+controlColor : + "image://colorimage/:/icons/icons/ui/play-sign.png?"+controlColor + } + MouseArea { + id: audioPlaybackStateArea + + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (!mediaCachedObserver.mediaCached) { + room.cacheMedia(eventId) + return + } + (media.playbackState == MediaPlayer.PlayingState) ? + media.pause() : + media.play() + } + } + } + Label { + text: (!mediaCachedObserver.mediaCached) ? "-/-" : + durationToString(media.position) + "/" + durationToString(media.duration) + } + + Slider { + Layout.fillWidth: true + Layout.minimumWidth: 50 + height: controlRect.controlHeight + value: media.position + onMoved: media.seek(value) + from: 0 + to: media.duration + } + } + } + + Label { + id: fileInfoLabel + + background: Rectangle { + color: Nheko.colors.base + } + Layout.fillWidth: true + text: body + " [" + filesize + "]" + textFormat: Text.PlainText + elide: Text.ElideRight + color: Nheko.colors.text + } } diff --git a/resources/res.qrc b/resources/res.qrc index f41835f9..1e7d0a25 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