From a244c27b1a43595fd4ce1921b0e46adec30bd00b Mon Sep 17 00:00:00 2001 From: Chris Josten Date: Sun, 14 Feb 2021 00:21:49 +0100 Subject: [PATCH] Move playback logic to C++ side --- core/include/JellyfinQt/jellyfinapiclient.h | 3 + .../JellyfinQt/jellyfinplaybackmanager.h | 64 ++++++++--- core/src/jellyfinplaybackmanager.cpp | 101 ++++++++++++++---- sailfish/qml/components/PlayToolbar.qml | 6 +- sailfish/qml/components/VideoPlayer.qml | 39 ++----- sailfish/qml/pages/VideoPage.qml | 11 +- .../qml/pages/itemdetails/BaseDetailPage.qml | 2 +- sailfish/qml/pages/itemdetails/VideoPage.qml | 5 +- 8 files changed, 147 insertions(+), 84 deletions(-) diff --git a/core/include/JellyfinQt/jellyfinapiclient.h b/core/include/JellyfinQt/jellyfinapiclient.h index 1a6c4da..b25c89b 100644 --- a/core/include/JellyfinQt/jellyfinapiclient.h +++ b/core/include/JellyfinQt/jellyfinapiclient.h @@ -39,11 +39,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include "credentialmanager.h" #include "jellyfindeviceprofile.h" #include "jellyfinitem.h" +#include "jellyfinplaybackmanager.h" #include "jellyfinwebsocket.h" namespace Jellyfin { class MediaSource; class WebSocket; +class PlaybackManager; /** * @brief An Api client for Jellyfin. Handles requests and authentication. * @@ -71,6 +73,7 @@ class WebSocket; */ class ApiClient : public QObject { friend class WebSocket; + friend class PlaybackManager; Q_OBJECT public: explicit ApiClient(QObject *parent = nullptr); diff --git a/core/include/JellyfinQt/jellyfinplaybackmanager.h b/core/include/JellyfinQt/jellyfinplaybackmanager.h index 04f9f82..3ad021a 100644 --- a/core/include/JellyfinQt/jellyfinplaybackmanager.h +++ b/core/include/JellyfinQt/jellyfinplaybackmanager.h @@ -23,6 +23,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include #include #include +#include #include #include @@ -31,11 +32,22 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include "jellyfinapiclient.h" +#include "jellyfinitem.h" namespace Jellyfin { -class PlaybackManager : public QObject { +// Forward declaration of Jellyfin::Item found in jellyfinitem.h +class Item; +// Forward declaration of Jellyfin::ApiClient found in jellyfinapiclient.h +class ApiClient; + +/** + * @brief The PlaybackManager class manages the playback of Jellyfin items. It fetches streams based on Jellyfin items, posts + * the current playback state to the Jellyfin Server and so on. + */ +class PlaybackManager : public QObject, public QQmlParserStatus { Q_OBJECT + Q_INTERFACES(QQmlParserStatus) public: enum PlayMethod { Transcode, @@ -46,58 +58,71 @@ public: explicit PlaybackManager(QObject *parent = nullptr); Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) - Q_PROPERTY(QString itemId READ itemId WRITE setItemId NOTIFY itemIdChanged) + Q_PROPERTY(Item *item READ item WRITE setItem NOTIFY itemChanged) Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY streamUrlChanged) 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) + Q_PROPERTY(bool resumePlayback MEMBER m_resumePlayback NOTIFY resumePlaybackChanged) + Q_PROPERTY(QObject* mediaPlayer READ mediaPlayer WRITE setMediaPlayer NOTIFY mediaPlayerChanged) - QString itemId() const { return m_itemId; } - void setItemId(const QString &newItemId); + Item *item() const { return m_item; } + void setItem(Item *newItem); - QMediaPlayer::State state() const { return m_state; } - void setState(QMediaPlayer::State newState); - - void setPosition(qint64 position); + QObject *mediaPlayer() const { + return m_qmlMediaPlayer; + } + void setMediaPlayer(QObject *qmlMediaPlayer); QString streamUrl() const { return m_streamUrl; } signals: - void itemIdChanged(const QString &newItemId); + void itemChanged(Item *newItemId); void streamUrlChanged(const QString &newStreamUrl); void autoOpenChanged(bool autoOpen); void audioIndexChanged(int audioIndex); void subtitleIndexChanged(int subtitleIndex); - void positionChanged(qint64 position); - void stateChanged(QMediaPlayer::State state); + void mediaPlayerChanged(QObject *newMediaPlayer); + void resumePlaybackChanged(bool newResumePlayback); public slots: void updatePlaybackInfo(); +private slots: + void mediaPlayerStateChanged(QMediaPlayer::State newState); + void mediaPlayerPositionChanged(qint64 position); + void mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus); private: QTimer m_updateTimer; ApiClient *m_apiClient = nullptr; - QString m_itemId; + Item *m_item = nullptr; QString m_streamUrl; QString m_playSessionId; int m_audioIndex = 0; int m_subtitleIndex = -1; - qint64 m_position = 0; + qint64 m_resumePosition = 0; + qint64 m_oldPosition = 0; qint64 m_stopPosition = 0; + QMediaPlayer::State m_oldState = QMediaPlayer::StoppedState; PlayMethod m_playMethod; - QMediaPlayer::State m_state = QMediaPlayer::StoppedState; + QObject *m_qmlMediaPlayer = nullptr; + QMediaPlayer * m_mediaPlayer = nullptr; + bool m_resumePlayback = true; + + bool m_qmlIsParsingComponent = false; /** * @brief Whether to automatically open the livestream of the item; */ bool m_autoOpen = false; + /** + * @brief Retrieves the URL of the stream to open. + */ void fetchStreamUrl(); void setStreamUrl(const QString &streamUrl); // Factor to multiply with when converting from milliseconds to ticks. - const int MS_TICK_FACTOR = 10000; + const static int MS_TICK_FACTOR = 10000; enum PlaybackInfoType { Started, Stopped, Progress }; @@ -105,6 +130,11 @@ private: * @brief Posts the playback information */ void postPlaybackInfo(PlaybackInfoType type); + + void classBegin() override { + m_qmlIsParsingComponent = true; + } + void componentComplete() override; }; } diff --git a/core/src/jellyfinplaybackmanager.cpp b/core/src/jellyfinplaybackmanager.cpp index 2e852fe..1b62d34 100644 --- a/core/src/jellyfinplaybackmanager.cpp +++ b/core/src/jellyfinplaybackmanager.cpp @@ -29,19 +29,27 @@ PlaybackManager::PlaybackManager(QObject *parent) } void PlaybackManager::fetchStreamUrl() { + if (m_item == nullptr || m_apiClient == nullptr) return; + m_resumePosition = 0; + if (m_resumePlayback && !m_item->property("userData").isNull()) { + UserData* userData = qvariant_cast(m_item->property("userData")); + if (userData != nullptr) { + m_resumePosition = userData->playbackPositionTicks(); + } + } QUrlQuery params; params.addQueryItem("UserId", m_apiClient->userId()); - params.addQueryItem("StartTimeTicks", QString::number(m_position)); + params.addQueryItem("StartTimeTicks", QString::number(m_resumePosition)); params.addQueryItem("IsPlayback", "true"); params.addQueryItem("AutoOpenLiveStream", this->m_autoOpen ? "true" : "false"); - params.addQueryItem("MediaSourceId", this->m_itemId); + params.addQueryItem("MediaSourceId", this->m_item->jellyfinId()); params.addQueryItem("SubtitleStreamIndex", QString::number(m_subtitleIndex)); params.addQueryItem("AudioStreamIndex", QString::number(m_audioIndex)); QJsonObject root; root["DeviceProfile"] = m_apiClient->playbackDeviceProfile(); - QNetworkReply *rep = m_apiClient->post("/Items/" + this->m_itemId + "/PlaybackInfo", QJsonDocument(root), params); + QNetworkReply *rep = m_apiClient->post("/Items/" + this->m_item->jellyfinId() + "/PlaybackInfo", QJsonDocument(root), params); connect(rep, &QNetworkReply::finished, this, [this, rep]() { QJsonObject root = QJsonDocument::fromJson(rep->readAll()).object(); this->m_playSessionId = root["PlaySessionId"].toString(); @@ -50,11 +58,11 @@ void PlaybackManager::fetchStreamUrl() { if (this->m_autoOpen) { QJsonArray mediaSources = root["MediaSources"].toArray(); //FIXME: relies on the fact that the returned transcode url always has a query! - this->m_streamUrl = this->m_apiClient->baseUrl() + QString streamUrl = this->m_apiClient->baseUrl() + mediaSources[0].toObject()["TranscodingUrl"].toString(); this->m_playMethod = Transcode; - emit this->streamUrlChanged(this->m_streamUrl); + setStreamUrl(streamUrl); qDebug() << "Found stream url: " << this->m_streamUrl; } @@ -62,39 +70,46 @@ void PlaybackManager::fetchStreamUrl() { }); } -void PlaybackManager::setItemId(const QString &newItemId) { +void PlaybackManager::setItem(Item *newItem) { + this->m_item = newItem; + // Don't try to start fetching when we're not completely parsed yet. + if (m_qmlIsParsingComponent) return; + if (m_apiClient == nullptr) { qWarning() << "apiClient is not set on this MediaSource instance! Aborting."; return; } - - this->m_itemId = newItemId; // Deinitialize the streamUrl setStreamUrl(""); - if (!newItemId.isEmpty()) { + if (newItem != nullptr && !newItem->jellyfinId().isEmpty()) { fetchStreamUrl(); } } void PlaybackManager::setStreamUrl(const QString &streamUrl) { this->m_streamUrl = streamUrl; + // Inspired by PHP naming schemes + QUrl realStreamUrl(streamUrl); + Q_ASSERT_X(realStreamUrl.isValid(), "setStreamUrl", "StreamURL Jellyfin returned is not valid"); + if (m_mediaPlayer != nullptr) { + m_mediaPlayer->setMedia(QMediaContent(realStreamUrl)); + } emit streamUrlChanged(streamUrl); } -void PlaybackManager::setPosition(qint64 position) { - if (position == 0 && m_position != 0) { +void PlaybackManager::mediaPlayerPositionChanged(qint64 position) { + if (position == 0 && m_oldPosition != 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_stopPosition = m_oldPosition; } - m_position = position; - emit positionChanged(position); + m_oldPosition = position; } -void PlaybackManager::setState(QMediaPlayer::State newState) { - if (m_state == newState) return; - if (m_state == QMediaPlayer::StoppedState) { +void PlaybackManager::mediaPlayerStateChanged(QMediaPlayer::State newState) { + if (m_oldState == newState) return; + if (m_oldState == QMediaPlayer::StoppedState) { // We're transitioning from stopped to either playing or paused. // Set up the recurring timer m_updateTimer.start(); @@ -106,10 +121,37 @@ void PlaybackManager::setState(QMediaPlayer::State newState) { } else { postPlaybackInfo(Progress); } + m_oldState = newState; +} +void PlaybackManager::mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus) { + if (newStatus == QMediaPlayer::LoadedMedia) { + m_mediaPlayer->play(); + if (m_resumePlayback) { + qDebug() << "Resuming playback by seeking to " << (m_resumePosition / MS_TICK_FACTOR); + m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); + } + } +} - m_state = newState; - emit this->stateChanged(newState); +void PlaybackManager::setMediaPlayer(QObject *qmlMediaPlayer) { + if (m_mediaPlayer != nullptr) { + // Clean up the old media player. + disconnect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); + } + + m_qmlMediaPlayer = qmlMediaPlayer; + if (qmlMediaPlayer != nullptr) { + m_mediaPlayer = qvariant_cast(qmlMediaPlayer->property("mediaObject")); + Q_ASSERT_X(m_mediaPlayer != nullptr, "setMediaPlayer", "The mediaPlayer property must contain a qml MediaPlayer with the mediaObject property"); + + // Connect signals from the new media player + connect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); + connect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); + connect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); + } } void PlaybackManager::updatePlaybackInfo() { @@ -119,24 +161,24 @@ void PlaybackManager::updatePlaybackInfo() { void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) { QJsonObject root; - root["ItemId"] = m_itemId; + root["ItemId"] = m_item->jellyfinId(); root["SessionId"] = m_playSessionId; switch(type) { case Started: // FALLTHROUGH case Progress: - root["IsPaused"] = m_state != QMediaPlayer::PlayingState; + root["IsPaused"] = m_mediaPlayer->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; + root["PositionTicks"] = m_mediaPlayer->position() * MS_TICK_FACTOR; break; case Stopped: - root["PositionTicks"] = m_stopPosition; + root["PositionTicks"] = m_stopPosition * MS_TICK_FACTOR; break; } @@ -160,4 +202,17 @@ void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) { m_apiClient->setDefaultErrorHandler(rep); } +void PlaybackManager::componentComplete() { + if (m_apiClient == nullptr) qWarning() << "No ApiClient set for PlaybackManager"; + if (m_item != nullptr) { + if (m_item->status() == RemoteData::Ready) { + fetchStreamUrl(); + } else { + connect(m_item, &RemoteData::ready, [this]() -> void { + fetchStreamUrl(); + }); + } + } +} + } diff --git a/sailfish/qml/components/PlayToolbar.qml b/sailfish/qml/components/PlayToolbar.qml index 0b6fac1..c605d79 100644 --- a/sailfish/qml/components/PlayToolbar.qml +++ b/sailfish/qml/components/PlayToolbar.qml @@ -26,7 +26,7 @@ Column { property real playProgress: 0.0 property bool favourited: false property alias imageBlurhash: playImage.blurhash - signal playPressed(bool startFromBeginning) + signal playPressed(bool resume) spacing: Theme.paddingLarge BackgroundItem { @@ -57,7 +57,7 @@ Column { color: Theme.highlightColor width: parent.width * playProgress } - onClicked: playPressed(false) + onClicked: playPressed(true) } Row { anchors { @@ -71,7 +71,7 @@ Column { id: playFromBeginning icon.source: "image://theme/icon-m-backup" visible: playProgress > 0 - onClicked: playPressed(true) + onClicked: playPressed(false) } IconButton { id: favouriteButton diff --git a/sailfish/qml/components/VideoPlayer.qml b/sailfish/qml/components/VideoPlayer.qml index 146bda4..4c2f1e3 100644 --- a/sailfish/qml/components/VideoPlayer.qml +++ b/sailfish/qml/components/VideoPlayer.qml @@ -31,15 +31,15 @@ import "../" SilicaItem { id: playerRoot - property string itemId - property string title + property alias item : mediaSource.item + property string title: item.name + property alias resume: mediaSource.resumePlayback property int progress readonly property bool landscape: videoOutput.contentRect.width > videoOutput.contentRect.height property MediaPlayer player readonly property bool hudVisible: !hud.hidden || player.error !== MediaPlayer.NoError property alias audioTrack: mediaSource.audioIndex property alias subtitleTrack: mediaSource.subtitleIndex - property real startTicks: 0 // Blackground to prevent the ambience from leaking through Rectangle { @@ -50,22 +50,10 @@ SilicaItem { PlaybackManager { id: mediaSource apiClient: ApiClient - itemId: playerRoot.itemId + mediaPlayer: player autoOpen: true - onStreamUrlChanged: { - if (mediaSource.streamUrl != "") { - player.source = streamUrl - } - } } - Connections { - target: player - onPlaybackStateChanged: mediaSource.state = player.playbackState - onPositionChanged: mediaSource.position = Utils.msToTicks(player.position) - } - - VideoOutput { id: videoOutput source: player @@ -81,7 +69,8 @@ SilicaItem { Label { anchors.fill: parent anchors.margins: Theme.horizontalPageMargin - text: itemId + "\n" + mediaSource.streamUrl + "\n" + text: item.jellyfinId + "\n" + mediaSource.streamUrl + "\n" + + player.position + "\n" + player.status + "\n" + player.bufferProgress + "\n" + player.metaData.videoCodec + "@" + player.metaData.videoFrameRate + "(" + player.metaData.videoBitRate + ")" + "\n" @@ -89,7 +78,7 @@ SilicaItem { + player.errorString + "\n" font.pixelSize: Theme.fontSizeExtraSmall wrapMode: "WordWrap" - visible: false + visible: true } } @@ -101,18 +90,4 @@ SilicaItem { function stop() { player.stop() } - - Connections { - property bool enabled: true - id: playerReadyToSeek - target: player - onPlaybackStateChanged: { - if (!enabled) return; - 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/sailfish/qml/pages/VideoPage.qml b/sailfish/qml/pages/VideoPage.qml index 8f1d8bc..74529a7 100644 --- a/sailfish/qml/pages/VideoPage.qml +++ b/sailfish/qml/pages/VideoPage.qml @@ -21,6 +21,8 @@ import Sailfish.Silica 1.0 import "../components" +import nl.netsoj.chris.Jellyfin 1.0 + /** * Page only containing a video player. * @@ -29,11 +31,10 @@ import "../components" Page { id: videoPage - property string itemId - property var itemData + property JellyfinItem itemData property int audioTrack property int subtitleTrack - property real startTicks: 0 // Why is this a real? Because an integer only goes to 3:44 when the ticks are converted to doubles + property bool resume: true allowedOrientations: Orientation.All showNavigationIndicator: videoPlayer.hudVisible @@ -41,12 +42,12 @@ Page { VideoPlayer { id: videoPlayer anchors.fill: parent - itemId: videoPage.itemId player: appWindow.mediaPlayer title: itemData.name audioTrack: videoPage.audioTrack subtitleTrack: videoPage.subtitleTrack - startTicks: videoPage.startTicks + resume: videoPage.resume + item: itemData onLandscapeChanged: { console.log("Is landscape: " + landscape) diff --git a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml index efc0e99..6be2fbb 100644 --- a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml +++ b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml @@ -62,7 +62,7 @@ Page { id: backdropBackground ThemeBackground { sourceItem: backdrop - backgroundMaterial: Materials.blur + backgroundMaterial: "blur" } } diff --git a/sailfish/qml/pages/itemdetails/VideoPage.qml b/sailfish/qml/pages/itemdetails/VideoPage.qml index 0d5af89..bf2f0eb 100644 --- a/sailfish/qml/pages/itemdetails/VideoPage.qml +++ b/sailfish/qml/pages/itemdetails/VideoPage.qml @@ -60,11 +60,10 @@ BaseDetailPage { favourited: itemData.userData.isFavorite playProgress: itemData.userData.playedPercentage / 100 onPlayPressed: pageStack.push(Qt.resolvedUrl("../VideoPage.qml"), - {"itemId": itemId, "itemData": itemData, + {"itemData": itemData, "audioTrack": trackSelector.audioTrack, "subtitleTrack": trackSelector.subtitleTrack, - "startTicks": startFromBeginning ? 0.0 - : _playbackProsition }) + "resume": resume}) } VideoTrackSelector {