1
0
Fork 0
mirror of https://github.com/HenkKalkwater/harbour-sailfin.git synced 2025-09-05 18:22:46 +00:00

Initial commit

Features so far:
- Login is working, both on back-end and GUI-wise
- Saving and reusing login tokens is working
- The home page is mostly functional
- Show details can be received and displayed in a basic manner

Following features are taken into account, but have not been fully
implemented:
- Support for multiple accounts/servers
- Securely saving login tokens
This commit is contained in:
Chris Josten 2020-09-15 16:53:13 +02:00
commit 53b3eac213
40 changed files with 2375 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
Page {
property string serverName
property string serverAddress
property Page firstPage
allowedOrientations: Orientation.All
BusyLabel {
text: qsTr("Connecting to %1").arg(serverName)
running: true
}
onStatusChanged: {
if (status == PageStatus.Active) {
console.log("Connecting page active");
ApiClient.setupConnection();
}
}
Connections {
target: ApiClient
onConnectionSuccess: {
console.log("Login success: " + loginMessage);
pageStack.replace(Qt.resolvedUrl("LoginDialog.qml"), {"loginMessage": loginMessage, "firstPage": firstPage});
}
onConnectionFailed: function(error) {
console.log("Connection failed : " + error)
pageStack.pop();
}
onNetworkError: {
console.log("ConnectingPage: popping page!")
pageStack.pop();
}
}
}

View file

@ -0,0 +1,89 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
Dialog {
id: dialogRoot
allowedOrientations: Orientation.All
// Picks the address of the ComboBox if selected, otherwise the manual address entry
readonly property string address: serverSelect.currentItem._address
readonly property bool addressCorrect: serverSelect.currentIndex > 0 || manualAddress.acceptableInput
readonly property string serverName: serverSelect.currentItem._name
acceptDestination: AddServerConnectingPage {
id: connectingPage
serverName: dialogRoot.serverName
serverAddress: address
firstPage: dialogRoot
}
Column {
width: parent.width
DialogHeader {
acceptText: qsTr("Connect")
title: qsTr("Connect to Jellyfin")
}
ServerDiscoveryModel {
id: serverModel
}
ComboBox {
id: serverSelect
label: qsTr("Server")
description: qsTr("Sailfin will try to search for Jellyfin servers on your local network automatically")
menu: ContextMenu {
MenuItem {
// Special values are cool, aren't they?
readonly property string _address: manualAddress.text
readonly property string _name: manualAddress.text
text: qsTr("enter address manually")
}
Repeater {
model: serverModel
delegate: MenuItem {
readonly property string _address: address
readonly property string _name: name
text: qsTr("%1 - %2").arg(name).arg(address)
}
}
}
}
TextField {
id: manualAddress
width: parent.width
clip: true
label: qsTr("Server address")
placeholderText: qsTr("e.g. https://demo.jellyfin.org")
enabled: serverSelect.currentIndex == 0
visible: enabled
inputMethodHints: Qt.ImhUrlCharactersOnly
validator: RegExpValidator {
regExp: /^https?:\/\/[a-zA-Z0-9-._~:/?#\[\]\@\!\$\&\'\(\)\*\+\,\;\=]+$/m
}
EnterKey.enabled: addressCorrect
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
EnterKey.onClicked: dialogRoot.tryConnect()
}
}
onOpened: serverModel.refresh()
canAccept: addressCorrect
function tryConnect() {
console.log("Hi there!")
ApiClient.baseUrl = address;
//ApiClient.setupConnection()
//fakeTimer.start()
}
onDone: tryConnect()
}

View file

@ -0,0 +1,138 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Page {
id: pageRoot
property string itemId: ""
property var itemData: ({})
property bool _loading: true
readonly property bool _hasLogo: itemData.ImageTags.Logo !== undefined
readonly property string _logo: itemData.ImageTags.Logo
readonly property var _backdropImages: itemData.BackdropImageTags
readonly property var _parentBackdropImages: itemData.ParentBackdropImageTags
readonly property string parentId: itemData.ParentId
on_BackdropImagesChanged: updateBackdrop()
on_ParentBackdropImagesChanged: updateBackdrop()
function updateBackdrop() {
if (_backdropImages && _backdropImages.length > 0) {
var rand = Math.floor(Math.random() * (_backdropImages.length - 0.001))
console.log("Random: ", rand)
backdrop.source = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand]
} else if (_parentBackdropImages && _parentBackdropImages.length > 0) {
console.log(parentId)
backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0]
}
}
allowedOrientations: Orientation.All
GlassyBackground {
id: backdrop
anchors.fill: parent
}
SilicaFlickable {
anchors.fill: parent
contentHeight: content.height
Column {
id: content
width: parent.width
PageHeader {
title: itemData.Name
visible: !_hasLogo
}
Column {
width: parent.width
Item {
width: 1
height: Theme.paddingLarge
}
RemoteImage {
anchors {
horizontalCenter: parent.horizontalCenter
}
source: _hasLogo ? ApiClient.baseUrl + "/Items/" + itemId + "/Images/Logo?tag=" + _logo : undefined
}
Item {
width: 1
height: Theme.paddingLarge
}
visible: _hasLogo
}
Item {
width: 1
height: Theme.paddingLarge
}
PlainLabel {
id: overviewText
text: itemData.Overview
visible: text.length > 0
font.pixelSize: Theme.fontSizeSmall
}
Item {
visible: overviewText.visible
width: 1
height: Theme.paddingLarge
}
Row {
anchors {
//left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
spacing: Theme.paddingMedium
IconButton {
id: favouriteButton
icon.source: "image://theme/icon-m-favorite"
}
IconButton {
id: playButton
icon.source: "image://theme/icon-l-play"
}
}
}
}
PageBusyIndicator {
running: pageRoot._loading
}
onItemIdChanged: {
itemData = {}
if (itemId.length > 0) {
pageRoot._loading = true
ApiClient.fetchItem(itemId)
}
}
onStatusChanged: {
if (status == PageStatus.Deactivating) {
backdrop.clear()
}
}
Connections {
target: ApiClient
onItemFetched: {
if (itemId === pageRoot.itemId) {
console.log(JSON.stringify(result))
pageRoot.itemData = result
pageRoot._loading = false
}
}
}
}

104
qml/pages/LegalPage.qml Normal file
View file

@ -0,0 +1,104 @@
import QtQuick 2.6
import QtQuick.XmlListModel 2.0
import Sailfish.Silica 1.0
import "../components"
Page {
allowedOrientations: Orientation.All
SilicaFlickable {
anchors.fill: parent
contentHeight: content.height
Column {
id: content
width: parent.width
XmlListModel {
id: licencesModel
source: Qt.resolvedUrl("../3rdparty.xml")
query: "/includes/include"
XmlRole { name: "name"; query: "name/string()" }
XmlRole { name: "type"; query: "type/string()" }
XmlRole { name: "url"; query: "url/string()" }
XmlRole { name: "copyright"; query: "license/copyright/string()" }
XmlRole { name: "licenseUrl"; query: "license/text/string()" }
XmlRole { name: "licenseType"; query: "license/type/string()" }
}
PageHeader {
title: qsTr("Legal")
}
PlainLabel {
text: qsTr("The Sailfin application contains some code from other projects. Without them, Sailfin would " +
"not be possible!")
}
Repeater {
model: licencesModel
Column {
width: parent.width
SectionHeader {
text: name
}
PlainLabel {
color: Theme.secondaryHighlightColor
text: {
switch(type) {
case "SNIPPET":
return qsTr("This program contains small snippets of code taken from <a href=\"%1\">%2</a>, which " +
"is licensed under the %3 license:")
.arg(model.url).arg(model.name).arg(model.licenseType);
}
}
}
Item {
width: 1
height: Theme.paddingLarge
}
SilicaFlickable {
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
height: licenseLabel.contentHeight
contentWidth: licenseLabel.contentWidth
clip: true
Label {
id: licenseLabel
color: Theme.secondaryHighlightColor
font.family: "monospace"
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.NoWrap
Component.onCompleted: {
var xhr = new XMLHttpRequest;
xhr.open("GET", Qt.resolvedUrl("../" + model.licenseUrl)); // set Method and File
console.log(Qt.resolvedUrl("../" + model.licenseUrl))
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE){ // if request_status == DONE
var response = model.copyright + "\n\n" + xhr.responseText;
console.log(response);
licenseLabel.text = response
}
}
xhr.send(); // begin the request
}
}
HorizontalScrollDecorator {}
}
}
}
VerticalScrollDecorator {}
}
}
}

127
qml/pages/LoginDialog.qml Normal file
View file

@ -0,0 +1,127 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Dialog {
property string loginMessage
property Page firstPage
allowedOrientations: Orientation.All
acceptDestination: Page {
BusyLabel {
text: qsTr("Logging in as %1").arg(username.text)
running: true
}
onStatusChanged: {
if(status == PageStatus.Active) {
ApiClient.authenticate(username.text, password.text, true)
}
}
Connections {
target: ApiClient
onAuthenticatedChanged: {
if (ApiClient.authenticated) {
console.log("authenticated!")
pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("MainPage.qml"))
}
}
onAuthenticationError: {
pageStack.completeAnimation()
pageStack.pop()
}
}
}
PublicUserModel {
id: userModel
apiClient: ApiClient
Component.onCompleted: reload();
}
DialogHeader {
id: dialogHeader
anchors.left: parent.left
anchors.right: parent.right
acceptText: qsTr("Login");
}
SilicaFlickable {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: dialogHeader.bottom
anchors.bottom: parent.bottom
contentHeight: column.height
clip: true
VerticalScrollDecorator {}
Column {
id: column
width: parent.width
Flow {
width: parent.width
Repeater {
model: userModel
delegate: UserGridDelegate {
name: model.name
image: model.primaryImageTag ? "%1/Users/%2/Images/Primary?tag=%3".arg(ApiClient.baseUrl).arg(model.id).arg(model.primaryImageTag) : null
highlighted: model.name == username.text
onClicked: {
username.text = model.name
password.focus = true
}
}
}
}
SectionHeader {
text: qsTr("Credentials")
}
TextField {
id: username
width: parent.width
placeholderText: qsTr("Username")
label: qsTr("Username")
EnterKey.iconSource: "image://theme/icon-m-enter-next"
EnterKey.onClicked: password.focus = true
}
TextField {
id: password
width: parent.width
placeholderText: qsTr("Password")
label: qsTr("password")
echoMode: TextInput.Password
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
EnterKey.onClicked: login()
}
SectionHeader {
text: qsTr("Login message")
}
Label {
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: loginMessage
wrapMode: Text.WordWrap
color: Theme.highlightColor
}
}
}
canAccept: username.text.length > 0
/*onAccepted: {
pageStack.replace(Qt.resolvedUrl("MainPage.qml"))
}*/
}

125
qml/pages/MainPage.qml Normal file
View file

@ -0,0 +1,125 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Page {
id: page
// The effective value will be restricted by ApplicationWindow.allowedOrientations
allowedOrientations: Orientation.All
property bool _modelsLoaded: false
Connections {
target: ApiClient
onAuthenticatedChanged: {
if (authenticated && !_modelsLoaded) loadModels();
}
}
Component.onCompleted: {
if (ApiClient.authenticated && _modelsLoaded) {
loadModels();
}
}
// To enable PullDownMenu, place our content in a SilicaFlickable
SilicaFlickable {
anchors.fill: parent
// PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView
PullDownMenu {
MenuItem {
text: qsTr("About")
onClicked: pageStack.push(Qt.resolvedUrl("LegalPage.qml"))
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SecondPage.qml"))
}
}
// Tell SilicaFlickable the height of its content.
contentHeight: column.height
// Place our content in a Column. The PageHeader is always placed at the top
// of the page, followed by our content.
Column {
id: column
width: page.width
//spacing: Theme.paddingLarge
UserViewModel {
id: mediaLibraryModel2
apiClient: ApiClient
}
MoreSection {
text: "Kijken hervatten"
enabled: false
}
MoreSection {
text: "Volgende"
}
UserViewModel {
id: mediaLibraryModel
apiClient: ApiClient
}
Repeater {
model: mediaLibraryModel
MoreSection {
text: model.name
busy: userItemModel.status != ApiModel.Ready
SilicaListView {
clip: true
height: count > 0 ? Screen.width / 4 : 0
Behavior on height {
NumberAnimation { duration: 300 }
}
width: parent.width
model: userItemModel
orientation: ListView.Horizontal
delegate: LibraryItemDelegate {
property string id: model.id
title: model.name
poster: 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})
}
}
HorizontalScrollDecorator {}
UserItemModel {
id: userItemModel
apiClient: ApiClient
parentId: model.id
limit: 12
}
Connections {
target: mediaLibraryModel
onStatusChanged: {
if (status == ApiModel.Ready) {
userItemModel.reload()
}
}
}
}
}
}
}
}
function loadModels() {
_modelsLoaded = true;
mediaLibraryModel.reload()
}
}

30
qml/pages/SecondPage.qml Normal file
View file

@ -0,0 +1,30 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
// The effective value will be restricted by ApplicationWindow.allowedOrientations
allowedOrientations: Orientation.All
SilicaListView {
id: listView
model: 20
anchors.fill: parent
header: PageHeader {
title: qsTr("Nested Page")
}
delegate: BackgroundItem {
id: delegate
Label {
x: Theme.horizontalPageMargin
text: qsTr("Item") + " " + index
anchors.verticalCenter: parent.verticalCenter
color: delegate.highlighted ? Theme.highlightColor : Theme.primaryColor
}
onClicked: console.log("Clicked " + index)
}
VerticalScrollDecorator {}
}
}