diff --git a/harbour-sailfin.pro b/harbour-sailfin.pro index a0e7986..917c7fc 100644 --- a/harbour-sailfin.pro +++ b/harbour-sailfin.pro @@ -37,6 +37,7 @@ DISTFILES += \ qml/components/MoreSection.qml \ qml/components/PlainLabel.qml \ qml/components/RemoteImage.qml \ + qml/components/Shim.qml \ qml/components/UserGridDelegate.qml \ qml/components/VideoPlayer.qml \ qml/components/itemdetails/CollectionFolder.qml \ @@ -51,6 +52,7 @@ DISTFILES += \ qml/cover/CoverPage.qml \ qml/cover/PosterCover.qml \ qml/cover/VideoCover.qml \ + qml/pages/CollectionPage.qml \ qml/pages/DetailPage.qml \ qml/pages/LegalPage.qml \ qml/pages/MainPage.qml \ diff --git a/qml/Constants.qml b/qml/Constants.qml index 96abbd7..ea64c78 100644 --- a/qml/Constants.qml +++ b/qml/Constants.qml @@ -7,4 +7,5 @@ QtObject { readonly property real libraryDelegateHeight: Screen.width / 3 readonly property real libraryDelegatePosterHeight: Screen.width / 2 + readonly property real libraryProgressHeight: Theme.paddingMedium } diff --git a/qml/Utils.js b/qml/Utils.js index c5257dd..6801a8a 100644 --- a/qml/Utils.js +++ b/qml/Utils.js @@ -35,5 +35,5 @@ function itemModelImageUrl(baseUrl, itemId, tag, type, options) { } function usePortraitCover(itemType) { - return ["Series", "Movie"].indexOf(itemType) >= 0 + return ["Series", "Movie", "tvshows", "movies"].indexOf(itemType) >= 0 } diff --git a/qml/components/LibraryItemDelegate.qml b/qml/components/LibraryItemDelegate.qml index 55a2299..38b3297 100644 --- a/qml/components/LibraryItemDelegate.qml +++ b/qml/components/LibraryItemDelegate.qml @@ -11,6 +11,8 @@ BackgroundItem { property alias poster: posterImage.source property alias title: titleText.text property bool landscape: false + property real progress: 0.0 + width: Constants.libraryDelegateWidth height: landscape ? Constants.libraryDelegateHeight : Constants.libraryDelegatePosterHeight @@ -32,17 +34,14 @@ BackgroundItem { visible: root.highlighted }*/ - Rectangle { + Shim { anchors { left: parent.left right: parent.right bottom: parent.bottom } height: titleText.height * 1.5 + Theme.paddingSmall * 2 - gradient: Gradient { - GradientStop { position: 0.0; color: "transparent"; } - GradientStop { position: 1.0; color: Theme.highlightDimmerColor } - } + } Label { @@ -58,4 +57,15 @@ BackgroundItem { truncationMode: TruncationMode.Fade horizontalAlignment: Text.AlignLeft } + + Rectangle { + id: progress + anchors { + left: parent.left + bottom: parent.bottom + } + height: Theme.paddingSmall + color: Theme.highlightColor + width: root.progress * parent.width + } } diff --git a/qml/components/Shim.qml b/qml/components/Shim.qml new file mode 100644 index 0000000..80c1d91 --- /dev/null +++ b/qml/components/Shim.qml @@ -0,0 +1,12 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +Rectangle { + property real shimOpacity: 1.0 + property color shimColor: Theme.overlayBackgroundColor + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.rgba(shimColor, 0.0); } + GradientStop { position: 1.0; color: Theme.rgba(shimColor, shimOpacity); } + } + +} diff --git a/qml/components/itemdetails/CollectionFolder.qml b/qml/components/itemdetails/CollectionFolder.qml index 9c36e13..28521e3 100644 --- a/qml/components/itemdetails/CollectionFolder.qml +++ b/qml/components/itemdetails/CollectionFolder.qml @@ -1,4 +1,4 @@ -import QtQuick 2.0 +import QtQuick 2.6 Item { diff --git a/qml/components/itemdetails/SeasonDetails.qml b/qml/components/itemdetails/SeasonDetails.qml index cff1b49..d9a73dc 100644 --- a/qml/components/itemdetails/SeasonDetails.qml +++ b/qml/components/itemdetails/SeasonDetails.qml @@ -27,7 +27,6 @@ Column { anchors { top: parent.top left: parent.left - leftMargin: Theme.horizontalPageMargin bottom: parent.bottom } width: Constants.libraryDelegateWidth @@ -35,6 +34,31 @@ Column { source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) fillMode: Image.PreserveAspectCrop clip: true + + // Makes the progress bar stand out more + Shim { + anchors { + left: parent.left + bottom: parent.bottom + right: parent.right + } + height: parent.height / 3 + shimColor: Theme.overlayBackgroundColor + shimOpacity: Theme.opacityOverlay + //width: model.userData.PlayedPercentage * parent.width / 100 + visible: episodeProgress.width > 0 // It doesn't look nice when it's visible on every image + } + + Rectangle { + id: episodeProgress + anchors { + left: parent.left + bottom: parent.bottom + } + height: Theme.paddingMedium + width: model.userData.PlayedPercentage * parent.width / 100 + color: Theme.highlightColor + } } Label { diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml index ee6da55..6a9927c 100644 --- a/qml/cover/CoverPage.qml +++ b/qml/cover/CoverPage.qml @@ -22,6 +22,7 @@ CoverBackground { imageTypes: ["Primary"] sortBy: ["IsFavoriteOrLiked", "Random"] recursive: true + parentId: appWindow.collectionId Component.onCompleted: reload() } @@ -32,6 +33,7 @@ CoverBackground { imageTypes: ["Primary"] sortBy: ["IsFavoriteOrLiked", "Random"] recursive: true + parentId: appWindow.collectionId Component.onCompleted: reload() } @@ -59,7 +61,8 @@ CoverBackground { clip: true height: row1.height width: height - source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": row1.height}) + source: model.id ? Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": row1.height}) + : "" fillMode: Image.PreserveAspectCrop } } @@ -120,6 +123,16 @@ CoverBackground { } } + Connections { + target: appWindow + onCollectionIdChanged: { + randomItems1.parentId = collectionId + randomItems2.parentId = collectionId + randomItems1.reload() + randomItems2.reload() + } + } + Timer { property bool odd: false running: true diff --git a/qml/harbour-sailfin.qml b/qml/harbour-sailfin.qml index f048e91..6489664 100644 --- a/qml/harbour-sailfin.qml +++ b/qml/harbour-sailfin.qml @@ -12,16 +12,11 @@ ApplicationWindow { property bool _hasInitialized: false // The global mediaPlayer instance readonly property MediaPlayer mediaPlayer: _mediaPlayer - property url backgroundSource - onBackgroundSourceChanged: { - if (backgroundSource) { - appWindow._overlayBackgroundSource.backgroundItem.source = backgroundSource - } else { - appWindow._overlayBackgroundSource.backgroundItem.source = Theme.backgroundImage - } - } + // Data of the currently selected item. For use on the cover. property var itemData + // Id of the collection currently browsing. For use on the cover. + property string collectionId //FIXME: proper error handling Connections { diff --git a/qml/pages/CollectionPage.qml b/qml/pages/CollectionPage.qml new file mode 100644 index 0000000..b1f0ce3 --- /dev/null +++ b/qml/pages/CollectionPage.qml @@ -0,0 +1,179 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 + +import ".." +import "../components" +import "../Utils.js" as Utils + +Page { + id: pageRoot + property var itemId + property var itemData + property bool _loading: true + + UserItemModel { + id: collectionModel + apiClient: ApiClient + parentId: itemData.Id || "" + sortBy: ["SortName"] + } + + SilicaGridView { + id: gridView + anchors.fill: parent + model: collectionModel + cellWidth: Constants.libraryDelegateWidth + cellHeight: Utils.usePortraitCover(itemData.CollectionType) ? Constants.libraryDelegatePosterHeight + : Constants.libraryDelegateHeight + header: PageHeader { + title: itemData.Name || qsTr("Loading") + } + PullDownMenu { + id: downMenu + MenuItem { + //: Menu item for selecting the sort order of a collection + text: qsTr("Sort by") + onClicked: pageStack.push(sortPageComponent) + } + busy: collectionModel.status == ApiModel.Loading + } + delegate: GridItem { + RemoteImage { + id: itemImage + anchors.fill: parent + source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxWidth": width}) + fillMode: Image.PreserveAspectCrop + clip: true + } + Rectangle { + anchors { + left: parent.left + bottom: parent.bottom + right: parent.right + } + height: itemName.height + Theme.paddingSmall * 2 + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Theme.highlightDimmerColor } + } + visible: itemImage.status !== Image.Null + } + Label { + id: itemName + anchors { + left: parent.left + leftMargin: Theme.paddingMedium + right: parent.right + rightMargin: Theme.paddingMedium + bottom: parent.bottom + bottomMargin: Theme.paddingSmall + } + text: model.name + truncationMode: TruncationMode.Fade + horizontalAlignment: Text.AlignLeft + font.pixelSize: Theme.fontSizeSmall + } + onClicked: { + switch(model.type) { + case "Folder": + pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), {"itemId": model.id}) + break; + default: + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) + } + } + } + + ViewPlaceholder { + enabled: gridView.count == 0 && !pageRoot._loading + text: qsTr("Empty collection") + hintText: qsTr("Add some items to this collection!") + } + + VerticalScrollDecorator {} + } + + PageBusyIndicator { + running: pageRoot._loading + } + + onItemIdChanged: { + itemData = {} + if (itemId.length && PageStatus.Active) { + pageRoot._loading = true + ApiClient.fetchItem(itemId) + } + } + + onStatusChanged: { + if (status == PageStatus.Deactivating) { + backdrop.clear() + } + if (status == PageStatus.Active) { + if (itemId && !itemData) { + ApiClient.fetchItem(itemId) + appWindow.collectionId = itemId + } + + } + } + + Connections { + target: ApiClient + onItemFetched: { + if (itemId === pageRoot.itemId) { + pageRoot.itemData = result + pageRoot._loading = false + console.log(JSON.stringify(result)) + collectionModel.parentId = result.Id + collectionModel.reload() + if (status == PageStatus.Active) { + appWindow.itemData = null + appWindow.collectionId = itemId + } + } + } + } + + Component { + id: sortPageComponent + Page { + id: sortPage + + ListModel { + id: sortOptions + ListElement { name: qsTr("Name"); value: "SortName"; } + ListElement { name: qsTr("Play count"); value: "PlayCount"; } + ListElement { name: qsTr("Date added"); value: "DateCreated"; } + } + + SilicaListView { + anchors.fill: parent + model: sortOptions + header: PageHeader { + title: qsTr("Sort by") + } + delegate: ListItem { + Label { + anchors { + left: parent.left + leftMargin: Theme.horizontalPageMargin + right: parent.right + rightMargin: Theme.horizontalPageMargin + verticalCenter: parent.verticalCenter + } + text: model.name + } + onClicked: { + collectionModel.sortBy = [model.value] + collectionModel.reload() + pageStack.pop() + } + } + } + } + } + +} diff --git a/qml/pages/DetailPage.qml b/qml/pages/DetailPage.qml index 8b9a114..78f533a 100644 --- a/qml/pages/DetailPage.qml +++ b/qml/pages/DetailPage.qml @@ -30,13 +30,10 @@ Page { 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] + "&maxHeight" + height - appWindow.backgroundSource = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand] + "&maxHeight" + height + backdrop.source = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand] + "&maxHeight" + height } else if (_parentBackdropImages && _parentBackdropImages.length > 0) { console.log(parentId) - //backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0] - appWindow.backgroundSource = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0] - Theme.backgroundGlowColor + backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0] } } diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml index 922fedf..7959200 100644 --- a/qml/pages/MainPage.qml +++ b/qml/pages/MainPage.qml @@ -16,10 +16,6 @@ Page { id: page allowedOrientations: Orientation.All - ViewPlaceholder { - - } - SilicaFlickable { anchors.fill: parent @@ -50,6 +46,20 @@ Page { MoreSection { text: qsTr("Resume watching") clickable: false + busy: userResumeModel.status == ApiModel.Loading + Loader { + width: parent.width + sourceComponent: carrouselView + property alias itemModel: userResumeModel + property string collectionType: "series" + + UserItemResumeModel { + id: userResumeModel + apiClient: ApiClient + limit: 12 + recursive: true + } + } } MoreSection { text: qsTr("Next up") @@ -65,45 +75,14 @@ Page { MoreSection { text: model.name busy: userItemModel.status != ApiModel.Ready - property string collectionType: model.collectionType || "" - onHeaderClicked: pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) - - SilicaListView { - clip: true - height: { - if (count > 0) { - if (["tvshows", "movies"].indexOf(collectionType) == -1) { - Constants.libraryDelegateHeight - } else { - Constants.libraryDelegatePosterHeight - } - } else { - 0 - } - } - Behavior on height { - NumberAnimation { duration: 300 } - } + onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), {"itemId": model.id}) + Loader { width: parent.width - model: userItemModel - orientation: ListView.Horizontal - leftMargin: Theme.horizontalPageMargin - rightMargin: Theme.horizontalPageMargin - spacing: Theme.paddingLarge - delegate: LibraryItemDelegate { - property string id: model.id - title: model.name - poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) - /*model.imageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + model.id - + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] - : ""*/ - landscape: !Utils.usePortraitCover(model.type) + sourceComponent: carrouselView + property alias itemModel: userItemModel + property string collectionType: model.collectionType || "" - onClicked: { - pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) - } - } UserItemLatestModel { id: userItemModel apiClient: ApiClient @@ -165,6 +144,49 @@ Page { if (force || (ApiClient.authenticated && !_modelsLoaded)) { _modelsLoaded = true; mediaLibraryModel.reload() + userResumeModel.reload() + } + } + + Component { + id: carrouselView + SilicaListView { + id: list + clip: true + height: { + if (count > 0) { + if (["tvshows", "movies"].indexOf(collectionType) == -1) { + Constants.libraryDelegateHeight + } else { + Constants.libraryDelegatePosterHeight + } + } else { + 0 + } + } + Behavior on height { + NumberAnimation { duration: 300 } + } + model: itemModel + width: parent.width + orientation: ListView.Horizontal + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + spacing: Theme.paddingLarge + delegate: LibraryItemDelegate { + property string id: model.id + title: model.name + poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) + /*model.imageTags["Primary"] ? ApiClient.baseUrl + "/Items/" + model.id + + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"] + : ""*/ + landscape: !Utils.usePortraitCover(model.type) + progress: model.userData.PlayedPercentage / 100 + + onClicked: { + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) + } + } } } } diff --git a/src/jellyfinapimodel.cpp b/src/jellyfinapimodel.cpp index 16b3bbe..6740c74 100644 --- a/src/jellyfinapimodel.cpp +++ b/src/jellyfinapimodel.cpp @@ -9,19 +9,13 @@ ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject } void ApiModel::reload() { + this->setStatus(Loading); + m_startIndex = 0; load(RELOAD); } void ApiModel::load(LoadType type) { qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); - switch(type) { - case RELOAD: - this->setStatus(Loading); - break; - case LOAD_MORE: - this->setStatus(LoadingMore); - break; - } if (m_apiClient == nullptr) { qWarning() << "Please set the apiClient property before (re)loading"; return; @@ -187,6 +181,7 @@ bool ApiModel::canFetchMore(const QModelIndex &parent) const { void ApiModel::fetchMore(const QModelIndex &parent) { if (parent.isValid()) return; + this->setStatus(LoadingMore); load(LOAD_MORE); } @@ -200,6 +195,7 @@ void registerModels(const char *URI) { qmlRegisterType(URI, 1, 0, "UserViewModel"); qmlRegisterType(URI, 1, 0, "UserItemModel"); qmlRegisterType(URI, 1, 0, "UserItemLatestModel"); + qmlRegisterType(URI, 1, 0, "UserItemResumeModel"); qmlRegisterType(URI, 1, 0, "ShowSeasonsModel"); qmlRegisterType(URI, 1, 0, "ShowEpisodesModel"); } diff --git a/src/jellyfinapimodel.h b/src/jellyfinapimodel.h index 4c442f2..9b0dd7d 100644 --- a/src/jellyfinapimodel.h +++ b/src/jellyfinapimodel.h @@ -195,7 +195,7 @@ protected: QList m_fields; QList m_imageTypes; QList m_sortBy = {}; - bool m_recursive; + bool m_recursive = false; QHash m_roles; @@ -218,13 +218,13 @@ private: class PublicUserModel : public ApiModel { public: explicit PublicUserModel (QObject *parent = nullptr) - : ApiModel ("/users/public", "", false, parent) { } + : ApiModel ("/users/public", false, false, parent) { } }; class UserViewModel : public ApiModel { public: explicit UserViewModel (QObject *parent = nullptr) - : ApiModel ("/Users/{{user}}/Views", "Items", false, parent) {} + : ApiModel ("/Users/{{user}}/Views", true, false, parent) {} }; class UserItemModel : public ApiModel { @@ -232,6 +232,13 @@ public: explicit UserItemModel (QObject *parent = nullptr) : ApiModel ("/Users/{{user}}/Items", true, false, parent) {} }; + +class UserItemResumeModel : public ApiModel { +public: + explicit UserItemResumeModel (QObject *parent = nullptr) + : ApiModel ("/Users/{{user}}/Items/Resume", true, false, parent) {} +}; + class UserItemLatestModel : public ApiModel { public: explicit UserItemLatestModel (QObject *parent = nullptr) diff --git a/translations/harbour-sailfin.ts b/translations/harbour-sailfin.ts index d4025b5..50ce80e 100644 --- a/translations/harbour-sailfin.ts +++ b/translations/harbour-sailfin.ts @@ -58,6 +58,38 @@ + + CollectionPage + + Loading + + + + Sort by + Menu item for selecting the sort order of a collection + + + + Empty collection + + + + Add some items to this collection! + + + + Name + + + + Play count + + + + Date added + + + CoverPage