From 92a18c4fa516f7618e2c40cd8d5030194e2f96c8 Mon Sep 17 00:00:00 2001 From: Chris Josten Date: Fri, 25 Sep 2020 14:46:39 +0200 Subject: [PATCH] Added videoplayer and many unrelated things --- harbour-sailfin.pro | 21 ++- qml/components/MoreSection.qml | 2 +- qml/components/RemoteImage.qml | 4 +- qml/components/VideoPlayer.qml | 64 +++++++ qml/components/itemdetails/FilmDetails.qml | 5 + qml/components/itemdetails/SeriesDetails.qml | 5 + qml/components/videoplayer/VideoHud.qml | 166 +++++++++++++++++ qml/cover/PosterCover.qml | 18 ++ qml/cover/VideoCover.qml | 28 +++ qml/harbour-sailfin.qml | 24 ++- .../{DetailBasePage.qml => DetailPage.qml} | 6 +- qml/pages/MainPage.qml | 7 +- qml/pages/VideoPage.qml | 37 ++++ .../{ => setup}/AddServerConnectingPage.qml | 0 qml/pages/{ => setup}/AddServerPage.qml | 0 qml/pages/{ => setup}/LoginDialog.qml | 4 +- qml/pages/setup/a | 0 src/harbour-sailfin.cpp | 9 +- src/jellyfinapiclient.cpp | 83 +++++++-- src/jellyfinapiclient.h | 95 +++++++++- src/jellyfinapimodel.cpp | 2 +- src/jellyfinapimodel.h | 4 +- src/jellyfindeviceprofile.cpp | 172 ++++++++++++++++++ src/jellyfindeviceprofile.h | 33 ++++ src/jellyfinmediasource.cpp | 84 +++++++++ src/jellyfinmediasource.h | 62 +++++++ src/serverdiscoverymodel.cpp | 2 + src/serverdiscoverymodel.h | 3 +- 28 files changed, 889 insertions(+), 51 deletions(-) create mode 100644 qml/components/VideoPlayer.qml create mode 100644 qml/components/itemdetails/FilmDetails.qml create mode 100644 qml/components/itemdetails/SeriesDetails.qml create mode 100644 qml/components/videoplayer/VideoHud.qml create mode 100644 qml/cover/PosterCover.qml create mode 100644 qml/cover/VideoCover.qml rename qml/pages/{DetailBasePage.qml => DetailPage.qml} (93%) create mode 100644 qml/pages/VideoPage.qml rename qml/pages/{ => setup}/AddServerConnectingPage.qml (100%) rename qml/pages/{ => setup}/AddServerPage.qml (100%) rename qml/pages/{ => setup}/LoginDialog.qml (94%) create mode 100644 qml/pages/setup/a create mode 100644 src/jellyfindeviceprofile.cpp create mode 100644 src/jellyfindeviceprofile.h create mode 100644 src/jellyfinmediasource.cpp create mode 100644 src/jellyfinmediasource.h diff --git a/harbour-sailfin.pro b/harbour-sailfin.pro index 60f1b93..4da3673 100644 --- a/harbour-sailfin.pro +++ b/harbour-sailfin.pro @@ -12,6 +12,7 @@ # The name of your application TARGET = harbour-sailfin +QT += multimedia CONFIG += sailfishapp c++11 @@ -20,6 +21,8 @@ SOURCES += \ src/harbour-sailfin.cpp \ src/jellyfinapiclient.cpp \ src/jellyfinapimodel.cpp \ + src/jellyfindeviceprofile.cpp \ + src/jellyfinmediasource.cpp \ src/serverdiscoverymodel.cpp DISTFILES += \ @@ -29,14 +32,22 @@ DISTFILES += \ qml/components/PlainLabel.qml \ qml/components/RemoteImage.qml \ qml/components/UserGridDelegate.qml \ + qml/components/VideoPlayer.qml \ + qml/components/itemdetails/FilmDetails.qml \ + qml/components/itemdetails/SeriesDetails.qml \ + qml/components/videoplayer/VideoHud.qml \ qml/cover/CoverPage.qml \ - qml/pages/AddServerConnectingPage.qml \ - qml/pages/DetailBasePage.qml \ + qml/cover/PosterCover.qml \ + qml/cover/VideoCover.qml \ + qml/pages/DetailPage.qml \ qml/pages/LegalPage.qml \ - qml/pages/LoginDialog.qml \ qml/pages/MainPage.qml \ qml/pages/SecondPages.qml \ - qml/harbour-sailfin.qml + qml/harbour-sailfin.qml \ + qml/pages/VideoPage.qml \ + qml/pages/setup/AddServerConnectingPage.qml \ + qml/pages/setup/LoginDialog.qml \ + qml/pages/setup/a SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172 @@ -54,4 +65,6 @@ CONFIG += sailfishapp_i18n src/credentialmanager.h \ src/jellyfinapiclient.h \ src/jellyfinapimodel.h \ + src/jellyfindeviceprofile.h \ + src/jellyfinmediasource.h \ src/serverdiscoverymodel.h diff --git a/qml/components/MoreSection.qml b/qml/components/MoreSection.qml index e757ae1..68a91b2 100644 --- a/qml/components/MoreSection.qml +++ b/qml/components/MoreSection.qml @@ -71,6 +71,6 @@ Item { right: parent.right } width: parent.width - height: children[0].height + height: children.length > 0 ? children[0].height : 0 } } diff --git a/qml/components/RemoteImage.qml b/qml/components/RemoteImage.qml index f9bb0bb..e3be379 100644 --- a/qml/components/RemoteImage.qml +++ b/qml/components/RemoteImage.qml @@ -17,13 +17,13 @@ Image { GradientStop { position: 0.0; color: Theme.highlightColor; } GradientStop { position: 1.0; color: Theme.highlightDimmerColor; } } - visible: parent.status == Image.Error + visible: parent.status == Image.Error || parent.status == Image.Null } Image { id: fallbackImageItem anchors.centerIn: parent - visible: parent.status == Image.Error + visible: parent.status == Image.Error || parent.status == Image.Null source: fallbackImage ? fallbackImage : "image://theme/icon-m-question" } } diff --git a/qml/components/VideoPlayer.qml b/qml/components/VideoPlayer.qml new file mode 100644 index 0000000..311fb18 --- /dev/null +++ b/qml/components/VideoPlayer.qml @@ -0,0 +1,64 @@ +import QtQuick 2.6 +import QtMultimedia 5.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 + +import "videoplayer" + +Item { + id: playerRoot + property string itemId + property string title + property int progress + readonly property bool landscape: videoOutput.contentRect.width > videoOutput.contentRect.height + property MediaPlayer player + readonly property bool hudVisible: !hud.hidden + + MediaSource { + id: mediaSource + apiClient: ApiClient + itemId: playerRoot.itemId + autoOpen: true + //autoPlay: true + onStreamUrlChanged: { + if (mediaSource.streamUrl != "") { + player.source = streamUrl + //mediaPlayer.play() + } + } + } + + + VideoOutput { + id: videoOutput + source: player + anchors.fill: parent + } + + VideoHud { + id: hud + anchors.fill: parent + player: playerRoot.player + title: videoPlayer.title + + Label { + anchors.fill: parent + anchors.margins: Theme.horizontalPageMargin + text: itemId + "\n" + mediaSource.streamUrl + "\n" + + player.status + "\n" + + player.bufferProgress + "\n" + + player.metaData.videoCodec + "@" + player.metaData.videoFrameRate + "(" + player.metaData.videoBitRate + ")" + "\n" + + player.metaData.audioCodec + "(" + player.metaData.audioBitRate + ")" + "\n" + + player.errorString + "\n" + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: "WordWrap" + visible: false + } + } + + function stop() { + player.stop() + player.source = "" + } +} diff --git a/qml/components/itemdetails/FilmDetails.qml b/qml/components/itemdetails/FilmDetails.qml new file mode 100644 index 0000000..9c36e13 --- /dev/null +++ b/qml/components/itemdetails/FilmDetails.qml @@ -0,0 +1,5 @@ +import QtQuick 2.0 + +Item { + +} diff --git a/qml/components/itemdetails/SeriesDetails.qml b/qml/components/itemdetails/SeriesDetails.qml new file mode 100644 index 0000000..9c36e13 --- /dev/null +++ b/qml/components/itemdetails/SeriesDetails.qml @@ -0,0 +1,5 @@ +import QtQuick 2.0 + +Item { + +} diff --git a/qml/components/videoplayer/VideoHud.qml b/qml/components/videoplayer/VideoHud.qml new file mode 100644 index 0000000..fa9bcdf --- /dev/null +++ b/qml/components/videoplayer/VideoHud.qml @@ -0,0 +1,166 @@ +import QtQuick 2.6 +import QtMultimedia 5.6 +import Sailfish.Silica 1.0 + +Item { + id: videoHud + property MediaPlayer player + property string title + property bool _manuallyActivated: false + readonly property bool hidden: opacity == 0.0 + + Behavior on opacity { FadeAnimator {} } + Rectangle { + id: topBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: pageTitle.height + + gradient: Gradient { + GradientStop { position: 1.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.15); } + GradientStop { position: 0.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.30); } + } + PageHeader { + id: pageTitle + title: videoHud.title + anchors.fill: parent + titleColor: palette.primaryColor + } + } + + Rectangle { + anchors.top: topBar.bottom + anchors.bottom: bottomBar.top + anchors.left: parent.left + anchors.right: parent.right + color: Theme.rgba(palette.overlayBackgroundColor, 0.15) + } + + MouseArea { + id: wakeupArea + enabled: true + anchors.fill: parent + onClicked: hidden ? videoHud.show(true) : videoHud.hide(true) + } + + BusyIndicator { + id: busyIndicator + anchors.centerIn: parent + size: BusyIndicatorSize.Medium + running: [MediaPlayer.Loading, MediaPlayer.Stalled].indexOf(player.status) >= 0 + } + + IconButton { + id: playPause + enabled: !hidden + anchors.centerIn: parent + icon.source: player.playbackState == MediaPlayer.PausedState ? "image://theme/icon-l-play" : "image://theme/icon-l-pause" + onClicked: { + if (player.playbackState == MediaPlayer.PlayingState) { + player.pause() + } else { + player.play() + } + } + visible: !busyIndicator.running + } + + Rectangle { + id: bottomBar + anchors.bottom: parent.bottom + width: parent.width + height: progress.height + visible: [MediaPlayer.Unavailable, MediaPlayer.Loading, MediaPlayer.NoMedia].indexOf(player.status) == -1 + + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.15); } + GradientStop { position: 1.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.30); } + } + + Item { + id: progress + height: progressSlider.height + 2 * Theme.paddingMedium + width: parent.width + + Label { + id: playedTime + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.verticalCenter: progressSlider.verticalCenter + text: timeToText(player.position) + } + + Slider { + id: progressSlider + enabled: player.seekable + value: player.position + maximumValue: player.duration + stepSize: 1000 + anchors.left: playedTime.right + anchors.right: totalTime.left + anchors.verticalCenter: parent.verticalCenter + onDownChanged: if (!down) { player.seek(value) } + } + + Label { + id: totalTime + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: progress.verticalCenter + text: timeToText(player.duration) + } + } + } + + function timeToText(time) { + if (time < 0) return "??:??:??" + var hours = Math.floor(time / (60 * 60 * 1000)) + var left = time % (60 * 60 * 1000) + var minutes = Math.floor(left / (60 * 1000)) + left = time % (60 * 1000) + var seconds = Math.floor(left / 1000) + + return hours + ":" + (minutes < 10 ? "0" : "") + minutes + ":" + (seconds < 10 ? "0" : "")+ seconds + } + + Connections { + target: player + onStatusChanged: { + console.log("New mediaPlayer status: " + player.status) + switch(player.status) { + case MediaPlayer.Loaded: + case MediaPlayer.Buffering: + show(false) + break; + case MediaPlayer.Buffered: + hide(false) + break; + } + } + } + + function show(manual) { + if (manual) { + _manuallyActivated = true + inactivityTimer.restart() + } else { + _manuallyActivated = false + } + opacity = 1 + } + + function hide(manual) { + // Don't hide if the user decided on their own to show the hud + if (!manual && _manuallyActivated) return; + opacity = 0 + } + + Timer { + id: inactivityTimer + interval: 5000 + onTriggered: { + hide(true) + } + } +} diff --git a/qml/cover/PosterCover.qml b/qml/cover/PosterCover.qml new file mode 100644 index 0000000..13b1843 --- /dev/null +++ b/qml/cover/PosterCover.qml @@ -0,0 +1,18 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 + +import "../components" + +CoverBackground { + property var mData: appWindow.itemData + RemoteImage { + anchors.fill: parent + source: mData.ImageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + mData.Id + + "/Images/Primary?maxHeight=" + height + "&tag=" + mData.ImageTags["Primary"] + : "" + fillMode: Image.PreserveAspectCrop + } + +} diff --git a/qml/cover/VideoCover.qml b/qml/cover/VideoCover.qml new file mode 100644 index 0000000..8530d9c --- /dev/null +++ b/qml/cover/VideoCover.qml @@ -0,0 +1,28 @@ +import QtQuick 2.6 +import QtMultimedia 5.6 +import Sailfish.Silica 1.0 + +CoverBackground { + readonly property MediaPlayer player: appWindow.mediaPlayer + + Rectangle { + anchors.fill: parent + color: "black" + + /*VideoOutput { + id: coverOutput + anchors.fill: parent + source: player + }*/ + + } + + CoverActionList { + CoverAction { + id: playPause + iconSource: player.playbackState === MediaPlayer.PlayingState ? "image://theme/icon-cover-pause" + : "image://theme/icon-cover-play" + } + } + +} diff --git a/qml/harbour-sailfin.qml b/qml/harbour-sailfin.qml index 13288ed..9105ff3 100644 --- a/qml/harbour-sailfin.qml +++ b/qml/harbour-sailfin.qml @@ -1,5 +1,6 @@ import QtQuick 2.0 import Sailfish.Silica 1.0 +import QtMultimedia 5.6 import nl.netsoj.chris.Jellyfin 1.0 import Nemo.Notifications 1.0 @@ -10,6 +11,8 @@ ApplicationWindow { id: appWindow property bool isInSetup: false property bool _hasInitialized: false + readonly property MediaPlayer mediaPlayer: _mediaPlayer + property var itemData //property alias backdrop: backdrop Connections { @@ -19,6 +22,11 @@ ApplicationWindow { //onConnectionSuccess: errorNotification.show("Success: " + loginMessage) } + MediaPlayer { + id: _mediaPlayer + autoPlay: true + } + /*GlassyBackground { id: backdrop anchors.fill: parent @@ -37,19 +45,29 @@ ApplicationWindow { onSetupRequired: { if (!isInSetup) { isInSetup = true; - pageStack.replace(Qt.resolvedUrl("pages/AddServerPage.qml"), {"backNavigation": false}); + pageStack.replace(Qt.resolvedUrl("pages/setup/AddServerPage.qml"), {"backNavigation": false}); } } } onStatusChanged: { if (status == PageStatus.Active && !_hasInitialized) { _hasInitialized = true; - ApiClient.initialize(); + ApiClient.restoreSavedSession(); } } } } - cover: Qt.resolvedUrl("cover/CoverPage.qml") + cover: { + if ([MediaPlayer.NoMedia, MediaPlayer.InvalidMedia, MediaPlayer.UnknownStatus].indexOf(mediaPlayer.status) >= 0) { + if (itemData) { + return Qt.resolvedUrl("cover/PosterCover.qml") + } else { + return Qt.resolvedUrl("cover/CoverPage.qml") + } + } else if (mediaPlayer.hasVideo){ + return Qt.resolvedUrl("cover/VideoCover.qml") + } + } allowedOrientations: Orientation.All Notification { diff --git a/qml/pages/DetailBasePage.qml b/qml/pages/DetailPage.qml similarity index 93% rename from qml/pages/DetailBasePage.qml rename to qml/pages/DetailPage.qml index 6b65048..7db26b4 100644 --- a/qml/pages/DetailBasePage.qml +++ b/qml/pages/DetailPage.qml @@ -4,6 +4,7 @@ import Sailfish.Silica 1.0 import nl.netsoj.chris.Jellyfin 1.0 import "../components" +import "../compontents/details" Page { id: pageRoot @@ -102,6 +103,7 @@ Page { IconButton { id: playButton icon.source: "image://theme/icon-l-play" + onPressed: pageStack.push(Qt.resolvedUrl("VideoPage.qml"), {"itemId": itemId, "itemData": itemData}) } } } @@ -122,6 +124,7 @@ Page { onStatusChanged: { if (status == PageStatus.Deactivating) { backdrop.clear() + appWindow.itemData = ({}) } } @@ -129,9 +132,10 @@ Page { target: ApiClient onItemFetched: { if (itemId === pageRoot.itemId) { - console.log(JSON.stringify(result)) + //console.log(JSON.stringify(result)) pageRoot.itemData = result pageRoot._loading = false + appWindow.itemData = result } } } diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 3f4b644..e67908b 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -5,6 +5,7 @@ import nl.netsoj.chris.Jellyfin 1.0 import "../components" +// Test Page { id: page @@ -90,11 +91,13 @@ Page { delegate: LibraryItemDelegate { property string id: model.id title: model.name - poster: ApiClient.baseUrl + "/Items/" + model.id + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] + poster: model.imageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + model.id + + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] + : "" landscape: true onClicked: { - pageStack.push(Qt.resolvedUrl("DetailBasePage.qml"), {"itemId": model.id}) + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) } } HorizontalScrollDecorator {} diff --git a/qml/pages/VideoPage.qml b/qml/pages/VideoPage.qml new file mode 100644 index 0000000..8e7f2b9 --- /dev/null +++ b/qml/pages/VideoPage.qml @@ -0,0 +1,37 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import "../components" + +Page { + id: videoPage + property string itemId + property var itemData + allowedOrientations: Orientation.All + palette.colorScheme: Theme.LightOnDark + showNavigationIndicator: videoPlayer.hudVisible + + Rectangle { + anchors.fill: parent + color: "black" + } + + VideoPlayer { + id: videoPlayer + anchors.fill: parent + itemId: videoPage.itemId + onLandscapeChanged: { + console.log("Is landscape: " + landscape) + //appWindow.orientation = landscape ? Orientation.Landscape : Orientation.Portrait + videoPage.allowedOrientations = landscape ? Orientation.LandscapeMask : Orientation.PortraitMask + } + player: appWindow.mediaPlayer + title: itemData.Name + } + + onStatusChanged: { + if (status == PageStatus.Inactive) { + videoPlayer.stop() + } + } +} diff --git a/qml/pages/AddServerConnectingPage.qml b/qml/pages/setup/AddServerConnectingPage.qml similarity index 100% rename from qml/pages/AddServerConnectingPage.qml rename to qml/pages/setup/AddServerConnectingPage.qml diff --git a/qml/pages/AddServerPage.qml b/qml/pages/setup/AddServerPage.qml similarity index 100% rename from qml/pages/AddServerPage.qml rename to qml/pages/setup/AddServerPage.qml diff --git a/qml/pages/LoginDialog.qml b/qml/pages/setup/LoginDialog.qml similarity index 94% rename from qml/pages/LoginDialog.qml rename to qml/pages/setup/LoginDialog.qml index 360318c..de4df34 100644 --- a/qml/pages/LoginDialog.qml +++ b/qml/pages/setup/LoginDialog.qml @@ -2,7 +2,7 @@ import QtQuick 2.6 import Sailfish.Silica 1.0 import nl.netsoj.chris.Jellyfin 1.0 -import "../components" +import "../../components" Dialog { property string loginMessage @@ -27,7 +27,7 @@ Dialog { onAuthenticatedChanged: { if (ApiClient.authenticated) { console.log("authenticated!") - pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("MainPage.qml")) + pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("../MainPage.qml")) } } onAuthenticationError: { diff --git a/qml/pages/setup/a b/qml/pages/setup/a new file mode 100644 index 0000000..e69de29 diff --git a/src/harbour-sailfin.cpp b/src/harbour-sailfin.cpp index c9facfa..e2625d4 100644 --- a/src/harbour-sailfin.cpp +++ b/src/harbour-sailfin.cpp @@ -11,17 +11,20 @@ #include "jellyfinapiclient.h" #include "jellyfinapimodel.h" +#include "jellyfinmediasource.h" #include "serverdiscoverymodel.h" void registerQml() { const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin"; - qmlRegisterSingletonType(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) { + // Singletons are perhaps bad, but they are convenient :) + qmlRegisterSingletonType(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) { Q_UNUSED(eng) Q_UNUSED(js) - return dynamic_cast(new JellyfinApiClient()); + return dynamic_cast(new Jellyfin::ApiClient()); }); - qmlRegisterType(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel"); + qmlRegisterType(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel"); + qmlRegisterType(QML_NAMESPACE, 1, 0, "MediaSource"); // API models Jellyfin::registerModels(QML_NAMESPACE); diff --git a/src/jellyfinapiclient.cpp b/src/jellyfinapiclient.cpp index e609fa3..82b25e3 100644 --- a/src/jellyfinapiclient.cpp +++ b/src/jellyfinapiclient.cpp @@ -3,11 +3,14 @@ #define STR2(x) #x #define STR(x) STR2(x) -JellyfinApiClient::JellyfinApiClient(QObject *parent) +namespace Jellyfin { +ApiClient::ApiClient(QObject *parent) : QObject(parent) { m_deviceName = QHostInfo::localHostName(); - m_deviceId = QUuid::createUuid().toString(); + m_deviceId = QUuid::createUuid().toString(); // TODO: make this not random? m_credManager = CredentialsManager::getInstance(this); + + generateDeviceProfile(); } //////////////////////////////////////////////////////////////////////////////////////////////////// @@ -15,8 +18,17 @@ JellyfinApiClient::JellyfinApiClient(QObject *parent) //////////////////////////////////////////////////////////////////////////////////////////////////// -void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms) { - QString authentication = "MediaBrowser "; +void ApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms) { + addTokenHeader(request); + request.setRawHeader("Accept", "application/json;"); // profile=\"CamelCase\""); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(STR(SAILFIN_VERSION))); + QString url = this->m_baseUrl + path; + if (!params.isEmpty()) url += "?" + params.toString(); + request.setUrl(url); +} + +void ApiClient::addTokenHeader(QNetworkRequest &request) { + QString authentication = "MediaBrowser "; authentication += "Client=\"Sailfin\""; authentication += ", Device=\"" + m_deviceName + "\""; authentication += ", DeviceId=\"" + m_deviceId + "\""; @@ -25,22 +37,20 @@ void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QS authentication += ", token=\"" + m_token + "\""; } request.setRawHeader("X-Emby-Authorization", authentication.toUtf8()); - request.setRawHeader("Accept", "application/json"); - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(STR(SAILFIN_VERSION))); - request.setUrl(this->m_baseUrl + path + "?" + params.toString()); - qDebug() << "REQUEST TO: " << request.url(); } -QNetworkReply *JellyfinApiClient::get(const QString &path, const QUrlQuery ¶ms) { +QNetworkReply *ApiClient::get(const QString &path, const QUrlQuery ¶ms) { QNetworkRequest req; addBaseRequestHeaders(req, path, params); + qDebug() << "GET " << req.url(); return m_naManager.get(req); } -QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument &data) { +QNetworkReply *ApiClient::post(const QString &path, const QJsonDocument &data, const QUrlQuery ¶ms) { QNetworkRequest req; req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - addBaseRequestHeaders(req, path); + addBaseRequestHeaders(req, path, params); + qDebug() << "POST " << req.url(); if (data.isEmpty()) return m_naManager.post(req, QByteArray()); else { @@ -52,7 +62,7 @@ QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument // Nice to have methods // //////////////////////////////////////////////////////////////////////////////////////////////////// -void JellyfinApiClient::initialize(){ +void ApiClient::restoreSavedSession(){ QObject *ctx1 = new QObject(this); connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) { qDebug() << "Servers listed: " << servers; @@ -82,6 +92,7 @@ void JellyfinApiClient::initialize(){ this->m_token = token; this->setUserId(user); this->setAuthenticated(true); + this->postCapabilities(); disconnect(ctx3); }, Qt::UniqueConnection); m_credManager->get(server, user); @@ -95,7 +106,7 @@ void JellyfinApiClient::initialize(){ m_credManager->listServers(); } -void JellyfinApiClient::setupConnection() { +void ApiClient::setupConnection() { // First detect redirects: // Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url, // which is something we want to avoid here. @@ -132,7 +143,7 @@ void JellyfinApiClient::setupConnection() { }); } -void JellyfinApiClient::getBrandingConfiguration() { +void ApiClient::getBrandingConfiguration() { QNetworkReply *rep = get("/Branding/Configuration"); connect(rep, &QNetworkReply::finished, this, [rep, this]() { qDebug() << "RESPONSE: " << statusCode(rep); @@ -161,7 +172,7 @@ void JellyfinApiClient::getBrandingConfiguration() { }); } -void JellyfinApiClient::authenticate(QString username, QString password, bool storeCredentials) { +void ApiClient::authenticate(QString username, QString password, bool storeCredentials) { QJsonObject requestData; requestData["Username"] = username; @@ -173,9 +184,13 @@ void JellyfinApiClient::authenticate(QString username, QString password, bool st if (status >= 200 && status < 300) { QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object(); this->m_token = authInfo["AccessToken"].toString(); - this->setAuthenticated(true); + // Fool this class's addRequestheaders to add the token, without + // notifying QML that we're authenticated, to prevent other requests going first. + this->m_authenticated = true; this->setUserId(authInfo["User"].toObject()["Id"].toString()); + this->postCapabilities(); + this->setAuthenticated(true); if (storeCredentials) { m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token); @@ -184,10 +199,10 @@ void JellyfinApiClient::authenticate(QString username, QString password, bool st rep->deleteLater(); }); connect(rep, static_cast(&QNetworkReply::error), - this, &JellyfinApiClient::defaultNetworkErrorHandler); + this, &ApiClient::defaultNetworkErrorHandler); } -void JellyfinApiClient::fetchItem(const QString &id) { +void ApiClient::fetchItem(const QString &id) { QNetworkReply *rep = get("/Users/" + m_userId + "/Items/" + id); connect(rep, &QNetworkReply::finished, this, [rep, id, this]() { int status = statusCode(rep); @@ -199,7 +214,36 @@ void JellyfinApiClient::fetchItem(const QString &id) { }); } -void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) { +void ApiClient::postCapabilities() { + QJsonObject capabilities; + capabilities["SupportsPersistentIdentifier"] = false; // Technically untrue, but not implemented yet. + capabilities["SupportsMediaControl"] = false; + capabilities["SupportsSync"] = false; + capabilities["SupportsContentUploading"] = false; + capabilities["AppStoreUrl"] = "https://chris.netsoj.nl/projects/harbour-sailfin"; + capabilities["IconUrl"] = "https://chris.netsoj.nl/static/img/logo.png"; + capabilities["DeviceProfile"] = m_deviceProfile; + QNetworkReply *rep = post("/Sessions/Capabilities/Full", QJsonDocument(capabilities)); + connect(rep, static_cast(&QNetworkReply::error), + this, &ApiClient::defaultNetworkErrorHandler); +} + +void ApiClient::generateDeviceProfile() { + QJsonObject root = DeviceProfile::generateProfile(); + m_playbackDeviceProfile = QJsonObject(root); + root["Name"] = m_deviceName; + root["Id"] = m_deviceId; + root["FriendlyName"] = QSysInfo::prettyProductName(); + QJsonArray playableMediaTypes; + playableMediaTypes.append("Audio"); + playableMediaTypes.append("Video"); + playableMediaTypes.append("Photo"); + root["PlayableMediaTypes"] = playableMediaTypes; + + m_deviceProfile = root; +} + +void ApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) { QObject *signalSender = sender(); QNetworkReply *rep = dynamic_cast(signalSender); if (rep != nullptr && statusCode(rep) == 401) { @@ -209,6 +253,7 @@ void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError e } rep->deleteLater(); } +} #undef STR #undef STR2 diff --git a/src/jellyfinapiclient.h b/src/jellyfinapiclient.h index caaa7ec..6f20aef 100644 --- a/src/jellyfinapiclient.h +++ b/src/jellyfinapiclient.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -16,12 +17,41 @@ #include #include "credentialmanager.h" +#include "jellyfindeviceprofile.h" -class JellyfinApiClient : public QObject { +namespace Jellyfin { +class MediaSource; +/** + * @brief An Api client for Jellyfin. Handles requests and authentication. + * + * This class should also be given to certain models and other sources, so they are able to make + * requests to the correct server. + * + * General usage is as follows: + * 1. (Optional) Call restoreSavedSession(). This will try to load previously saved credentials and connect to the server. + * If all succeeds, the property authenticated should be set to true and its signal should be emitted. All is done. + * If it fails, setupRequired will be emitted. Continue following these steps. + * 2. If opting in to manually manage the session or restoreSavedSession() failed, you'll need to set the property + * baseUrl to the root of the Jellyfin server, e.g. "https://jellyfin.example.com:8098", so not the url to the + * web interface! Nearby servers can be discovered using Jellyfin::ServerDiscoveryModel. + * 3. Call ::setupConnection(). First of all, the client will try to resolve any redirects and will update + * the baseUrl property if following redirects. Then it will emit connectionSuccess(QString). The QString from + * the signal contains a user-oriented login message configured by the user that should be displayed in the URL + * somewhere. + * 4. After ::connected is emitted, call ::authenticate(QString, QString, bool). with the username and password. + * The last boolean argument is used if you want to have the ApiClient store your credentials, so that they + * later can be used with restoreSavedSession(). + * 5. If the authenticated property is set to true, you are now authenticated! If loginError() is emitted, you aren't and + * you should go back to step 4. + * + * These steps might change. I'm considering decoupling CredentialsManager from this class to clean some code up. + */ +class ApiClient : public QObject { + friend class MediaSource; Q_OBJECT public: - explicit JellyfinApiClient(QObject *parent = nullptr); - Q_PROPERTY(QString baseUrl MEMBER m_baseUrl NOTIFY baseUrlChanged) + explicit ApiClient(QObject *parent = nullptr); + Q_PROPERTY(QString baseUrl MEMBER m_baseUrl READ baseUrl NOTIFY baseUrlChanged) Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged) Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged) @@ -38,7 +68,7 @@ public: } QNetworkReply *get(const QString &path, const QUrlQuery ¶ms = QUrlQuery()); - QNetworkReply *post(const QString &path, const QJsonDocument &data = QJsonDocument()); + QNetworkReply *post(const QString &path, const QJsonDocument &data = QJsonDocument(), const QUrlQuery ¶ms = QUrlQuery()); void getPublicUsers(); enum ApiError { @@ -48,7 +78,10 @@ public: INVALID_PASSWORD }; + QString &baseUrl() { return this->m_baseUrl; } QString &userId() { return m_userId; } + QJsonObject &deviceProfile() { return m_deviceProfile; } + QJsonObject &playbackDeviceProfile() { return m_playbackDeviceProfile; } signals: /* * Emitted when the server requires authentication. Please authenticate your user via authenticate. @@ -79,7 +112,7 @@ public slots: * @brief Tries to access credentials and connect to a server. If nothing has been configured yet, * emits setupRequired(); */ - void initialize(); + void restoreSavedSession(); /* * Try to connect with the server. Tries to resolve redirects and retrieves information * about the login procedure. Emits connectionSuccess on success, networkError or ConnectionFailed @@ -89,6 +122,11 @@ public slots: void authenticate(QString username, QString password, bool storeCredentials = false); void fetchItem(const QString &id); + /** + * @brief Shares the capabilities of this device to the server. + */ + void postCapabilities(); + protected slots: void defaultNetworkErrorHandler(QNetworkReply::NetworkError error); @@ -100,19 +138,49 @@ protected: */ void addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms = QUrlQuery()); + /** + * @brief Adds the authorization to the header + * @param The request to add the header to + */ + void addTokenHeader(QNetworkRequest &request); + /** * @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore) */ void getBrandingConfiguration(); + /** + * @brief Generates a profile, containing the name of the application, manufacturer and most importantly, + * which media types this device supports. + * + * The actual detection of supported media types is done within jellyfindeviceprofile.cpp, since the code + * is a big mess and should be safely contained in it's own file. + */ + void generateDeviceProfile(); + QString &token() { return m_token; } private: + QNetworkAccessManager m_naManager; + /* + * State information + */ CredentialsManager * m_credManager; QString m_token; QString m_deviceName; QString m_deviceId; - QString m_userId = ""; + QJsonObject m_deviceProfile; + QJsonObject m_playbackDeviceProfile; + + bool m_authenticated = false; + /** + * @brief The base url of the request. + */ + QString m_baseUrl; + + /* + * Setters + */ void setAuthenticated(bool authenticated) { this->m_authenticated = authenticated; @@ -123,14 +191,21 @@ private: emit userIdChanged(userId); } - bool m_authenticated = false; - QString m_baseUrl; + /* + * Utilities + */ - QNetworkAccessManager m_naManager; + /** + * @brief Returns the statusCode of a QNetworkReply + * @param The reply to obtain the statusCode of + * @return The statuscode of the reply + * + * Seriously, Qt, why is your method to obtain the status code of a request so horrendous? + */ static inline int statusCode(QNetworkReply *rep) { return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); } }; - +} // NS Jellyfin #endif // JELLYFIN_API_CLIENT diff --git a/src/jellyfinapimodel.cpp b/src/jellyfinapimodel.cpp index 31e80ec..49c78e6 100644 --- a/src/jellyfinapimodel.cpp +++ b/src/jellyfinapimodel.cpp @@ -82,7 +82,7 @@ void ApiModel::generateFields() { QByteArray keyArr = keyName.toUtf8(); if (!m_roles.values().contains(keyArr)) { m_roles.insert(i++, keyArr); - qDebug() << m_path << " adding " << keyName << " as " << ( i - 1); + //qDebug() << m_path << " adding " << keyName << " as " << ( i - 1); } } this->endResetModel(); diff --git a/src/jellyfinapimodel.h b/src/jellyfinapimodel.h index 03b9a25..07fb979 100644 --- a/src/jellyfinapimodel.h +++ b/src/jellyfinapimodel.h @@ -100,7 +100,7 @@ public: * Subfield should be set to "data" in this example. */ explicit ApiModel(QString path, QString subfield, QObject *parent = nullptr); - Q_PROPERTY(JellyfinApiClient *apiClient MEMBER m_apiClient) + Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged) Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged) Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged) @@ -141,7 +141,7 @@ public slots: */ void reload(); protected: - JellyfinApiClient *m_apiClient = nullptr; + ApiClient *m_apiClient = nullptr; ModelStatus m_status = Uninitialised; QString m_path; diff --git a/src/jellyfindeviceprofile.cpp b/src/jellyfindeviceprofile.cpp new file mode 100644 index 0000000..97a92f4 --- /dev/null +++ b/src/jellyfindeviceprofile.cpp @@ -0,0 +1,172 @@ +#include "jellyfindeviceprofile.h" + +namespace Jellyfin { + +bool DeviceProfile::supportsHls() { + return true; +} + +bool DeviceProfile::canPlayH264() { + return true; +} + +bool DeviceProfile::canPlayAc3() { + return true; +} + +bool DeviceProfile::supportsMp3VideoAudio() { + qDebug() << "Mp3VideoAudio: " << QMediaPlayer::hasSupport("video/mp4", {"avc1.640029", "mp3"}, QMediaPlayer::StreamPlayback); + return true; +} + +int DeviceProfile::maxStreamingBitrate() { + return 5000000; +} + +QJsonObject DeviceProfile::generateProfile() { + using JsonPair = QPair; + QJsonObject profile; + + QStringList videoAudioCodecs; + QStringList mp4VideoCodecs; + QStringList hlsVideoCodecs; + QStringList hlsVideoAudioCodecs; + + if (canPlayH264()) { + mp4VideoCodecs.append("h264"); + hlsVideoCodecs.append("h264"); + } + + if (canPlayAc3()) { + videoAudioCodecs.append("ac3"); + hlsVideoAudioCodecs.append("ac3"); + } + + if (supportsMp3VideoAudio()) { + videoAudioCodecs.append("mp3"); + hlsVideoAudioCodecs.append("mp3"); + } + + QJsonArray codecProfiles = {}; + codecProfiles.append(QJsonObject { + JsonPair("Codec", "aac"), + JsonPair("Conditions", QJsonArray { + QJsonObject { + JsonPair("Property", "IsSecondaryAudio"), + JsonPair("Condition", "Equals"), + JsonPair("Value", false), + JsonPair("IsRequired", false) + } + }), + JsonPair("Type", "VideoAudio") + }); + codecProfiles.append(QJsonObject { + JsonPair("Coded", "h264"), + JsonPair("Conditions", QJsonArray { + QJsonObject { + JsonPair("Property", "IsAnamorphic"), + JsonPair("Condition", "NotEquals"), + JsonPair("Value", true), + JsonPair("IsRequired", false) + }, + QJsonObject { + JsonPair("Property", "VideoProfile"), + JsonPair("Condition", "EqualsAny"), + JsonPair("Value", "baseline|constrained baseline"), //"high|main|baseline|constrained baseline"), + JsonPair("IsRequired", false), + }, + QJsonObject { + JsonPair("Property", "VideoLevel"), + JsonPair("Condition", "LessThanEqual"), + JsonPair("Value", 51), + JsonPair("IsRequired", false) + }, + QJsonObject { + JsonPair("Property", "IsInterlaced"), + JsonPair("Condition", "NotEquals"), + JsonPair("Value", true), + JsonPair("IsRequired", false) + } + }), + JsonPair("Type", "Video") + }); + + QJsonArray transcodingProfiles = {}; + + // Hard coded nr 1: + QJsonObject transcoding1; + transcoding1["AudioCodec"] = "aac"; + transcoding1["BreakOnNonKeyFrames"] =true; + transcoding1["Container"] = "ts"; + transcoding1["Context"] = "Streaming"; + transcoding1["MaxAudioChannels"] = 2; + transcoding1["MinSegments"] = 1; + transcoding1["Protocol"] = "hls"; + transcoding1["Type"] = "Audio"; + transcodingProfiles.append(transcoding1); + + // Hard code nr 2 + transcodingProfiles.append(QJsonObject({ + JsonPair("AudioCodec", "mp3,aac"), + JsonPair("BreakOnNonKeyFrames", true), + JsonPair("Container", "ts"), + JsonPair("Context", "Streaming"), + JsonPair("MaxAudioChannels", 2), + JsonPair("MinSegments", 1), + JsonPair("Protocol", "hls"), + JsonPair("Type", "Video"), + JsonPair("VideoCodec", "h264") + })); + + // Fallback + transcodingProfiles.append(QJsonObject { + JsonPair("Container", "mp4"), + JsonPair("Type", "Video"), + JsonPair("AudioCodec", videoAudioCodecs.join(',')), + JsonPair("VideoCodec", "h264"), + JsonPair("Context", "Static"), + JsonPair("Protocol", "http") + }); + + + if (supportsHls() && !hlsVideoAudioCodecs.isEmpty()) { + transcodingProfiles.append(QJsonObject { + JsonPair("Container", "ts"), + JsonPair("Type", "Video"), + JsonPair("AudioCodec", hlsVideoAudioCodecs.join(",")), + JsonPair("VideoCodec", hlsVideoCodecs.join(",")), + JsonPair("Context", "Streaming"), + JsonPair("Protocol", "hls"), + JsonPair("MaxAudioChannels", 2), + JsonPair("MinSegments", 1), + JsonPair("BreakOnNonKeyFrames", true) + }); + } + + // Response profiles (or whatever it actually does?) + QJsonArray responseProfiles = {}; + responseProfiles.append(QJsonObject({ + JsonPair("Type", "Video"), + JsonPair("Container", "m4v"), + JsonPair("MimeType", "video/mp4") + })); + + // Direct play profiles + QJsonArray directPlayProfiles; + directPlayProfiles.append(QJsonObject { + JsonPair("Container", "mp4,m4v"), + JsonPair("Type", "Video"), + JsonPair("VideoCodec", mp4VideoCodecs.join(',')), + JsonPair("AudioCodec", videoAudioCodecs.join(',')) + }); + + profile["CodecProfiles"] = codecProfiles; + profile["ContainerProfiles"] = QJsonArray(); + profile["DirectPlayProfiles"] = directPlayProfiles; + profile["ResponseProfiles"] = responseProfiles; + profile["SubtitleProfiles"] = QJsonArray(); + profile["TranscodingProfiles"] = transcodingProfiles; + profile["MaxStreamingBitrate"] = maxStreamingBitrate(); + return profile; +} +} diff --git a/src/jellyfindeviceprofile.h b/src/jellyfindeviceprofile.h new file mode 100644 index 0000000..39b2d76 --- /dev/null +++ b/src/jellyfindeviceprofile.h @@ -0,0 +1,33 @@ +#ifndef JELLYFIN_DEVICE_PROFILE_H +#define JELLYFIN_DEVICE_PROFILE_H + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Jellyfin { +namespace DeviceProfile { + QJsonObject generateProfile(); + // Transport + bool supportsHls(); + + // Bitrate + int maxStreamingBitrate(); + + // Video codecs + bool canPlayH264(); + bool canPlayH265(); + + // Audio codecs + bool canPlayAc3(); + bool supportsMp3VideoAudio(); +} +} + +#endif // JELLYFIN_DEVICE_PROFILE_H diff --git a/src/jellyfinmediasource.cpp b/src/jellyfinmediasource.cpp new file mode 100644 index 0000000..8e320ec --- /dev/null +++ b/src/jellyfinmediasource.cpp @@ -0,0 +1,84 @@ +#include "jellyfinmediasource.h" + +namespace Jellyfin { + +MediaSource::MediaSource(QObject *parent) + : QObject(parent), + m_mediaPlayer(new QMediaPlayer(this)){ + +} + +void MediaSource::fetchStreamUrl() { + QUrlQuery params; + params.addQueryItem("UserId", m_apiClient->userId()); + params.addQueryItem("StartTimeTicks", "0"); + params.addQueryItem("IsPlayback", "true"); + params.addQueryItem("AutoOpenLiveStream", this->m_autoOpen ? "true" : "false"); + params.addQueryItem("MediaSourceId", this->m_itemId); + params.addQueryItem("SubtitleStreamIndex", "-1"); + params.addQueryItem("AudioStreamIndex", "0"); + + QJsonObject root; + root["DeviceProfile"] = m_apiClient->playbackDeviceProfile(); + + QNetworkReply *rep = m_apiClient->post("/Items/" + this->m_itemId + "/PlaybackInfo", QJsonDocument(root), params); + connect(rep, &QNetworkReply::finished, this, [this, rep]() { + QJsonObject root = QJsonDocument::fromJson(rep->readAll()).object(); + this->m_playSessionId = root["PlaySessionId"].toString(); + qDebug() << "Session id: " << this->m_playSessionId; + + 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() + + mediaSources[0].toObject()["TranscodingUrl"].toString(); + + + emit this->streamUrlChanged(this->m_streamUrl); + qDebug() << "Found stream url: " << this->m_streamUrl; + /*QNetworkRequest req; + req.setUrl(this->m_streamUrl); + m_apiClient->addTokenHeader(req); + m_mediaPlayer->setMedia(QMediaContent(req)); + if (m_autoPlay) m_mediaPlayer->play();*/ + } + + rep->deleteLater(); + }); +} + +void MediaSource::setItemId(const QString &newItemId) { + if (m_apiClient == nullptr) { + qWarning() << "apiClient is not set on this MediaSource instance! Aborting."; + return; + } + if (m_mediaPlayer == nullptr) { + qWarning() << "mediaPlayer is not set on this MediaSource instance! Aborting."; + return; + } + this->m_itemId = newItemId; + // Deinitialize the streamUrl + setStreamUrl(""); + if (!newItemId.isEmpty()) { + fetchStreamUrl(); + } +} + +void MediaSource::setStreamUrl(const QString &streamUrl) { + this->m_streamUrl = streamUrl; + emit streamUrlChanged(streamUrl); +} + +void MediaSource::play() { + this->m_mediaPlayer->play(); +} + +void MediaSource::pause() { + this->m_mediaPlayer->pause(); +} + +void MediaSource::stop() { + this->m_mediaPlayer->stop(); +} + +} diff --git a/src/jellyfinmediasource.h b/src/jellyfinmediasource.h new file mode 100644 index 0000000..e19a46c --- /dev/null +++ b/src/jellyfinmediasource.h @@ -0,0 +1,62 @@ +#ifndef JELLYFIN_MEDIA_SOURCE_H +#define JELLYFIN_MEDIA_SOURCE_H + +#include +#include +#include + +#include + +#include + + +#include "jellyfinapiclient.h" + +namespace Jellyfin { + +class MediaSource : public QObject { + Q_OBJECT +public: + explicit MediaSource(QObject *parent = nullptr); + Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) + Q_PROPERTY(QString itemId READ itemId WRITE setItemId NOTIFY itemIdChanged) + Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY streamUrlChanged) + Q_PROPERTY(bool autoOpen MEMBER m_autoOpen NOTIFY autoOpenChanged) + Q_PROPERTY(QMediaPlayer *mediaPlayer READ mediaPlayer) + Q_PROPERTY(bool autoPlay MEMBER m_autoPlay) + + QString itemId() const { return m_itemId; } + void setItemId(const QString &newItemId); + + QString streamUrl() const { return m_streamUrl; } + + QMediaPlayer *mediaPlayer() { return m_mediaPlayer; } +signals: + void itemIdChanged(const QString &newItemId); + void streamUrlChanged(const QString &newStreamUrl); + void autoOpenChanged(bool autoOpen); + +public slots: + void play(); + void pause(); + void stop(); + +private: + ApiClient *m_apiClient = nullptr; + QMediaPlayer *m_mediaPlayer = nullptr; + QString m_itemId; + QString m_streamUrl; + QString m_playSessionId; + /** + * @brief Whether to automatically open the livestream of the item; + */ + bool m_autoOpen = false; + bool m_autoPlay = false; + + void fetchStreamUrl(); + void setStreamUrl(const QString &streamUrl); +}; + +} + +#endif // JELLYFIN_MEDIA_SOURCE_H diff --git a/src/serverdiscoverymodel.cpp b/src/serverdiscoverymodel.cpp index 0815ca6..5d4b882 100644 --- a/src/serverdiscoverymodel.cpp +++ b/src/serverdiscoverymodel.cpp @@ -1,5 +1,6 @@ #include "serverdiscoverymodel.h" +namespace Jellyfin { ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent) : QAbstractListModel (parent) { connect(&m_socket, &QUdpSocket::readyRead, this, &ServerDiscoveryModel::on_datagramsAvailable); @@ -71,3 +72,4 @@ void ServerDiscoveryModel::on_datagramsAvailable() { m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end()); endInsertRows(); }; +} diff --git a/src/serverdiscoverymodel.h b/src/serverdiscoverymodel.h index 16d7aa5..e503027 100644 --- a/src/serverdiscoverymodel.h +++ b/src/serverdiscoverymodel.h @@ -12,6 +12,7 @@ #include #include +namespace Jellyfin { struct ServerDiscovery { QString name; QString address; @@ -59,5 +60,5 @@ private: QUdpSocket m_socket; std::vector m_discoveredServers; }; - +} #endif //SERVER_DISCOVERY_MODEL_H