mirror of
https://github.com/HenkKalkwater/harbour-sailfin.git
synced 2024-11-22 09:15:18 +00:00
Added videoplayer and many unrelated things
This commit is contained in:
parent
53b3eac213
commit
92a18c4fa5
|
@ -12,6 +12,7 @@
|
||||||
# The name of your application
|
# The name of your application
|
||||||
TARGET = harbour-sailfin
|
TARGET = harbour-sailfin
|
||||||
|
|
||||||
|
QT += multimedia
|
||||||
|
|
||||||
CONFIG += sailfishapp c++11
|
CONFIG += sailfishapp c++11
|
||||||
|
|
||||||
|
@ -20,6 +21,8 @@ SOURCES += \
|
||||||
src/harbour-sailfin.cpp \
|
src/harbour-sailfin.cpp \
|
||||||
src/jellyfinapiclient.cpp \
|
src/jellyfinapiclient.cpp \
|
||||||
src/jellyfinapimodel.cpp \
|
src/jellyfinapimodel.cpp \
|
||||||
|
src/jellyfindeviceprofile.cpp \
|
||||||
|
src/jellyfinmediasource.cpp \
|
||||||
src/serverdiscoverymodel.cpp
|
src/serverdiscoverymodel.cpp
|
||||||
|
|
||||||
DISTFILES += \
|
DISTFILES += \
|
||||||
|
@ -29,14 +32,22 @@ DISTFILES += \
|
||||||
qml/components/PlainLabel.qml \
|
qml/components/PlainLabel.qml \
|
||||||
qml/components/RemoteImage.qml \
|
qml/components/RemoteImage.qml \
|
||||||
qml/components/UserGridDelegate.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/cover/CoverPage.qml \
|
||||||
qml/pages/AddServerConnectingPage.qml \
|
qml/cover/PosterCover.qml \
|
||||||
qml/pages/DetailBasePage.qml \
|
qml/cover/VideoCover.qml \
|
||||||
|
qml/pages/DetailPage.qml \
|
||||||
qml/pages/LegalPage.qml \
|
qml/pages/LegalPage.qml \
|
||||||
qml/pages/LoginDialog.qml \
|
|
||||||
qml/pages/MainPage.qml \
|
qml/pages/MainPage.qml \
|
||||||
qml/pages/SecondPages.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
|
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
|
||||||
|
|
||||||
|
@ -54,4 +65,6 @@ CONFIG += sailfishapp_i18n
|
||||||
src/credentialmanager.h \
|
src/credentialmanager.h \
|
||||||
src/jellyfinapiclient.h \
|
src/jellyfinapiclient.h \
|
||||||
src/jellyfinapimodel.h \
|
src/jellyfinapimodel.h \
|
||||||
|
src/jellyfindeviceprofile.h \
|
||||||
|
src/jellyfinmediasource.h \
|
||||||
src/serverdiscoverymodel.h
|
src/serverdiscoverymodel.h
|
||||||
|
|
|
@ -71,6 +71,6 @@ Item {
|
||||||
right: parent.right
|
right: parent.right
|
||||||
}
|
}
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: children[0].height
|
height: children.length > 0 ? children[0].height : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,13 @@ Image {
|
||||||
GradientStop { position: 0.0; color: Theme.highlightColor; }
|
GradientStop { position: 0.0; color: Theme.highlightColor; }
|
||||||
GradientStop { position: 1.0; color: Theme.highlightDimmerColor; }
|
GradientStop { position: 1.0; color: Theme.highlightDimmerColor; }
|
||||||
}
|
}
|
||||||
visible: parent.status == Image.Error
|
visible: parent.status == Image.Error || parent.status == Image.Null
|
||||||
}
|
}
|
||||||
|
|
||||||
Image {
|
Image {
|
||||||
id: fallbackImageItem
|
id: fallbackImageItem
|
||||||
anchors.centerIn: parent
|
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"
|
source: fallbackImage ? fallbackImage : "image://theme/icon-m-question"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
64
qml/components/VideoPlayer.qml
Normal file
64
qml/components/VideoPlayer.qml
Normal file
|
@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
5
qml/components/itemdetails/FilmDetails.qml
Normal file
5
qml/components/itemdetails/FilmDetails.qml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
|
||||||
|
}
|
5
qml/components/itemdetails/SeriesDetails.qml
Normal file
5
qml/components/itemdetails/SeriesDetails.qml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
|
||||||
|
}
|
166
qml/components/videoplayer/VideoHud.qml
Normal file
166
qml/components/videoplayer/VideoHud.qml
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
qml/cover/PosterCover.qml
Normal file
18
qml/cover/PosterCover.qml
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
28
qml/cover/VideoCover.qml
Normal file
28
qml/cover/VideoCover.qml
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import QtQuick 2.0
|
import QtQuick 2.0
|
||||||
import Sailfish.Silica 1.0
|
import Sailfish.Silica 1.0
|
||||||
|
import QtMultimedia 5.6
|
||||||
import nl.netsoj.chris.Jellyfin 1.0
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
import Nemo.Notifications 1.0
|
import Nemo.Notifications 1.0
|
||||||
|
|
||||||
|
@ -10,6 +11,8 @@ ApplicationWindow {
|
||||||
id: appWindow
|
id: appWindow
|
||||||
property bool isInSetup: false
|
property bool isInSetup: false
|
||||||
property bool _hasInitialized: false
|
property bool _hasInitialized: false
|
||||||
|
readonly property MediaPlayer mediaPlayer: _mediaPlayer
|
||||||
|
property var itemData
|
||||||
//property alias backdrop: backdrop
|
//property alias backdrop: backdrop
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
|
@ -19,6 +22,11 @@ ApplicationWindow {
|
||||||
//onConnectionSuccess: errorNotification.show("Success: " + loginMessage)
|
//onConnectionSuccess: errorNotification.show("Success: " + loginMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaPlayer {
|
||||||
|
id: _mediaPlayer
|
||||||
|
autoPlay: true
|
||||||
|
}
|
||||||
|
|
||||||
/*GlassyBackground {
|
/*GlassyBackground {
|
||||||
id: backdrop
|
id: backdrop
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
@ -37,19 +45,29 @@ ApplicationWindow {
|
||||||
onSetupRequired: {
|
onSetupRequired: {
|
||||||
if (!isInSetup) {
|
if (!isInSetup) {
|
||||||
isInSetup = true;
|
isInSetup = true;
|
||||||
pageStack.replace(Qt.resolvedUrl("pages/AddServerPage.qml"), {"backNavigation": false});
|
pageStack.replace(Qt.resolvedUrl("pages/setup/AddServerPage.qml"), {"backNavigation": false});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onStatusChanged: {
|
onStatusChanged: {
|
||||||
if (status == PageStatus.Active && !_hasInitialized) {
|
if (status == PageStatus.Active && !_hasInitialized) {
|
||||||
_hasInitialized = true;
|
_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
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
Notification {
|
Notification {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Sailfish.Silica 1.0
|
||||||
import nl.netsoj.chris.Jellyfin 1.0
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
import "../components"
|
import "../components"
|
||||||
|
import "../compontents/details"
|
||||||
|
|
||||||
Page {
|
Page {
|
||||||
id: pageRoot
|
id: pageRoot
|
||||||
|
@ -102,6 +103,7 @@ Page {
|
||||||
IconButton {
|
IconButton {
|
||||||
id: playButton
|
id: playButton
|
||||||
icon.source: "image://theme/icon-l-play"
|
icon.source: "image://theme/icon-l-play"
|
||||||
|
onPressed: pageStack.push(Qt.resolvedUrl("VideoPage.qml"), {"itemId": itemId, "itemData": itemData})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,6 +124,7 @@ Page {
|
||||||
onStatusChanged: {
|
onStatusChanged: {
|
||||||
if (status == PageStatus.Deactivating) {
|
if (status == PageStatus.Deactivating) {
|
||||||
backdrop.clear()
|
backdrop.clear()
|
||||||
|
appWindow.itemData = ({})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,9 +132,10 @@ Page {
|
||||||
target: ApiClient
|
target: ApiClient
|
||||||
onItemFetched: {
|
onItemFetched: {
|
||||||
if (itemId === pageRoot.itemId) {
|
if (itemId === pageRoot.itemId) {
|
||||||
console.log(JSON.stringify(result))
|
//console.log(JSON.stringify(result))
|
||||||
pageRoot.itemData = result
|
pageRoot.itemData = result
|
||||||
pageRoot._loading = false
|
pageRoot._loading = false
|
||||||
|
appWindow.itemData = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,6 +5,7 @@ import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
import "../components"
|
import "../components"
|
||||||
|
|
||||||
|
// Test
|
||||||
Page {
|
Page {
|
||||||
id: page
|
id: page
|
||||||
|
|
||||||
|
@ -90,11 +91,13 @@ Page {
|
||||||
delegate: LibraryItemDelegate {
|
delegate: LibraryItemDelegate {
|
||||||
property string id: model.id
|
property string id: model.id
|
||||||
title: model.name
|
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
|
landscape: true
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
pageStack.push(Qt.resolvedUrl("DetailBasePage.qml"), {"itemId": model.id})
|
pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalScrollDecorator {}
|
HorizontalScrollDecorator {}
|
||||||
|
|
37
qml/pages/VideoPage.qml
Normal file
37
qml/pages/VideoPage.qml
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import QtQuick 2.6
|
||||||
import Sailfish.Silica 1.0
|
import Sailfish.Silica 1.0
|
||||||
import nl.netsoj.chris.Jellyfin 1.0
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
import "../components"
|
import "../../components"
|
||||||
|
|
||||||
Dialog {
|
Dialog {
|
||||||
property string loginMessage
|
property string loginMessage
|
||||||
|
@ -27,7 +27,7 @@ Dialog {
|
||||||
onAuthenticatedChanged: {
|
onAuthenticatedChanged: {
|
||||||
if (ApiClient.authenticated) {
|
if (ApiClient.authenticated) {
|
||||||
console.log("authenticated!")
|
console.log("authenticated!")
|
||||||
pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("MainPage.qml"))
|
pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("../MainPage.qml"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onAuthenticationError: {
|
onAuthenticationError: {
|
0
qml/pages/setup/a
Normal file
0
qml/pages/setup/a
Normal file
|
@ -11,17 +11,20 @@
|
||||||
|
|
||||||
#include "jellyfinapiclient.h"
|
#include "jellyfinapiclient.h"
|
||||||
#include "jellyfinapimodel.h"
|
#include "jellyfinapimodel.h"
|
||||||
|
#include "jellyfinmediasource.h"
|
||||||
#include "serverdiscoverymodel.h"
|
#include "serverdiscoverymodel.h"
|
||||||
|
|
||||||
|
|
||||||
void registerQml() {
|
void registerQml() {
|
||||||
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
|
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
|
||||||
qmlRegisterSingletonType<JellyfinApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
|
// Singletons are perhaps bad, but they are convenient :)
|
||||||
|
qmlRegisterSingletonType<Jellyfin::ApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
|
||||||
Q_UNUSED(eng)
|
Q_UNUSED(eng)
|
||||||
Q_UNUSED(js)
|
Q_UNUSED(js)
|
||||||
return dynamic_cast<QObject*>(new JellyfinApiClient());
|
return dynamic_cast<QObject*>(new Jellyfin::ApiClient());
|
||||||
});
|
});
|
||||||
qmlRegisterType<ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
|
qmlRegisterType<Jellyfin::ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
|
||||||
|
qmlRegisterType<Jellyfin::MediaSource>(QML_NAMESPACE, 1, 0, "MediaSource");
|
||||||
|
|
||||||
// API models
|
// API models
|
||||||
Jellyfin::registerModels(QML_NAMESPACE);
|
Jellyfin::registerModels(QML_NAMESPACE);
|
||||||
|
|
|
@ -3,11 +3,14 @@
|
||||||
#define STR2(x) #x
|
#define STR2(x) #x
|
||||||
#define STR(x) STR2(x)
|
#define STR(x) STR2(x)
|
||||||
|
|
||||||
JellyfinApiClient::JellyfinApiClient(QObject *parent)
|
namespace Jellyfin {
|
||||||
|
ApiClient::ApiClient(QObject *parent)
|
||||||
: QObject(parent) {
|
: QObject(parent) {
|
||||||
m_deviceName = QHostInfo::localHostName();
|
m_deviceName = QHostInfo::localHostName();
|
||||||
m_deviceId = QUuid::createUuid().toString();
|
m_deviceId = QUuid::createUuid().toString(); // TODO: make this not random?
|
||||||
m_credManager = CredentialsManager::getInstance(this);
|
m_credManager = CredentialsManager::getInstance(this);
|
||||||
|
|
||||||
|
generateDeviceProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -15,7 +18,16 @@ JellyfinApiClient::JellyfinApiClient(QObject *parent)
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|
||||||
void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms) {
|
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 ";
|
QString authentication = "MediaBrowser ";
|
||||||
authentication += "Client=\"Sailfin\"";
|
authentication += "Client=\"Sailfin\"";
|
||||||
authentication += ", Device=\"" + m_deviceName + "\"";
|
authentication += ", Device=\"" + m_deviceName + "\"";
|
||||||
|
@ -25,22 +37,20 @@ void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QS
|
||||||
authentication += ", token=\"" + m_token + "\"";
|
authentication += ", token=\"" + m_token + "\"";
|
||||||
}
|
}
|
||||||
request.setRawHeader("X-Emby-Authorization", authentication.toUtf8());
|
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;
|
QNetworkRequest req;
|
||||||
addBaseRequestHeaders(req, path, params);
|
addBaseRequestHeaders(req, path, params);
|
||||||
|
qDebug() << "GET " << req.url();
|
||||||
return m_naManager.get(req);
|
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;
|
QNetworkRequest req;
|
||||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
addBaseRequestHeaders(req, path);
|
addBaseRequestHeaders(req, path, params);
|
||||||
|
qDebug() << "POST " << req.url();
|
||||||
if (data.isEmpty())
|
if (data.isEmpty())
|
||||||
return m_naManager.post(req, QByteArray());
|
return m_naManager.post(req, QByteArray());
|
||||||
else {
|
else {
|
||||||
|
@ -52,7 +62,7 @@ QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument
|
||||||
// Nice to have methods //
|
// Nice to have methods //
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
void JellyfinApiClient::initialize(){
|
void ApiClient::restoreSavedSession(){
|
||||||
QObject *ctx1 = new QObject(this);
|
QObject *ctx1 = new QObject(this);
|
||||||
connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) {
|
connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) {
|
||||||
qDebug() << "Servers listed: " << servers;
|
qDebug() << "Servers listed: " << servers;
|
||||||
|
@ -82,6 +92,7 @@ void JellyfinApiClient::initialize(){
|
||||||
this->m_token = token;
|
this->m_token = token;
|
||||||
this->setUserId(user);
|
this->setUserId(user);
|
||||||
this->setAuthenticated(true);
|
this->setAuthenticated(true);
|
||||||
|
this->postCapabilities();
|
||||||
disconnect(ctx3);
|
disconnect(ctx3);
|
||||||
}, Qt::UniqueConnection);
|
}, Qt::UniqueConnection);
|
||||||
m_credManager->get(server, user);
|
m_credManager->get(server, user);
|
||||||
|
@ -95,7 +106,7 @@ void JellyfinApiClient::initialize(){
|
||||||
m_credManager->listServers();
|
m_credManager->listServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
void JellyfinApiClient::setupConnection() {
|
void ApiClient::setupConnection() {
|
||||||
// First detect redirects:
|
// First detect redirects:
|
||||||
// Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url,
|
// Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url,
|
||||||
// which is something we want to avoid here.
|
// 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");
|
QNetworkReply *rep = get("/Branding/Configuration");
|
||||||
connect(rep, &QNetworkReply::finished, this, [rep, this]() {
|
connect(rep, &QNetworkReply::finished, this, [rep, this]() {
|
||||||
qDebug() << "RESPONSE: " << statusCode(rep);
|
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;
|
QJsonObject requestData;
|
||||||
|
|
||||||
requestData["Username"] = username;
|
requestData["Username"] = username;
|
||||||
|
@ -173,9 +184,13 @@ void JellyfinApiClient::authenticate(QString username, QString password, bool st
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object();
|
QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object();
|
||||||
this->m_token = authInfo["AccessToken"].toString();
|
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->setUserId(authInfo["User"].toObject()["Id"].toString());
|
||||||
|
this->postCapabilities();
|
||||||
|
this->setAuthenticated(true);
|
||||||
|
|
||||||
if (storeCredentials) {
|
if (storeCredentials) {
|
||||||
m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token);
|
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();
|
rep->deleteLater();
|
||||||
});
|
});
|
||||||
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&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);
|
QNetworkReply *rep = get("/Users/" + m_userId + "/Items/" + id);
|
||||||
connect(rep, &QNetworkReply::finished, this, [rep, id, this]() {
|
connect(rep, &QNetworkReply::finished, this, [rep, id, this]() {
|
||||||
int status = statusCode(rep);
|
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<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&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();
|
QObject *signalSender = sender();
|
||||||
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
|
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
|
||||||
if (rep != nullptr && statusCode(rep) == 401) {
|
if (rep != nullptr && statusCode(rep) == 401) {
|
||||||
|
@ -209,6 +253,7 @@ void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError e
|
||||||
}
|
}
|
||||||
rep->deleteLater();
|
rep->deleteLater();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#undef STR
|
#undef STR
|
||||||
#undef STR2
|
#undef STR2
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QSysInfo>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
|
@ -16,12 +17,41 @@
|
||||||
#include <QUrlQuery>
|
#include <QUrlQuery>
|
||||||
|
|
||||||
#include "credentialmanager.h"
|
#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
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit JellyfinApiClient(QObject *parent = nullptr);
|
explicit ApiClient(QObject *parent = nullptr);
|
||||||
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl NOTIFY baseUrlChanged)
|
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl READ baseUrl NOTIFY baseUrlChanged)
|
||||||
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
|
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
|
||||||
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
|
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
|
||||||
|
|
||||||
|
@ -38,7 +68,7 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
QNetworkReply *get(const QString &path, const QUrlQuery ¶ms = QUrlQuery());
|
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();
|
void getPublicUsers();
|
||||||
|
|
||||||
enum ApiError {
|
enum ApiError {
|
||||||
|
@ -48,7 +78,10 @@ public:
|
||||||
INVALID_PASSWORD
|
INVALID_PASSWORD
|
||||||
};
|
};
|
||||||
|
|
||||||
|
QString &baseUrl() { return this->m_baseUrl; }
|
||||||
QString &userId() { return m_userId; }
|
QString &userId() { return m_userId; }
|
||||||
|
QJsonObject &deviceProfile() { return m_deviceProfile; }
|
||||||
|
QJsonObject &playbackDeviceProfile() { return m_playbackDeviceProfile; }
|
||||||
signals:
|
signals:
|
||||||
/*
|
/*
|
||||||
* Emitted when the server requires authentication. Please authenticate your user via authenticate.
|
* 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,
|
* @brief Tries to access credentials and connect to a server. If nothing has been configured yet,
|
||||||
* emits setupRequired();
|
* emits setupRequired();
|
||||||
*/
|
*/
|
||||||
void initialize();
|
void restoreSavedSession();
|
||||||
/*
|
/*
|
||||||
* Try to connect with the server. Tries to resolve redirects and retrieves information
|
* Try to connect with the server. Tries to resolve redirects and retrieves information
|
||||||
* about the login procedure. Emits connectionSuccess on success, networkError or ConnectionFailed
|
* 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 authenticate(QString username, QString password, bool storeCredentials = false);
|
||||||
void fetchItem(const QString &id);
|
void fetchItem(const QString &id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Shares the capabilities of this device to the server.
|
||||||
|
*/
|
||||||
|
void postCapabilities();
|
||||||
|
|
||||||
protected slots:
|
protected slots:
|
||||||
void defaultNetworkErrorHandler(QNetworkReply::NetworkError error);
|
void defaultNetworkErrorHandler(QNetworkReply::NetworkError error);
|
||||||
|
|
||||||
|
@ -100,19 +138,49 @@ protected:
|
||||||
*/
|
*/
|
||||||
void addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms = QUrlQuery());
|
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)
|
* @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore)
|
||||||
*/
|
*/
|
||||||
void getBrandingConfiguration();
|
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:
|
private:
|
||||||
|
QNetworkAccessManager m_naManager;
|
||||||
|
/*
|
||||||
|
* State information
|
||||||
|
*/
|
||||||
CredentialsManager * m_credManager;
|
CredentialsManager * m_credManager;
|
||||||
QString m_token;
|
QString m_token;
|
||||||
QString m_deviceName;
|
QString m_deviceName;
|
||||||
QString m_deviceId;
|
QString m_deviceId;
|
||||||
|
|
||||||
QString m_userId = "";
|
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) {
|
void setAuthenticated(bool authenticated) {
|
||||||
this->m_authenticated = authenticated;
|
this->m_authenticated = authenticated;
|
||||||
|
@ -123,14 +191,21 @@ private:
|
||||||
emit userIdChanged(userId);
|
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) {
|
static inline int statusCode(QNetworkReply *rep) {
|
||||||
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
} // NS Jellyfin
|
||||||
|
|
||||||
#endif // JELLYFIN_API_CLIENT
|
#endif // JELLYFIN_API_CLIENT
|
||||||
|
|
|
@ -82,7 +82,7 @@ void ApiModel::generateFields() {
|
||||||
QByteArray keyArr = keyName.toUtf8();
|
QByteArray keyArr = keyName.toUtf8();
|
||||||
if (!m_roles.values().contains(keyArr)) {
|
if (!m_roles.values().contains(keyArr)) {
|
||||||
m_roles.insert(i++, keyArr);
|
m_roles.insert(i++, keyArr);
|
||||||
qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
|
//qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this->endResetModel();
|
this->endResetModel();
|
||||||
|
|
|
@ -100,7 +100,7 @@ public:
|
||||||
* Subfield should be set to "data" in this example.
|
* Subfield should be set to "data" in this example.
|
||||||
*/
|
*/
|
||||||
explicit ApiModel(QString path, QString subfield, QObject *parent = nullptr);
|
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(ModelStatus status READ status NOTIFY statusChanged)
|
||||||
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
|
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
|
||||||
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
|
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
|
||||||
|
@ -141,7 +141,7 @@ public slots:
|
||||||
*/
|
*/
|
||||||
void reload();
|
void reload();
|
||||||
protected:
|
protected:
|
||||||
JellyfinApiClient *m_apiClient = nullptr;
|
ApiClient *m_apiClient = nullptr;
|
||||||
ModelStatus m_status = Uninitialised;
|
ModelStatus m_status = Uninitialised;
|
||||||
|
|
||||||
QString m_path;
|
QString m_path;
|
||||||
|
|
172
src/jellyfindeviceprofile.cpp
Normal file
172
src/jellyfindeviceprofile.cpp
Normal file
|
@ -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<QString, QJsonValue>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
33
src/jellyfindeviceprofile.h
Normal file
33
src/jellyfindeviceprofile.h
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
#ifndef JELLYFIN_DEVICE_PROFILE_H
|
||||||
|
#define JELLYFIN_DEVICE_PROFILE_H
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QList>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QString>
|
||||||
|
#include <QSysInfo>
|
||||||
|
|
||||||
|
#include <QtMultimedia/QMediaPlayer>
|
||||||
|
|
||||||
|
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
|
84
src/jellyfinmediasource.cpp
Normal file
84
src/jellyfinmediasource.cpp
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
62
src/jellyfinmediasource.h
Normal file
62
src/jellyfinmediasource.h
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
#ifndef JELLYFIN_MEDIA_SOURCE_H
|
||||||
|
#define JELLYFIN_MEDIA_SOURCE_H
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
#include <QtMultimedia/QMediaPlayer>
|
||||||
|
|
||||||
|
|
||||||
|
#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
|
|
@ -1,5 +1,6 @@
|
||||||
#include "serverdiscoverymodel.h"
|
#include "serverdiscoverymodel.h"
|
||||||
|
|
||||||
|
namespace Jellyfin {
|
||||||
ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent)
|
ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent)
|
||||||
: QAbstractListModel (parent) {
|
: QAbstractListModel (parent) {
|
||||||
connect(&m_socket, &QUdpSocket::readyRead, this, &ServerDiscoveryModel::on_datagramsAvailable);
|
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());
|
m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end());
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
#include <QHostAddress>
|
#include <QHostAddress>
|
||||||
#include <QUdpSocket>
|
#include <QUdpSocket>
|
||||||
|
|
||||||
|
namespace Jellyfin {
|
||||||
struct ServerDiscovery {
|
struct ServerDiscovery {
|
||||||
QString name;
|
QString name;
|
||||||
QString address;
|
QString address;
|
||||||
|
@ -59,5 +60,5 @@ private:
|
||||||
QUdpSocket m_socket;
|
QUdpSocket m_socket;
|
||||||
std::vector<ServerDiscovery> m_discoveredServers;
|
std::vector<ServerDiscovery> m_discoveredServers;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
#endif //SERVER_DISCOVERY_MODEL_H
|
#endif //SERVER_DISCOVERY_MODEL_H
|
||||||
|
|
Loading…
Reference in a new issue