From c01fcdcb544485e4071a515435107ec590dc7bfa Mon Sep 17 00:00:00 2001 From: Chris Josten Date: Thu, 1 Oct 2020 21:45:34 +0200 Subject: [PATCH] Report playback progress and resume items [Playback]: New: playback progress is reported to the Jellyfin server. [Playback]: New: resume partly played items or start playing from the beginning if desired. I also had to make some changes to the VideoPlayer, because the VideoHUD got locked up when the player changed status from Buffering to Buffered too quickly in succession, which occurs when trying to seek directly after the application is able to. --- qml/Utils.js | 8 +++ qml/components/GlassyBackground.qml | 2 +- qml/components/PlayToolbar.qml | 20 +++++- qml/components/VideoPlayer.qml | 24 ++++++- qml/components/VideoTrackSelector.qml | 1 + qml/components/videoplayer/VideoError.qml | 4 ++ qml/components/videoplayer/VideoHud.qml | 15 ++-- qml/pages/MainPage.qml | 2 +- qml/pages/VideoPage.qml | 2 + qml/pages/itemdetails/EpisodePage.qml | 12 ++-- src/jellyfinmediasource.cpp | 87 ++++++++++++++++++++--- src/jellyfinmediasource.h | 36 +++++++++- translations/harbour-sailfin.ts | 9 +++ 13 files changed, 195 insertions(+), 27 deletions(-) diff --git a/qml/Utils.js b/qml/Utils.js index c709f61..993c000 100644 --- a/qml/Utils.js +++ b/qml/Utils.js @@ -33,6 +33,14 @@ function timeToText(time) { return hours + ":" + (minutes < 10 ? "0" : "") + minutes + ":" + (seconds < 10 ? "0" : "")+ seconds } +function msToTicks(ms) { + return ms * 10000; +} + +function ticksToMs(ticks) { + return ticks / 10000; +} + function ticksToText(ticks) { return timeToText(ticks / 10000); } diff --git a/qml/components/GlassyBackground.qml b/qml/components/GlassyBackground.qml index 8d480e4..93fccd5 100644 --- a/qml/components/GlassyBackground.qml +++ b/qml/components/GlassyBackground.qml @@ -54,7 +54,7 @@ Rectangle { anchors.fill: backgroundImage source: backgroundImage opacity: dimmedOpacity - radius: 50 + radius: 100 } Image { diff --git a/qml/components/PlayToolbar.qml b/qml/components/PlayToolbar.qml index d1b6e1f..ae44a3a 100644 --- a/qml/components/PlayToolbar.qml +++ b/qml/components/PlayToolbar.qml @@ -23,7 +23,8 @@ import Sailfish.Silica 1.0 Column { property alias imageSource : playImage.source property real imageAspectRatio: 1.0 - signal playPressed() + property real playProgress: 0.0 + signal playPressed(bool startFromBeginning) spacing: Theme.paddingLarge BackgroundItem { @@ -42,7 +43,16 @@ Column { anchors.centerIn: parent highlighted: parent.highlighted } - onClicked: playPressed() + Rectangle { + anchors { + left: parent.left + bottom: parent.bottom + } + height: Theme.paddingMedium + color: Theme.highlightColor + width: parent.width * playProgress + } + onClicked: playPressed(false) } Row { anchors { @@ -52,6 +62,12 @@ Column { rightMargin: Theme.horizontalPageMargin } spacing: Theme.paddingMedium + IconButton { + id: playFromBeginning + icon.source: "image://theme/icon-m-backup" + visible: playProgress > 0 + onClicked: playPressed(true) + } IconButton { id: favouriteButton icon.source: "image://theme/icon-m-favorite" diff --git a/qml/components/VideoPlayer.qml b/qml/components/VideoPlayer.qml index 2e2e579..ff9a26c 100644 --- a/qml/components/VideoPlayer.qml +++ b/qml/components/VideoPlayer.qml @@ -23,6 +23,7 @@ import Sailfish.Silica 1.0 import nl.netsoj.chris.Jellyfin 1.0 import "videoplayer" +import "../" /** * A videoPlayer for Jellyfin videos @@ -38,6 +39,7 @@ SilicaItem { readonly property bool hudVisible: !hud.hidden || player.error !== MediaPlayer.NoError property alias audioTrack: mediaSource.audioIndex property alias subtitleTrack: mediaSource.subtitleIndex + property int startTicks: 0 // Force a Light on Dark theme since I doubt that there are persons who are willing to watch a Video // on a white background. @@ -49,7 +51,6 @@ SilicaItem { color: "black" } - MediaSource { id: mediaSource apiClient: ApiClient @@ -59,11 +60,16 @@ SilicaItem { onStreamUrlChanged: { if (mediaSource.streamUrl != "") { player.source = streamUrl - //mediaPlayer.play() } } } + Connections { + target: player + onPlaybackStateChanged: mediaSource.state = player.playbackState + onPositionChanged: mediaSource.position = Utils.msToTicks(player.position) + } + VideoOutput { id: videoOutput @@ -99,6 +105,18 @@ SilicaItem { function stop() { player.stop() - player.source = "" + //player.source = "" + } + + Connections { + id: playerReadyToSeek + target: player + onPlaybackStateChanged: { + if (startTicks > 0 && player.playbackState == MediaPlayer.PlayingState) { + console.log("Seeking to " + Utils.ticksToMs(startTicks)) + player.seek(Utils.ticksToMs(startTicks)) + playerReadyToSeek.enabled = false // Only seek the first time this property changes + } + } } } diff --git a/qml/components/VideoTrackSelector.qml b/qml/components/VideoTrackSelector.qml index 64e6eb9..ca9eff4 100644 --- a/qml/components/VideoTrackSelector.qml +++ b/qml/components/VideoTrackSelector.qml @@ -70,6 +70,7 @@ Column { onTracksChanged: { audioModel.clear() subtitleModel.clear() + if (typeof tracks === "undefined") return for(var i = 0; i < tracks.length; i++) { var track = tracks[i]; switch(track.Type) { diff --git a/qml/components/videoplayer/VideoError.qml b/qml/components/videoplayer/VideoError.qml index 978c073..3214116 100644 --- a/qml/components/videoplayer/VideoError.qml +++ b/qml/components/videoplayer/VideoError.qml @@ -41,6 +41,10 @@ Rectangle { color: Theme.errorColor text: { switch(player.error) { + case MediaPlayer.NoError: + //: Just to be complete if the application shows a video playback error when there's no error. + qsTr("No error"); + break; case MediaPlayer.ResourceError: //: Video playback error: out of resources qsTr("Resource allocation error") diff --git a/qml/components/videoplayer/VideoHud.qml b/qml/components/videoplayer/VideoHud.qml index 338f704..8ea41f8 100644 --- a/qml/components/videoplayer/VideoHud.qml +++ b/qml/components/videoplayer/VideoHud.qml @@ -65,7 +65,11 @@ Item { id: wakeupArea enabled: true anchors.fill: parent - onClicked: hidden ? videoHud.show(true) : videoHud.hide(true) + onClicked: { + hidden ? videoHud.show(true) : videoHud.hide(true) + console.log("Trying") + } + } BusyIndicator { @@ -156,18 +160,21 @@ Item { } function show(manual) { + _manuallyActivated = manual if (manual) { - _manuallyActivated = true inactivityTimer.restart() } else { - _manuallyActivated = false + inactivityTimer.stop() } opacity = 1 } function hide(manual) { // Don't hide if the user decided on their own to show the hud - if (!manual && _manuallyActivated) return; + //if (!manual && _manuallyActivated) return; + // Don't give in to the user if they want to hide the hud while it was forced upon them + /*if (!_manuallyActivated && manual) return; + _manuallyActivated = false;*/ opacity = 0 } diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index dc3bf6c..6f4d872 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -199,7 +199,7 @@ Page { + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] : ""*/ landscape: !Utils.usePortraitCover(model.type) - progress: model.userData.PlayedPercentage / 100 + progress: (typeof model.userData !== "undefined") ? model.userData.PlayedPercentage / 100 : 0.0 onClicked: { pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id}) diff --git a/qml/pages/VideoPage.qml b/qml/pages/VideoPage.qml index ff6f9df..7d321f4 100644 --- a/qml/pages/VideoPage.qml +++ b/qml/pages/VideoPage.qml @@ -33,6 +33,7 @@ Page { property var itemData property int audioTrack property int subtitleTrack + property int startTicks: 0 allowedOrientations: Orientation.All showNavigationIndicator: videoPlayer.hudVisible @@ -45,6 +46,7 @@ Page { title: itemData.Name audioTrack: videoPage.audioTrack subtitleTrack: videoPage.subtitleTrack + startTicks: videoPage.startTicks onLandscapeChanged: { console.log("Is landscape: " + landscape) diff --git a/qml/pages/itemdetails/EpisodePage.qml b/qml/pages/itemdetails/EpisodePage.qml index ade3cb0..4b68575 100644 --- a/qml/pages/itemdetails/EpisodePage.qml +++ b/qml/pages/itemdetails/EpisodePage.qml @@ -47,10 +47,14 @@ BaseDetailPage { PlayToolbar { imageSource: Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width}) - imageAspectRatio: itemData.PrimaryImageAspectRatio + imageAspectRatio: itemData.PrimaryImageAspectRatio || 1.0 + playProgress: itemData.UserData.PlayedPercentage / 100 onPlayPressed: pageStack.push(Qt.resolvedUrl("../VideoPage.qml"), - {"itemId": itemId, "itemData": itemData, "audioTrack": trackSelector.audioTrack, - "subtitleTrack": trackSelector.subtitleTrack }) + {"itemId": itemId, "itemData": itemData, + "audioTrack": trackSelector.audioTrack, + "subtitleTrack": trackSelector.subtitleTrack, + "startTicks": startFromBeginning ? 0.0 + : itemData.UserData.PlaybackPositionTicks }) width: parent.width } @@ -66,7 +70,7 @@ BaseDetailPage { PlainLabel { id: overviewText - text: itemData.Overview + text: itemData.Overview || qsTr("No overview available") font.pixelSize: Theme.fontSizeSmall color: Theme.secondaryHighlightColor } diff --git a/src/jellyfinmediasource.cpp b/src/jellyfinmediasource.cpp index cf2e9f4..8bc3027 100644 --- a/src/jellyfinmediasource.cpp +++ b/src/jellyfinmediasource.cpp @@ -23,13 +23,15 @@ namespace Jellyfin { MediaSource::MediaSource(QObject *parent) : QObject(parent) { - + m_updateTimer.setInterval(10000); // 10 seconds + m_updateTimer.setSingleShot(false); + connect(&m_updateTimer, &QTimer::timeout, this, &MediaSource::updatePlaybackInfo); } void MediaSource::fetchStreamUrl() { QUrlQuery params; params.addQueryItem("UserId", m_apiClient->userId()); - params.addQueryItem("StartTimeTicks", "0"); + params.addQueryItem("StartTimeTicks", QString::number(m_position)); params.addQueryItem("IsPlayback", "true"); params.addQueryItem("AutoOpenLiveStream", this->m_autoOpen ? "true" : "false"); params.addQueryItem("MediaSourceId", this->m_itemId); @@ -51,7 +53,7 @@ void MediaSource::fetchStreamUrl() { this->m_streamUrl = this->m_apiClient->baseUrl() + mediaSources[0].toObject()["TranscodingUrl"].toString(); - + this->m_playMethod = Transcode; emit this->streamUrlChanged(this->m_streamUrl); qDebug() << "Found stream url: " << this->m_streamUrl; } @@ -79,16 +81,83 @@ void MediaSource::setStreamUrl(const QString &streamUrl) { emit streamUrlChanged(streamUrl); } -void MediaSource::play() { - //todo: playback reporting +void MediaSource::setPosition(qint64 position) { + if (position == 0 && m_position != 0) { + // Save the old position when stop gets called. The QMediaPlayer will try to set + // position to 0 when stopped, but we don't want to report that to Jellyfin. We + // want the old position. + m_stopPosition = m_position; + } + m_position = position; + emit positionChanged(position); } -void MediaSource::pause() { - //todo: playback reporting +void MediaSource::setState(QMediaPlayer::State newState) { + if (m_state == newState) return; + if (m_state == QMediaPlayer::StoppedState) { + // We're transitioning from stopped to either playing or paused. + // Set up the recurring timer + m_updateTimer.start(); + postPlaybackInfo(Started); + } else if (newState == QMediaPlayer::StoppedState) { + // We've stopped playing the media. Post a stop signal. + m_updateTimer.stop(); + postPlaybackInfo(Stopped); + } else { + postPlaybackInfo(Progress); + } + + + m_state = newState; + emit this->stateChanged(newState); } -void MediaSource::stop() { - //todo: playback reporting +void MediaSource::updatePlaybackInfo() { + postPlaybackInfo(Progress); +} + +void MediaSource::postPlaybackInfo(PlaybackInfoType type) { + QJsonObject root; + + root["ItemId"] = m_itemId; + root["SessionId"] = m_playSessionId; + + switch(type) { + case Started: // FALLTHROUGH + case Progress: + + root["IsPaused"] = m_state != QMediaPlayer::PlayingState; + root["IsMuted"] = false; + + root["AudioStreamIndex"] = m_audioIndex; + root["SubtitleStreamIndex"] = m_subtitleIndex; + + root["PlayMethod"] = QVariant::fromValue(m_playMethod).toString(); + root["PositionTicks"] = m_position; + break; + case Stopped: + root["PositionTicks"] = m_stopPosition; + break; + } + + QString path; + switch (type) { + case Started: + path = "/Sessions/Playing"; + break; + case Progress: + path = "/Sessions/Playing/Progress"; + break; + case Stopped: + path = "/Sessions/Playing/Stopped"; + break; + } + + QNetworkReply *rep = m_apiClient->post(path, QJsonDocument(root)); + connect(rep, &QNetworkReply::finished, this, [rep](){ + rep->deleteLater(); + }); + m_apiClient->setDefaultErrorHandler(rep); } } diff --git a/src/jellyfinmediasource.h b/src/jellyfinmediasource.h index 2a483bb..8761c15 100644 --- a/src/jellyfinmediasource.h +++ b/src/jellyfinmediasource.h @@ -23,6 +23,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include #include #include +#include #include @@ -36,6 +37,13 @@ namespace Jellyfin { class MediaSource : public QObject { Q_OBJECT public: + enum PlayMethod { + Transcode, + Stream, + DirectPlay + }; + Q_ENUM(PlayMethod) + explicit MediaSource(QObject *parent = nullptr); Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) Q_PROPERTY(QString itemId READ itemId WRITE setItemId NOTIFY itemIdChanged) @@ -43,10 +51,17 @@ public: Q_PROPERTY(bool autoOpen MEMBER m_autoOpen NOTIFY autoOpenChanged) Q_PROPERTY(int audioIndex MEMBER m_audioIndex NOTIFY audioIndexChanged) Q_PROPERTY(int subtitleIndex MEMBER m_subtitleIndex NOTIFY subtitleIndexChanged) + Q_PROPERTY(qint64 position MEMBER m_position WRITE setPosition NOTIFY positionChanged) + Q_PROPERTY(QMediaPlayer::State state READ state WRITE setState NOTIFY stateChanged) QString itemId() const { return m_itemId; } void setItemId(const QString &newItemId); + QMediaPlayer::State state() const { return m_state; } + void setState(QMediaPlayer::State newState); + + void setPosition(qint64 position); + QString streamUrl() const { return m_streamUrl; } signals: void itemIdChanged(const QString &newItemId); @@ -54,19 +69,24 @@ signals: void autoOpenChanged(bool autoOpen); void audioIndexChanged(int audioIndex); void subtitleIndexChanged(int subtitleIndex); + void positionChanged(qint64 position); + void stateChanged(QMediaPlayer::State state); public slots: - void play(); - void pause(); - void stop(); + void updatePlaybackInfo(); private: + QTimer m_updateTimer; ApiClient *m_apiClient = nullptr; QString m_itemId; QString m_streamUrl; QString m_playSessionId; int m_audioIndex = 0; int m_subtitleIndex = -1; + qint64 m_position = 0; + qint64 m_stopPosition = 0; + PlayMethod m_playMethod; + QMediaPlayer::State m_state = QMediaPlayer::StoppedState; /** * @brief Whether to automatically open the livestream of the item; @@ -75,6 +95,16 @@ private: void fetchStreamUrl(); void setStreamUrl(const QString &streamUrl); + + // Factor to multiply with when converting from milliseconds to ticks. + const int MS_TICK_FACTOR = 10000; + + enum PlaybackInfoType { Started, Stopped, Progress }; + + /** + * @brief Posts the playback information + */ + void postPlaybackInfo(PlaybackInfoType type); }; } diff --git a/translations/harbour-sailfin.ts b/translations/harbour-sailfin.ts index 34c9d59..86461c8 100644 --- a/translations/harbour-sailfin.ts +++ b/translations/harbour-sailfin.ts @@ -125,6 +125,10 @@ Overview + + No overview available + + FilmPage @@ -318,6 +322,11 @@ Button to retry loading a video after a failure + + No error + Just to be complete if the application shows a video playback error when there's no error. + + VideoPage