1
0
Fork 0
mirror of https://github.com/HenkKalkwater/harbour-sailfin.git synced 2024-11-22 09:15:18 +00:00

Implemented collections + misc UI improvements

* There is a basic collection page, allowing the user to browse through
  collections. It has a sort function, that sort of works
* Item cards now show a bar indicating play time
* Item cards now have a black/white (depending on theme) shim, improving
  readability.
* The resume watching section now actually loads items
This commit is contained in:
Chris Josten 2020-09-27 16:54:45 +02:00
parent 5ea17070fe
commit 5d395ad7b6
15 changed files with 363 additions and 73 deletions

View file

@ -37,6 +37,7 @@ DISTFILES += \
qml/components/MoreSection.qml \ qml/components/MoreSection.qml \
qml/components/PlainLabel.qml \ qml/components/PlainLabel.qml \
qml/components/RemoteImage.qml \ qml/components/RemoteImage.qml \
qml/components/Shim.qml \
qml/components/UserGridDelegate.qml \ qml/components/UserGridDelegate.qml \
qml/components/VideoPlayer.qml \ qml/components/VideoPlayer.qml \
qml/components/itemdetails/CollectionFolder.qml \ qml/components/itemdetails/CollectionFolder.qml \
@ -51,6 +52,7 @@ DISTFILES += \
qml/cover/CoverPage.qml \ qml/cover/CoverPage.qml \
qml/cover/PosterCover.qml \ qml/cover/PosterCover.qml \
qml/cover/VideoCover.qml \ qml/cover/VideoCover.qml \
qml/pages/CollectionPage.qml \
qml/pages/DetailPage.qml \ qml/pages/DetailPage.qml \
qml/pages/LegalPage.qml \ qml/pages/LegalPage.qml \
qml/pages/MainPage.qml \ qml/pages/MainPage.qml \

View file

@ -7,4 +7,5 @@ QtObject {
readonly property real libraryDelegateHeight: Screen.width / 3 readonly property real libraryDelegateHeight: Screen.width / 3
readonly property real libraryDelegatePosterHeight: Screen.width / 2 readonly property real libraryDelegatePosterHeight: Screen.width / 2
readonly property real libraryProgressHeight: Theme.paddingMedium
} }

View file

@ -35,5 +35,5 @@ function itemModelImageUrl(baseUrl, itemId, tag, type, options) {
} }
function usePortraitCover(itemType) { function usePortraitCover(itemType) {
return ["Series", "Movie"].indexOf(itemType) >= 0 return ["Series", "Movie", "tvshows", "movies"].indexOf(itemType) >= 0
} }

View file

@ -11,6 +11,8 @@ BackgroundItem {
property alias poster: posterImage.source property alias poster: posterImage.source
property alias title: titleText.text property alias title: titleText.text
property bool landscape: false property bool landscape: false
property real progress: 0.0
width: Constants.libraryDelegateWidth width: Constants.libraryDelegateWidth
height: landscape ? Constants.libraryDelegateHeight : Constants.libraryDelegatePosterHeight height: landscape ? Constants.libraryDelegateHeight : Constants.libraryDelegatePosterHeight
@ -32,17 +34,14 @@ BackgroundItem {
visible: root.highlighted visible: root.highlighted
}*/ }*/
Rectangle { Shim {
anchors { anchors {
left: parent.left left: parent.left
right: parent.right right: parent.right
bottom: parent.bottom bottom: parent.bottom
} }
height: titleText.height * 1.5 + Theme.paddingSmall * 2 height: titleText.height * 1.5 + Theme.paddingSmall * 2
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent"; }
GradientStop { position: 1.0; color: Theme.highlightDimmerColor }
}
} }
Label { Label {
@ -58,4 +57,15 @@ BackgroundItem {
truncationMode: TruncationMode.Fade truncationMode: TruncationMode.Fade
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
} }
Rectangle {
id: progress
anchors {
left: parent.left
bottom: parent.bottom
}
height: Theme.paddingSmall
color: Theme.highlightColor
width: root.progress * parent.width
}
} }

12
qml/components/Shim.qml Normal file
View file

@ -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); }
}
}

View file

@ -1,4 +1,4 @@
import QtQuick 2.0 import QtQuick 2.6
Item { Item {

View file

@ -27,7 +27,6 @@ Column {
anchors { anchors {
top: parent.top top: parent.top
left: parent.left left: parent.left
leftMargin: Theme.horizontalPageMargin
bottom: parent.bottom bottom: parent.bottom
} }
width: Constants.libraryDelegateWidth width: Constants.libraryDelegateWidth
@ -35,6 +34,31 @@ Column {
source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height}) source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["Primary"], "Primary", {"maxHeight": height})
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
clip: true 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 { Label {

View file

@ -22,6 +22,7 @@ CoverBackground {
imageTypes: ["Primary"] imageTypes: ["Primary"]
sortBy: ["IsFavoriteOrLiked", "Random"] sortBy: ["IsFavoriteOrLiked", "Random"]
recursive: true recursive: true
parentId: appWindow.collectionId
Component.onCompleted: reload() Component.onCompleted: reload()
} }
@ -32,6 +33,7 @@ CoverBackground {
imageTypes: ["Primary"] imageTypes: ["Primary"]
sortBy: ["IsFavoriteOrLiked", "Random"] sortBy: ["IsFavoriteOrLiked", "Random"]
recursive: true recursive: true
parentId: appWindow.collectionId
Component.onCompleted: reload() Component.onCompleted: reload()
} }
@ -59,7 +61,8 @@ CoverBackground {
clip: true clip: true
height: row1.height height: row1.height
width: 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 fillMode: Image.PreserveAspectCrop
} }
} }
@ -120,6 +123,16 @@ CoverBackground {
} }
} }
Connections {
target: appWindow
onCollectionIdChanged: {
randomItems1.parentId = collectionId
randomItems2.parentId = collectionId
randomItems1.reload()
randomItems2.reload()
}
}
Timer { Timer {
property bool odd: false property bool odd: false
running: true running: true

View file

@ -12,16 +12,11 @@ ApplicationWindow {
property bool _hasInitialized: false property bool _hasInitialized: false
// The global mediaPlayer instance // The global mediaPlayer instance
readonly property MediaPlayer mediaPlayer: _mediaPlayer 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. // Data of the currently selected item. For use on the cover.
property var itemData property var itemData
// Id of the collection currently browsing. For use on the cover.
property string collectionId
//FIXME: proper error handling //FIXME: proper error handling
Connections { Connections {

View file

@ -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()
}
}
}
}
}
}

View file

@ -30,13 +30,10 @@ Page {
if (_backdropImages && _backdropImages.length > 0) { if (_backdropImages && _backdropImages.length > 0) {
var rand = Math.floor(Math.random() * (_backdropImages.length - 0.001)) var rand = Math.floor(Math.random() * (_backdropImages.length - 0.001))
console.log("Random: ", rand) console.log("Random: ", rand)
//backdrop.source = 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
appWindow.backgroundSource = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand] + "&maxHeight" + height
} else if (_parentBackdropImages && _parentBackdropImages.length > 0) { } else if (_parentBackdropImages && _parentBackdropImages.length > 0) {
console.log(parentId) console.log(parentId)
//backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0] 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
} }
} }

View file

@ -16,10 +16,6 @@ Page {
id: page id: page
allowedOrientations: Orientation.All allowedOrientations: Orientation.All
ViewPlaceholder {
}
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
@ -50,6 +46,20 @@ Page {
MoreSection { MoreSection {
text: qsTr("Resume watching") text: qsTr("Resume watching")
clickable: false 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 { MoreSection {
text: qsTr("Next up") text: qsTr("Next up")
@ -65,45 +75,14 @@ Page {
MoreSection { MoreSection {
text: model.name text: model.name
busy: userItemModel.status != ApiModel.Ready busy: userItemModel.status != ApiModel.Ready
property string collectionType: model.collectionType || ""
onHeaderClicked: pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id}) onHeaderClicked: pageStack.push(Qt.resolvedUrl("CollectionPage.qml"), {"itemId": model.id})
Loader {
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 }
}
width: parent.width width: parent.width
model: userItemModel sourceComponent: carrouselView
orientation: ListView.Horizontal property alias itemModel: userItemModel
leftMargin: Theme.horizontalPageMargin property string collectionType: model.collectionType || ""
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)
onClicked: {
pageStack.push(Qt.resolvedUrl("DetailPage.qml"), {"itemId": model.id})
}
}
UserItemLatestModel { UserItemLatestModel {
id: userItemModel id: userItemModel
apiClient: ApiClient apiClient: ApiClient
@ -165,6 +144,49 @@ Page {
if (force || (ApiClient.authenticated && !_modelsLoaded)) { if (force || (ApiClient.authenticated && !_modelsLoaded)) {
_modelsLoaded = true; _modelsLoaded = true;
mediaLibraryModel.reload() 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})
}
}
} }
} }
} }

View file

@ -9,19 +9,13 @@ ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject
} }
void ApiModel::reload() { void ApiModel::reload() {
this->setStatus(Loading);
m_startIndex = 0;
load(RELOAD); load(RELOAD);
} }
void ApiModel::load(LoadType type) { void ApiModel::load(LoadType type) {
qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); 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) { if (m_apiClient == nullptr) {
qWarning() << "Please set the apiClient property before (re)loading"; qWarning() << "Please set the apiClient property before (re)loading";
return; return;
@ -187,6 +181,7 @@ bool ApiModel::canFetchMore(const QModelIndex &parent) const {
void ApiModel::fetchMore(const QModelIndex &parent) { void ApiModel::fetchMore(const QModelIndex &parent) {
if (parent.isValid()) return; if (parent.isValid()) return;
this->setStatus(LoadingMore);
load(LOAD_MORE); load(LOAD_MORE);
} }
@ -200,6 +195,7 @@ void registerModels(const char *URI) {
qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel"); qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel");
qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel"); qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel");
qmlRegisterType<UserItemLatestModel>(URI, 1, 0, "UserItemLatestModel"); qmlRegisterType<UserItemLatestModel>(URI, 1, 0, "UserItemLatestModel");
qmlRegisterType<UserItemResumeModel>(URI, 1, 0, "UserItemResumeModel");
qmlRegisterType<ShowSeasonsModel>(URI, 1, 0, "ShowSeasonsModel"); qmlRegisterType<ShowSeasonsModel>(URI, 1, 0, "ShowSeasonsModel");
qmlRegisterType<ShowEpisodesModel>(URI, 1, 0, "ShowEpisodesModel"); qmlRegisterType<ShowEpisodesModel>(URI, 1, 0, "ShowEpisodesModel");
} }

View file

@ -195,7 +195,7 @@ protected:
QList<QString> m_fields; QList<QString> m_fields;
QList<QString> m_imageTypes; QList<QString> m_imageTypes;
QList<QString> m_sortBy = {}; QList<QString> m_sortBy = {};
bool m_recursive; bool m_recursive = false;
QHash<int, QByteArray> m_roles; QHash<int, QByteArray> m_roles;
@ -218,13 +218,13 @@ private:
class PublicUserModel : public ApiModel { class PublicUserModel : public ApiModel {
public: public:
explicit PublicUserModel (QObject *parent = nullptr) explicit PublicUserModel (QObject *parent = nullptr)
: ApiModel ("/users/public", "", false, parent) { } : ApiModel ("/users/public", false, false, parent) { }
}; };
class UserViewModel : public ApiModel { class UserViewModel : public ApiModel {
public: public:
explicit UserViewModel (QObject *parent = nullptr) explicit UserViewModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Views", "Items", false, parent) {} : ApiModel ("/Users/{{user}}/Views", true, false, parent) {}
}; };
class UserItemModel : public ApiModel { class UserItemModel : public ApiModel {
@ -232,6 +232,13 @@ public:
explicit UserItemModel (QObject *parent = nullptr) explicit UserItemModel (QObject *parent = nullptr)
: ApiModel ("/Users/{{user}}/Items", true, false, parent) {} : 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 { class UserItemLatestModel : public ApiModel {
public: public:
explicit UserItemLatestModel (QObject *parent = nullptr) explicit UserItemLatestModel (QObject *parent = nullptr)

View file

@ -58,6 +58,38 @@
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
</context> </context>
<context>
<name>CollectionPage</name>
<message>
<source>Loading</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Sort by</source>
<extracomment>Menu item for selecting the sort order of a collection</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<source>Empty collection</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Add some items to this collection!</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Name</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Play count</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Date added</source>
<translation type="unfinished"></translation>
</message>
</context>
<context> <context>
<name>CoverPage</name> <name>CoverPage</name>
<message> <message>