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