Merge branch 'video_player_enhancements' into 'master'

Video player enhancements

See merge request nheko-reborn/nheko!15
This commit is contained in:
Nicolas Werner 2021-11-12 00:19:25 +00:00
commit 1ab4d35579
10 changed files with 416 additions and 198 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

View file

@ -56,15 +56,13 @@ Page {
property color bubbleBackground: Nheko.colors.highlight property color bubbleBackground: Nheko.colors.highlight
property color bubbleText: Nheko.colors.highlightedText property color bubbleText: Nheko.colors.highlightedText
background: Rectangle {
color: backgroundColor
}
height: avatarSize + 2 * Nheko.paddingMedium height: avatarSize + 2 * Nheko.paddingMedium
width: ListView.view.width width: ListView.view.width
state: "normal" state: "normal"
ToolTip.visible: hovered && collapsed ToolTip.visible: hovered && collapsed
ToolTip.text: model.tooltip ToolTip.text: model.tooltip
onClicked: Communities.setCurrentTagId(model.id)
onPressAndHold: communityContextMenu.show(model.id)
states: [ states: [
State { State {
name: "highlight" name: "highlight"
@ -108,9 +106,6 @@ Page {
} }
onClicked: Communities.setCurrentTagId(model.id)
onPressAndHold: communityContextMenu.show(model.id)
RowLayout { RowLayout {
spacing: Nheko.paddingMedium spacing: Nheko.paddingMedium
anchors.fill: parent anchors.fill: parent
@ -149,6 +144,10 @@ Page {
} }
background: Rectangle {
color: backgroundColor
}
} }
} }

View file

@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
import "../" import "../"
import "../ui/media"
import QtMultimedia 5.15 import QtMultimedia 5.15
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 1.2 import QtQuick.Layouts 1.15
import im.nheko 1.0 import im.nheko 1.0
Rectangle { Item {
id: bg id: content
required property double proportionalHeight required property double proportionalHeight
required property int type required property int type
@ -20,201 +21,88 @@ Rectangle {
required property string url required property string url
required property string body required property string body
required property string filesize 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 height: (type == MtxEvent.VideoMessage ? tooHigh ? timelineRoot.height / divisor : tempHeight : 80) + fileInfoLabel.height
color: Nheko.colors.alternateBase width: type == MtxEvent.VideoMessage ? tooHigh ? (timelineRoot.height / divisor) / proportionalHeight : tempWidth : 250
height: Math.round(content.height + 24)
width: parent ? parent.width : undefined
ListView.onPooled: height = 4
ListView.onReused: height = Math.round(content.height + 24)
Column { MxcMedia {
id: content id: mxcmedia
width: parent.width - 24 // TODO: Show error in overlay or so?
anchors.centerIn: parent 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 { Rectangle {
id: videoContainer id: videoContainer
property double tempWidth: Math.min(parent ? parent.width : undefined, originalWidth < 1 ? 400 : originalWidth) color: type == MtxEvent.VideoMessage ? Nheko.colors.window : "transparent"
property double tempHeight: tempWidth * proportionalHeight width: parent.width
property double divisor: isReply ? 4 : 2 height: parent.height - fileInfoLabel.height
property bool tooHigh: tempHeight > timelineView.height / divisor
visible: type == MtxEvent.VideoMessage TapHandler {
height: tooHigh ? timelineView.height / divisor : tempHeight onTapped: mediaControls.showControls()
width: tooHigh ? (timelineView.height / divisor) / proportionalHeight : tempWidth }
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 anchors.fill: parent
source: thumbnailUrl.replace("mxc://", "image://MxcImage/") fillMode: VideoOutput.PreserveAspectFit
asynchronous: true source: mxcmedia
fillMode: Image.PreserveAspectFit flushMode: VideoOutput.FirstFrame
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
}
} }
} }
} }
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
}
}
} }

View file

@ -66,9 +66,6 @@ ApplicationWindow {
hoverEnabled: true hoverEnabled: true
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: model.mxid ToolTip.text: model.mxid
background: Rectangle {
color: readReceiptsRoot.color
}
RowLayout { RowLayout {
id: receiptLayout id: receiptLayout
@ -113,6 +110,10 @@ ApplicationWindow {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
background: Rectangle {
color: readReceiptsRoot.color
}
} }
} }

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -0,0 +1,3 @@
module im.nheko.UI.Media
VolumeSlider 1.0 VolumeSlider.qml
MediaControls 1.0 MediaControls.qml

View file

@ -1,3 +1,4 @@
module im.nheko.UI module im.nheko.UI
NhekoSlider 1.0 NhekoSlider.qml
Ripple 1.0 Ripple.qml Ripple 1.0 Ripple.qml
Spinner 1.0 Spinner.qml Spinner 1.0 Spinner.qml

View file

@ -3,6 +3,7 @@
<file>icons/ui/at-solid.svg</file> <file>icons/ui/at-solid.svg</file>
<file>icons/ui/volume-off-indicator.png</file> <file>icons/ui/volume-off-indicator.png</file>
<file>icons/ui/volume-off-indicator@2x.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.png</file>
<file>icons/ui/black-bubble-speech@2x.png</file> <file>icons/ui/black-bubble-speech@2x.png</file>
<file>icons/ui/do-not-disturb-rounded-sign.png</file> <file>icons/ui/do-not-disturb-rounded-sign.png</file>
@ -179,9 +180,11 @@
<file>qml/dialogs/UserProfile.qml</file> <file>qml/dialogs/UserProfile.qml</file>
<file>qml/emoji/EmojiPicker.qml</file> <file>qml/emoji/EmojiPicker.qml</file>
<file>qml/emoji/StickerPicker.qml</file> <file>qml/emoji/StickerPicker.qml</file>
<file>qml/ui/NhekoSlider.qml</file>
<file>qml/ui/Ripple.qml</file> <file>qml/ui/Ripple.qml</file>
<file>qml/ui/Spinner.qml</file> <file>qml/ui/Spinner.qml</file>
<file>qml/ui/animations/BlinkAnimation.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/ActiveCallBar.qml</file>
<file>qml/voip/CallDevices.qml</file> <file>qml/voip/CallDevices.qml</file>
<file>qml/voip/CallInvite.qml</file> <file>qml/voip/CallInvite.qml</file>

View file

@ -13,6 +13,11 @@
#include <QStandardPaths> #include <QStandardPaths>
#include <QUrl> #include <QUrl>
#if defined(Q_OS_MACOS)
// TODO (red_sky): Remove for Qt6. See other ifdef below
#include <QTemporaryFile>
#endif
#include "EventAccessors.h" #include "EventAccessors.h"
#include "Logging.h" #include "Logging.h"
#include "MatrixClient.h" #include "MatrixClient.h"
@ -75,7 +80,7 @@ MxcMediaProxy::startDownload()
QPointer<MxcMediaProxy> self = this; QPointer<MxcMediaProxy> self = this;
auto processBuffer = [this, encryptionInfo, filename, self](QIODevice &device) { auto processBuffer = [this, encryptionInfo, filename, self, suffix](QIODevice &device) {
if (!self) if (!self)
return; return;
@ -90,10 +95,34 @@ MxcMediaProxy::startDownload()
buffer.open(QIODevice::ReadOnly); buffer.open(QIODevice::ReadOnly);
buffer.reset(); 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( nhlog::ui()->info(
"Playing buffer with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen()); "Playing buffer with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen());
this->setMedia(QMediaContent(filename.fileName()), &buffer); this->setMedia(QMediaContent(filename.fileName()), &buffer);
#endif
emit loadedChanged(); emit loadedChanged();
}); });
}; };