diff --git a/core/include/JellyfinQt/DTO/dto.h b/core/include/JellyfinQt/DTO/dto.h index 1118b75..51dc4c2 100644 --- a/core/include/JellyfinQt/DTO/dto.h +++ b/core/include/JellyfinQt/DTO/dto.h @@ -46,6 +46,7 @@ class JsonSerializable : public QObject { Q_OBJECT public: Q_INVOKABLE JsonSerializable(QObject *parent); + virtual ~JsonSerializable(); /** * @brief Sets this objects properties based on obj. @@ -56,7 +57,7 @@ public: private: QVariant jsonToVariant(QMetaProperty prop, const QJsonValue &val, const QJsonObject &root); QJsonValue variantToJson(const QVariant var) const; - QVariant deserializeQobject(const QJsonObject &obj, const QMetaProperty &prop); + QVariant deserializeQObject(const QJsonObject &obj, const QMetaProperty &prop); /** * @brief Sets the first letter of the string to lower case (to make it camelCase). @@ -72,6 +73,8 @@ private: static QString toPascalCase(QString st); static const QRegularExpression m_listExpression; + static const QRegularExpression m_hashExpression; + static int findTypeIdForProperty(QString type); /** * @brief Qt is doing weird. I'll keep track of the metatypes myself. */ @@ -107,17 +110,20 @@ public: Q_PROPERTY(Status status READ status NOTIFY statusChanged STORED false) Q_PROPERTY(QNetworkReply::NetworkError error READ error NOTIFY errorChanged STORED false) Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged STORED false) + Q_PROPERTY(QStringList extraFields MEMBER m_extraFields WRITE setExtraFields NOTIFY extraFieldsChanged STORED false) Status status() const { return m_status; } QNetworkReply::NetworkError error() const { return m_error; } QString errorString() const { return m_errorString; } void setApiClient(ApiClient *newApiClient); + void setExtraFields(const QStringList &extraFields); signals: void statusChanged(Status newStatus); void apiClientChanged(ApiClient *newApiClient); void errorChanged(QNetworkReply::NetworkError newError); void errorStringChanged(QString newErrorString); + void extraFieldsChanged(const QStringList &newExtraFields); /** * @brief Convenience signal for status == RemoteData.Ready. */ @@ -159,6 +165,7 @@ private: Status m_status = Uninitialised; QNetworkReply::NetworkError m_error = QNetworkReply::NoError; QString m_errorString; + QStringList m_extraFields; }; } // NS DTO diff --git a/core/include/JellyfinQt/DTO/item.h b/core/include/JellyfinQt/DTO/item.h index 999d81a..9108cc6 100644 --- a/core/include/JellyfinQt/DTO/item.h +++ b/core/include/JellyfinQt/DTO/item.h @@ -93,11 +93,13 @@ public: Q_PROPERTY(int productionYear READ productionYear WRITE setProductionYear NOTIFY productionYearChanged) Q_PROPERTY(int indexNumber READ indexNumber WRITE setIndexNumber NOTIFY indexNumberChanged) Q_PROPERTY(int indexNumberEnd READ indexNumberEnd WRITE setIndexNumberEnd NOTIFY indexNumberEndChanged) + Q_PROPERTY(int parentIndexNumber READ parentIndexNumber WRITE setParentIndexNumber NOTIFY parentIndexNumberChanged) Q_PROPERTY(bool isFolder READ isFolder WRITE setIsFolder NOTIFY isFolderChanged) + Q_PROPERTY(QString parentID MEMBER m_parentId READ parentId NOTIFY parentIdChanged) Q_PROPERTY(QString type MEMBER m_type NOTIFY typeChanged) Q_PROPERTY(QString parentBackdropItemId MEMBER m_parentBackdropItemId NOTIFY parentBackdropItemIdChanged) Q_PROPERTY(QStringList parentBackdropImageTags MEMBER m_parentBackdropImageTags NOTIFY parentBackdropImageTagsChanged) - Q_PROPERTY(UserData *userData MEMBER m_userData NOTIFY userDataChanged) + Q_PROPERTY(UserData *userData READ userData WRITE setUserData NOTIFY userDataChanged) Q_PROPERTY(int recursiveItemCount READ recursiveItemCount WRITE setRecursiveItemCount NOTIFY recursiveItemCountChanged) Q_PROPERTY(int childCount READ childCount WRITE setChildCount NOTIFY childCountChanged) Q_PROPERTY(QString albumArtist MEMBER m_albumArtist NOTIFY albumArtistChanged) @@ -147,8 +149,19 @@ public: void setIndexNumber(int newIndexNumber) { m_indexNumber = std::optional(newIndexNumber); emit indexNumberChanged(newIndexNumber); } int indexNumberEnd() const { return m_indexNumberEnd.value_or(-1); } void setIndexNumberEnd(int newIndexNumberEnd) { m_indexNumberEnd = std::optional(newIndexNumberEnd); emit indexNumberEndChanged(newIndexNumberEnd); } + int parentIndexNumber() const { return m_parentIndexNumber.value_or(-1); } + void setParentIndexNumber(int newParentIndexNumber) { m_parentIndexNumber= std::optional(newParentIndexNumber); emit parentIndexNumberChanged(newParentIndexNumber); } bool isFolder() const { return m_isFolder.value_or(false); } void setIsFolder(bool newIsFolder) { m_isFolder = newIsFolder; emit isFolderChanged(newIsFolder); } + QString parentId() const { return m_parentId; } + UserData *userData() const { return m_userData; } + void setUserData(UserData *newUserData) { + if (m_userData != nullptr) { + m_userData->deleteLater(); + } + m_userData = newUserData; + emit userDataChanged(newUserData); + } int recursiveItemCount() const { return m_recursiveItemCount.value_or(-1); } void setRecursiveItemCount(int newRecursiveItemCount) { m_recursiveItemCount = newRecursiveItemCount; emit recursiveItemCountChanged(newRecursiveItemCount); } int childCount() const { return m_childCount.value_or(-1); } @@ -193,6 +206,7 @@ signals: void indexNumberChanged(int newIndexNumber); void indexNumberEndChanged(int newIndexNumberEnd); void isFolderChanged(bool newIsFolder); + void parentIdChanged(const QString &newParentId); void typeChanged(const QString &newType); void parentBackdropItemIdChanged(); void parentBackdropImageTagsChanged(); @@ -213,7 +227,7 @@ signals: void heightChanged(int newHeight); public slots: - void onUserDataChanged(const QString &itemId, QSharedPointer userData); + void onUserDataChanged(const QString &itemId, UserData *userData); protected: // Overrides QString getDataUrl() const override; @@ -251,7 +265,9 @@ protected: std::optional m_productionYear = std::nullopt; std::optional m_indexNumber = std::nullopt; std::optional m_indexNumberEnd = std::nullopt; + std::optional m_parentIndexNumber = std::nullopt; std::optional m_isFolder = std::nullopt; + QString m_parentId; QString m_type; QString m_parentBackdropItemId; QStringList m_parentBackdropImageTags; diff --git a/core/include/JellyfinQt/DTO/mediastream.h b/core/include/JellyfinQt/DTO/mediastream.h index a6cff69..4e269e5 100644 --- a/core/include/JellyfinQt/DTO/mediastream.h +++ b/core/include/JellyfinQt/DTO/mediastream.h @@ -35,7 +35,6 @@ public: Q_INVOKABLE explicit MediaStream(QObject *parent = nullptr); MediaStream(const MediaStream &other); bool operator==(const MediaStream &other); - virtual ~MediaStream() { qDebug() << "MediaStream destroyed"; } enum MediaStreamType { Undefined, diff --git a/core/include/JellyfinQt/DTO/user.h b/core/include/JellyfinQt/DTO/user.h index e2b59eb..1c0bc7c 100644 --- a/core/include/JellyfinQt/DTO/user.h +++ b/core/include/JellyfinQt/DTO/user.h @@ -33,26 +33,38 @@ class User : public RemoteData { public: Q_INVOKABLE User(QObject *parent = nullptr); - Q_PROPERTY(QString userId MEMBER m_userId WRITE setUserId NOTIFY userIdChanged) + Q_PROPERTY(QString jellyfinId MEMBER m_jellyfinId WRITE setJellyfinId NOTIFY jellyfinIdChanged) Q_PROPERTY(QString name MEMBER m_name NOTIFY nameChanged) Q_PROPERTY(QString primaryImageTag MEMBER m_primaryImageTag NOTIFY primaryImageTagChanged) + Q_PROPERTY(bool hasPassword MEMBER m_hasPassword NOTIFY hasPasswordChanged) + Q_PROPERTY(bool hasConfiguredPassword MEMBER m_hasConfiguredPassword NOTIFY hasConfiguredPasswordChanged) + Q_PROPERTY(bool hasConfiguredEasyPassword MEMBER m_hasConfiguredEasyPassword NOTIFY hasConfiguredEasyPasswordChanged) - void setUserId(const QString &newUserId) { - this->m_userId = newUserId; - emit userIdChanged(newUserId); - reload(); + void setJellyfinId(const QString &newJellyfinId) { + if (m_jellyfinId != newJellyfinId) { + this->m_jellyfinId = newJellyfinId; + emit jellyfinIdChanged(newJellyfinId); + reload(); + } } signals: - void userIdChanged(const QString &newUserId); void nameChanged(const QString &newName); + void jellyfinIdChanged(const QString &newJellyfinId); void primaryImageTagChanged(const QString &newPrimaryImageTag); + void hasPasswordChanged(bool newHasPasswordChanged); + void hasConfiguredPasswordChanged(bool newHasConfiguredPasswordChanged); + void hasConfiguredEasyPasswordChanged(bool newHasConfiguredEasyPasswordChanged); + protected: QString getDataUrl() const override; bool canReload() const override; private: - QString m_userId; QString m_name; + QString m_jellyfinId; QString m_primaryImageTag; + bool m_hasPassword; + bool m_hasConfiguredPassword; + bool m_hasConfiguredEasyPassword; }; } // NS DTO diff --git a/core/include/JellyfinQt/DTO/userdata.h b/core/include/JellyfinQt/DTO/userdata.h index 3c9b1b5..8ffb77b 100644 --- a/core/include/JellyfinQt/DTO/userdata.h +++ b/core/include/JellyfinQt/DTO/userdata.h @@ -67,7 +67,7 @@ signals: void playedChanged(bool newPlayed); public slots: void updateOnServer(); - void onUpdated(QSharedPointer other); + void onUpdated(UserData *other); private: std::optional m_playedPercentage = std::nullopt; qint64 m_playbackPositionTicks = 0; diff --git a/core/include/JellyfinQt/apiclient.h b/core/include/JellyfinQt/apiclient.h index ca982d2..d19f033 100644 --- a/core/include/JellyfinQt/apiclient.h +++ b/core/include/JellyfinQt/apiclient.h @@ -162,8 +162,9 @@ signals: * @param userData The new user data * * Note: only Jellyfin::UserData should connect to this signal, they will update themselves! + * Note: the userData is only valid during this callback, afterwards it is deleted! */ - void userDataChanged(const QString &itemId, QSharedPointer userData); + void userDataChanged(const QString &itemId, UserData *userData); public slots: /** @@ -192,7 +193,7 @@ public slots: protected slots: void defaultNetworkErrorHandler(QNetworkReply::NetworkError error); - void onUserDataChanged(const QString &itemId, QSharedPointer newData); + void onUserDataChanged(const QString &itemId, UserData *newData); protected: /** diff --git a/core/include/JellyfinQt/apimodel.h b/core/include/JellyfinQt/apimodel.h index 40f360f..20a7e06 100644 --- a/core/include/JellyfinQt/apimodel.h +++ b/core/include/JellyfinQt/apimodel.h @@ -27,6 +27,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include #include #include +#include #include #include "apiclient.h" @@ -35,9 +36,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA namespace Jellyfin { namespace DTO { + class Item; class JsonSerializable; + class User; } -class SortOptions : public QObject{ +class SortOptions : public QObject { Q_OBJECT public: explicit SortOptions (QObject *parent = nullptr) : QObject(parent) {} @@ -61,6 +64,134 @@ public: Q_ENUM(SortBy) }; +/** + * Q_OBJECT does not support template classes. This base class declares the + * Q_OBJECT related properties and signals. + */ +class BaseApiModel : public QAbstractListModel, public QQmlParserStatus { + Q_OBJECT +public: + explicit BaseApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent = nullptr); + enum ModelStatus { + Uninitialised, + Loading, + Ready, + Error, + LoadingMore + }; + Q_ENUM(ModelStatus) + + enum SortOrder { + Unspecified, + Ascending, + Descending + }; + Q_ENUM(SortOrder) + Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient NOTIFY apiClientChanged) + Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged) + + // Query properties + Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged) + Q_PROPERTY(QList sortBy MEMBER m_sortBy NOTIFY sortByChanged) + Q_PROPERTY(QList fields MEMBER m_fields NOTIFY fieldsChanged) + Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged) + + ModelStatus status() const { return m_status; } + void setApiClient(ApiClient *newApiClient); + void setLimit(int newLimit); + + // From AbstractListModel, gets implemented in ApiModel + virtual int rowCount(const QModelIndex &index) const override = 0; + virtual QHash roleNames() const override = 0; + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override = 0; + virtual bool canFetchMore(const QModelIndex &parent) const override = 0; + virtual void fetchMore(const QModelIndex &parent) override = 0; + + +signals: + void ready(); + void apiClientChanged(ApiClient *newApiClient); + void statusChanged(ModelStatus newStatus); + void limitChanged(int newLimit); + void sortByChanged(QList newSortOrder); + void sortOrderChanged(SortOrder newSortOrder); + void fieldsChanged(QList newFields); + +public slots: + /** + * @brief (Re)loads the data into this model. This might make a network request. + */ + void reload(); + +protected: + enum LoadType { + RELOAD, + LOAD_MORE + }; + + ApiClient *m_apiClient = nullptr; + bool m_isBeingParsed = false; + // Per-model specific settings. + QString m_path; + bool m_hasRecordResponse; + bool m_addUserId; + bool padding; bool padding2; + + // Query/record controlling properties + int m_limit = -1; + int m_startIndex = 0; + int m_totalRecordCount = 0; + const int DEFAULT_LIMIT = 100; + + // Query properties + QList m_fields = {}; + QList m_sortBy = {}; + SortOrder m_sortOrder = Unspecified; + + // State properties. + ModelStatus m_status = Uninitialised; + + void setStatus(ModelStatus newStatus) { + if (this->m_status != newStatus) { + this->m_status = newStatus; + emit this->statusChanged(newStatus); + if (m_status == Ready) { + emit ready(); + } + } + } + + void load(LoadType loadType); + virtual void setModelData(QJsonArray &data) = 0; + virtual void appendModelData(QJsonArray &data) = 0; + /** + * @brief Adds parameters to the query + * @param query The query to add parameters to + * + * This method is intended to be overrided by subclasses. It gets called + * before a request is made to the server and can be used to enable + * query types specific for a certain model to be available. + * + * Make sure to call the method in the superclass as well! + */ + virtual void addQueryParameters(QUrlQuery &query); + + /** + * @brief Replaces placeholders in an URL. + * @param path The path in which placeholders should be replaced. + * + * This method is intended to be overrided by subclasses. It gets called + * before a request is made to the server and can be used to enable + * query types specific for a certain model to be available. + * + * Make sure to call the method in the superclass as well! + */ + virtual void replacePathPlaceholders(QString &path); + + virtual void classBegin() override; + virtual void componentComplete() override; +}; + /** * @brief Abstract model for displaying a REST JSON collection. Role names will be based on the fields encountered in the @@ -86,25 +217,9 @@ public: * The model will have roleNames for "name" and "id". * */ -class ApiModel : public QAbstractListModel { - Q_OBJECT +template +class ApiModel : public BaseApiModel { public: - enum ModelStatus { - Uninitialised, - Loading, - Ready, - Error, - LoadingMore - }; - Q_ENUM(ModelStatus) - - enum SortOrder { - Unspecified, - Ascending, - Descending - }; - Q_ENUM(SortOrder) - /** * @brief Creates a new basemodel * @param path The path (relative to the baseUrl of JellyfinApiClient) to make the call to. @@ -134,22 +249,6 @@ public: * responseHasRecords should be true */ explicit ApiModel(QString path, bool responseHasRecords, bool passUserId = false, QObject *parent = nullptr); - Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient NOTIFY apiClientChanged) - Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged) - - // Query properties - Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged) - Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged) - Q_PROPERTY(QList sortBy MEMBER m_sortBy NOTIFY sortByChanged) - Q_PROPERTY(QList fields MEMBER m_fields NOTIFY fieldsChanged) - Q_PROPERTY(QString seasonId MEMBER m_seasonId NOTIFY seasonIdChanged) - Q_PROPERTY(QList imageTypes MEMBER m_imageTypes NOTIFY imageTypesChanged) - Q_PROPERTY(QList includeItemTypes MEMBER m_includeItemTypes NOTIFY includeItemTypesChanged) - Q_PROPERTY(bool recursive MEMBER m_recursive) - Q_PROPERTY(SortOrder sortOrder MEMBER m_sortOrder NOTIFY sortOrderChanged) - - // Path properties - Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged) // Standard QAbstractItemModel overrides int rowCount(const QModelIndex &index) const override { @@ -161,12 +260,18 @@ public: bool canFetchMore(const QModelIndex &parent) const override; void fetchMore(const QModelIndex &parent) override; - ModelStatus status() const { return m_status; } - // Helper methods template QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); } + // QList-like API + T* at(int index) { return m_array.at(index); } + int size() { return rowCount(QModelIndex()); } + void insert(int index, T* object); + void append(T* object) { insert(size(), object); } + void removeAt(int index); + void removeOne(T* object); + template QString enumListToString (const QList enumList) { QString result; @@ -176,74 +281,20 @@ public: return result; } -signals: - void apiClientChanged(ApiClient *newApiClient); - void statusChanged(ModelStatus newStatus); - void limitChanged(int newLimit); - void parentIdChanged(QString newParentId); - void sortByChanged(QList newSortOrder); - void sortOrderChanged(SortOrder newSortOrder); - void showChanged(QString newShow); - void seasonIdChanged(QString newSeasonId); - void fieldsChanged(QList newFields); - void imageTypesChanged(QList newImageTypes); - void includeItemTypesChanged(const QList &newIncludeItemTypes); - -public slots: - /** - * @brief (Re)loads the data into this model. This might make a network request. - */ - void reload(); protected: - - enum LoadType { - RELOAD, - LOAD_MORE - }; - - void load(LoadType loadType); - /** - * @brief Adds parameters to the query - * @param query The query to add parameters to - * - * This method is intended to be overrided by subclasses. It gets called - * before a request is made to the server and can be used to enable - * query types specific for a certain model to be available. - */ - virtual void addQueryParameters(QUrlQuery &query); - ApiClient *m_apiClient = nullptr; - ModelStatus m_status = Uninitialised; - - QString m_path; - QJsonArray m_array; - bool m_hasRecordResponse; - - // Path properties - QString m_show; - - // Query/record controlling properties - int m_limit = -1; - int m_startIndex = 0; - int m_totalRecordCount = 0; - const int DEFAULT_LIMIT = 100; - - // Query properties - bool m_addUserId = false; - QString m_parentId; - QString m_seasonId; - QList m_fields = {}; - QList m_imageTypes = {}; - QList m_sortBy = {}; - QList m_includeItemTypes = {}; - SortOrder m_sortOrder = Unspecified; - bool m_recursive = false; - + // AbstractItemModel bookkeeping QHash m_roles; - void setStatus(ModelStatus newStatus) { - this->m_status = newStatus; - emit this->statusChanged(newStatus); - } + // Helper methods. + T *deserializeResult(QJsonValueRef source); + virtual void addQueryParameters(QUrlQuery &query) override; + virtual void replacePathPlaceholders(QString &path) override; + + virtual void setModelData(QJsonArray &data) override; + virtual void appendModelData(QJsonArray &data) override; + + // Model-specific properties. + QList m_array; private: /** @@ -256,7 +307,7 @@ private: /** * @brief List of the public users on the server. */ -class PublicUserModel : public ApiModel { +class PublicUserModel : public ApiModel { public: explicit PublicUserModel (QObject *parent = nullptr); }; @@ -266,15 +317,54 @@ public: * * Listens for updates in the library and updates the model accordingly. */ -class ItemModel : public ApiModel { +class ItemModel : public ApiModel { Q_OBJECT public: explicit ItemModel (QString path, bool responseHasRecords, bool replaceUser, QObject *parent = nullptr); + // Query parameters + Q_PROPERTY(QString parentId MEMBER m_parentId WRITE setParentId NOTIFY parentIdChanged) + Q_PROPERTY(QString seasonId MEMBER m_seasonId NOTIFY seasonIdChanged) + Q_PROPERTY(QList imageTypes MEMBER m_imageTypes NOTIFY imageTypesChanged) + Q_PROPERTY(QList includeItemTypes MEMBER m_includeItemTypes NOTIFY includeItemTypesChanged) + Q_PROPERTY(bool recursive MEMBER m_recursive) + QList m_includeItemTypes = {}; + + // Path properties + Q_PROPERTY(QString show MEMBER m_show NOTIFY showChanged) + + void setParentId(const QString &parentId) { + m_parentId = parentId; + emit parentIdChanged(m_parentId); + } +signals: + // Query property signals + void parentIdChanged(QString newParentId); + void seasonIdChanged(QString newSeasonId); + void imageTypesChanged(QList newImageTypes); + void includeItemTypesChanged(const QList &newIncludeItemTypes); + + // Path property signals + void showChanged(QString newShow); public slots: - void onUserDataChanged(const QString &itemId, QSharedPointer userData); + void onUserDataChanged(const QString &itemId, DTO::UserData *userData); +protected: + virtual void addQueryParameters(QUrlQuery &query) override; + virtual void replacePathPlaceholders(QString &path) override; +private: + // Path properties + QString m_show; + + // Query parameters + QString m_parentId; + QString m_seasonId; + QList m_imageTypes = {}; + bool m_recursive = false; }; -class UserViewModel : public ApiModel { +//template<> +//void ApiModel::apiClientChanged(); + +class UserViewModel : public ItemModel { public: explicit UserViewModel (QObject *parent = nullptr); }; diff --git a/core/include/JellyfinQt/jsonhelper.h b/core/include/JellyfinQt/jsonhelper.h index 878738a..dd4a8dc 100644 --- a/core/include/JellyfinQt/jsonhelper.h +++ b/core/include/JellyfinQt/jsonhelper.h @@ -31,6 +31,7 @@ namespace Jellyfin { namespace JsonHelper { void convertToCamelCase(QJsonValueRef val); + void convertToCamelCase(QJsonValue &val); QString convertToCamelCaseHelper(const QString &str); }; diff --git a/core/include/JellyfinQt/playbackmanager.h b/core/include/JellyfinQt/playbackmanager.h index b2d53fd..9ffc6de 100644 --- a/core/include/JellyfinQt/playbackmanager.h +++ b/core/include/JellyfinQt/playbackmanager.h @@ -20,29 +20,38 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #ifndef JELLYFIN_MEDIA_SOURCE_H #define JELLYFIN_MEDIA_SOURCE_H +#include #include #include +#include #include #include +#include #include -#include - #include +#include + +#include #include "JellyfinQt/DTO/item.h" #include "apiclient.h" + namespace Jellyfin { // Forward declaration of Jellyfin::ApiClient found in jellyfinapiclient.h class ApiClient; +class ItemModel; using namespace DTO; /** * @brief The PlaybackManager class manages the playback of Jellyfin items. It fetches streams based on Jellyfin items, posts - * the current playback state to the Jellyfin Server and so on. + * the current playback state to the Jellyfin Server, contains the actual media player and so on. + * + * The PlaybackManager actually keeps two mediaPlayers, m_mediaPlayer1 and m_mediaPlayer2. When one is playing, the other is + * preloading the next item in the queue. The current media player is pointed to by m_mediaPlayer. */ class PlaybackManager : public QObject, public QQmlParserStatus { Q_OBJECT @@ -54,28 +63,49 @@ public: DirectPlay }; Q_ENUM(PlayMethod) + using FetchCallback = std::function; explicit PlaybackManager(QObject *parent = nullptr); + Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient) - Q_PROPERTY(Item *item READ item WRITE setItem NOTIFY itemChanged) Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY streamUrlChanged) Q_PROPERTY(bool autoOpen MEMBER m_autoOpen NOTIFY autoOpenChanged) Q_PROPERTY(int audioIndex MEMBER m_audioIndex NOTIFY audioIndexChanged) Q_PROPERTY(int subtitleIndex MEMBER m_subtitleIndex NOTIFY subtitleIndexChanged) Q_PROPERTY(bool resumePlayback MEMBER m_resumePlayback NOTIFY resumePlaybackChanged) - Q_PROPERTY(QObject* mediaPlayer READ mediaPlayer WRITE setMediaPlayer NOTIFY mediaPlayerChanged) Q_PROPERTY(PlayMethod playMethod READ playMethod NOTIFY playMethodChanged) - Item *item() const { return m_item; } - void setItem(Item *newItem); + // Current Item and queue informatoion + Q_PROPERTY(Item *item READ item NOTIFY itemChanged) + Q_PROPERTY(QAbstractItemModel *queue READ queue NOTIFY queueChanged) + Q_PROPERTY(int queueIndex READ queueIndex NOTIFY queueIndexChanged) - QObject *mediaPlayer() const { - return m_qmlMediaPlayer; - } - void setMediaPlayer(QObject *qmlMediaPlayer); + // Current media player related property getters + Q_PROPERTY(qint64 duration READ duration NOTIFY durationChanged) + Q_PROPERTY(QMediaPlayer::Error error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) + Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY hasVideoChanged) + Q_PROPERTY(QObject* mediaObject READ mediaObject NOTIFY mediaObjectChanged) + Q_PROPERTY(QMediaPlayer::MediaStatus mediaStatus READ mediaStatus NOTIFY mediaStatusChanged) + Q_PROPERTY(QMediaPlayer::State playbackState READ playbackState NOTIFY playbackStateChanged) + Q_PROPERTY(qint64 position READ position NOTIFY positionChanged) + + Item *item() const { return m_item; } QString streamUrl() const { return m_streamUrl; } PlayMethod playMethod() const { return m_playMethod; } + QObject *mediaObject() const { return m_mediaPlayer; } + qint64 position() const { return m_mediaPlayer->position(); } + qint64 duration() const { return m_mediaPlayer->duration(); } + ItemModel *queue() const { return m_queue; } + int queueIndex() const { return m_queueIndex; } + + // Current media player related property getters + QMediaPlayer::State playbackState() const { return m_playbackState; } + QMediaPlayer::MediaStatus mediaStatus() const { return m_mediaPlayer->mediaStatus(); } + bool hasVideo() const { return m_mediaPlayer->isVideoAvailable(); } + QMediaPlayer::Error error () const { return m_mediaPlayer->error(); } + QString errorString() const { return m_mediaPlayer->errorString(); } signals: void itemChanged(Item *newItemId); void streamUrlChanged(const QString &newStreamUrl); @@ -86,14 +116,29 @@ signals: void resumePlaybackChanged(bool newResumePlayback); void playMethodChanged(PlayMethod newPlayMethod); + // Current media player related property signals + void mediaObjectChanged(QObject *newMediaObject); + void positionChanged(qint64 newPosition); + void durationChanged(qint64 newDuration); + void queueChanged(ItemModel *newQue); + void queueIndexChanged(int newIndex); + void playbackStateChanged(QMediaPlayer::State newState); + void mediaStatusChanged(QMediaPlayer::MediaStatus newMediaStatus); + void hasVideoChanged(bool newHasVideo); + void errorChanged(QMediaPlayer::Error newError); + void errorStringChanged(const QString &newErrorString); public slots: - void updatePlaybackInfo(); /** * @brief playItem Plays the item with the given id. This will construct the Jellyfin::Item internally * and delete it later. * @param itemId The id of the item to play. */ void playItem(const QString &itemId); + void playItemInList(ItemModel *itemList, int index); + void play() { m_mediaPlayer->play(); } + void pause() { m_mediaPlayer->pause(); } + void seek(qint64 pos) { m_mediaPlayer->setPosition(pos); } + void stop() { m_mediaPlayer->stop(); } /** * @brief previous Play the previous track in the current playlist. @@ -104,10 +149,16 @@ public slots: * @brief next Play the next track in the current playlist. */ void next(); + private slots: void mediaPlayerStateChanged(QMediaPlayer::State newState); void mediaPlayerPositionChanged(qint64 position); void mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus); + void mediaPlayerError(QMediaPlayer::Error error); + /** + * @brief updatePlaybackInfo Updates the Jellyfin server with the current playback progress etc. + */ + void updatePlaybackInfo(); private: QTimer m_updateTimer; @@ -122,10 +173,19 @@ private: qint64 m_stopPosition = 0; QMediaPlayer::State m_oldState = QMediaPlayer::StoppedState; PlayMethod m_playMethod = Transcode; - QObject *m_qmlMediaPlayer = nullptr; - QMediaPlayer * m_mediaPlayer = nullptr; + QMediaPlayer::State m_playbackState = QMediaPlayer::StoppedState; + // Pointer to the current media player. + QMediaPlayer *m_mediaPlayer = nullptr; + + QMediaPlayer *m_mediaPlayer1; + QMediaPlayer *m_mediaPlayer2; + ItemModel *m_queue = nullptr; + int m_queueIndex = 0; bool m_resumePlayback = true; + void setItem(Item *newItem); + void swapMediaPlayer(); + bool m_qmlIsParsingComponent = false; /** @@ -136,8 +196,13 @@ private: /** * @brief Retrieves the URL of the stream to open. */ - void fetchStreamUrl(); + void fetchStreamUrl(const Item *item, bool autoOpen, const FetchCallback &callback); + void fetchAndSetStreamUrl(const Item *item); void setStreamUrl(const QString &streamUrl); + void setPlaybackState(QMediaPlayer::State newState); + + Item *nextItem(); + void setQueue(ItemModel *itemModel); // Factor to multiply with when converting from milliseconds to ticks. const static int MS_TICK_FACTOR = 10000; @@ -149,6 +214,7 @@ private: */ void postPlaybackInfo(PlaybackInfoType type); + void classBegin() override { m_qmlIsParsingComponent = true; } diff --git a/core/src/DTO/dto.cpp b/core/src/DTO/dto.cpp index ddf1d48..e6a78f6 100644 --- a/core/src/DTO/dto.cpp +++ b/core/src/DTO/dto.cpp @@ -25,7 +25,16 @@ namespace Jellyfin { namespace DTO { const QRegularExpression JsonSerializable::m_listExpression = QRegularExpression("^QList<\\s*([a-zA-Z0-9]*)\\s*\\*?\\s*>$"); +const QRegularExpression JsonSerializable::m_hashExpression = QRegularExpression("^QHash<\\s*([a-zA-Z0-9]*)\\s*\\*?\\s*,\\s*([a-zA-Z0-9]*)\\s*\\*?\\s*>$"); JsonSerializable::JsonSerializable(QObject *parent) : QObject(parent) { } +JsonSerializable::~JsonSerializable() { + if (parent() == nullptr) { + qDebug() << "Deleting" << metaObject()->className() << ", parent: nullptr, ownership: " << QQmlEngine::objectOwnership(this); + } else { + qDebug() << "Deleting" << metaObject()->className() << ", parent: " << parent()->metaObject()->className() + << ", ownership: " << QQmlEngine::objectOwnership(this); + } +} void JsonSerializable::deserialize(const QJsonObject &jObj) { const QMetaObject *obj = this->metaObject(); @@ -95,45 +104,55 @@ QVariant JsonSerializable::jsonToVariant(QMetaProperty prop, const QJsonValue &v JsonHelper::convertToCamelCase(QJsonValueRef(&tmp, 0)); return QVariant(innerObj); } else { - return deserializeQobject(innerObj, prop); + return deserializeQObject(innerObj, prop); } } return QVariant(); } -QVariant JsonSerializable::deserializeQobject(const QJsonObject &innerObj, const QMetaProperty &prop) { +QVariant JsonSerializable::deserializeQObject(const QJsonObject &innerObj, const QMetaProperty &prop) { int typeNo = prop.userType(); - const QMetaObject *metaType = QMetaType::metaObjectForType(prop.userType()); - if (metaType == nullptr) { - // Try to determine if the type is a qlist - QRegularExpressionMatch match = m_listExpression.match(prop.typeName()); - if (match.hasMatch()) { - // It is a qList! Now extract the inner type - // There should be an easier way, shouldn't there? - QString listType = match.captured(1).prepend("Jellyfin::DTO::").append("*"); - // UGLY CODE HERE WE COME - typeNo = QMetaType::type(listType.toUtf8()); - if (typeNo == QMetaType::UnknownType) { - qDebug() << "Unknown type: " << listType; - return QVariant(); - } - metaType = QMetaType::metaObjectForType(typeNo); - } else { - qDebug() << "No metaObject for " << prop.typeName() << ", " << prop.type() << ", " << prop.userType(); - return QVariant(); - } - } - QObject *deserializedInnerObj = metaType->newInstance(); - deserializedInnerObj->setParent(this); - if (JsonSerializable *ser = dynamic_cast(deserializedInnerObj)) { - qDebug() << "Deserializing user type " << deserializedInnerObj->metaObject()->className(); - ser->deserialize(innerObj); - return QVariant(typeNo, &ser); + const QMetaObject *metaType = QMetaType::metaObjectForType(prop.userType()); + if (metaType == nullptr) { + // Try to determine if the type is a qlist + QRegularExpressionMatch listMatch = m_listExpression.match(prop.typeName()); + QRegularExpressionMatch hashMatch = m_hashExpression.match(prop.typeName()); + if (listMatch.hasMatch()) { + // It is a qList! Now extract the inner type + // There should be an easier way, shouldn't there? + QString listType = listMatch.captured(1); + typeNo = findTypeIdForProperty(listType); + metaType = QMetaType::metaObjectForType(typeNo); } else { - deserializedInnerObj->deleteLater(); - qDebug() << "Object is not a serializable one!"; + qDebug() << "No metaObject for " << prop.typeName() << ", " << prop.type() << ", " << prop.userType(); return QVariant(); } + } + QObject *deserializedInnerObj = metaType->newInstance(); + deserializedInnerObj->setParent(this); + if (JsonSerializable *ser = dynamic_cast(deserializedInnerObj)) { + // qDebug() << "Deserializing user type " << deserializedInnerObj->metaObject()->className(); + ser->deserialize(innerObj); + return QVariant(typeNo, &ser); + } else { + deserializedInnerObj->deleteLater(); + qDebug() << "Object is not a serializable one!"; + return QVariant(); + } +} + +int JsonSerializable::findTypeIdForProperty(QString type) { + // UGLY CODE HERE WE COME + // We assume the type is either in no namespace (Qt Types) or in the Jellyfin::DTO namespace. + int typeNo = QMetaType::type(type.toUtf8()); + if (typeNo == QMetaType::UnknownType) { + typeNo = QMetaType::type(type.prepend("Jellyfin::DTO::").append("*").toUtf8()); + if (typeNo == QMetaType::UnknownType) { + qDebug() << "Unknown type: " << type; + return typeNo; + } + } + return typeNo; } QJsonObject JsonSerializable::serialize(bool capitalize) const { @@ -220,6 +239,13 @@ void RemoteData::setApiClient(ApiClient *newApiClient) { reload(); } +void RemoteData::setExtraFields(const QStringList &extraFields) { + if (extraFields != m_extraFields) { + emit extraFieldsChanged(extraFields); + reload(); + } +} + void RemoteData::reload() { if (!canReload() || m_apiClient == nullptr) { setStatus(Uninitialised); @@ -227,7 +253,11 @@ void RemoteData::reload() { } else { setStatus(Loading); } - QNetworkReply *rep = m_apiClient->get(getDataUrl()); + QUrlQuery params; + if (m_extraFields.length() > 0) { + params.addQueryItem("fields", m_extraFields.join(",")); + } + QNetworkReply *rep = m_apiClient->get(getDataUrl() + QStringLiteral("?") + params.toString(QUrl::EncodeReserved)); connect(rep, &QNetworkReply::finished, this, [this, rep]() { rep->deleteLater(); diff --git a/core/src/DTO/item.cpp b/core/src/DTO/item.cpp index e634596..0706017 100644 --- a/core/src/DTO/item.cpp +++ b/core/src/DTO/item.cpp @@ -43,7 +43,7 @@ QString Item::getDataUrl() const { } bool Item::canReload() const { - return !m_id.isNull(); + return !m_id.isNull() && m_apiClient != nullptr; } void Item::setJellyfinId(QString newId) { @@ -54,7 +54,7 @@ void Item::setJellyfinId(QString newId) { } } -void Item::onUserDataChanged(const QString &itemId, QSharedPointer userData) { +void Item::onUserDataChanged(const QString &itemId, UserData *userData) { if (itemId != m_id || m_userData == nullptr) return; m_userData->onUpdated(userData); } diff --git a/core/src/DTO/userdata.cpp b/core/src/DTO/userdata.cpp index 73e28f2..5887ecc 100644 --- a/core/src/DTO/userdata.cpp +++ b/core/src/DTO/userdata.cpp @@ -27,7 +27,7 @@ void UserData::updateOnServer() { //TODO: implement } -void UserData::onUpdated(QSharedPointer other) { +void UserData::onUpdated(UserData *other) { // The reason I'm not using setLikes and similar is that they don't work with std::nullopt, // since QML does not like it. // THe other reason is that the setLikes method will send a post request to the server, to update the contents diff --git a/core/src/apiclient.cpp b/core/src/apiclient.cpp index cde5fbe..96d1b9a 100644 --- a/core/src/apiclient.cpp +++ b/core/src/apiclient.cpp @@ -45,7 +45,7 @@ void ApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &p request.setRawHeader("Accept", "application/json;"); // profile=\"CamelCase\""); request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(version())); QString url = this->m_baseUrl + path; - if (!params.isEmpty()) url += "?" + params.toString(); + if (!params.isEmpty()) url += "?" + params.toString(QUrl::EncodeReserved); request.setUrl(url); } @@ -263,7 +263,7 @@ void ApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) { rep->deleteLater(); } -void ApiClient::onUserDataChanged(const QString &itemId, QSharedPointer userData) { +void ApiClient::onUserDataChanged(const QString &itemId, UserData *userData) { emit userDataChanged(itemId, userData); } diff --git a/core/src/apimodel.cpp b/core/src/apimodel.cpp index 514d75a..e9cc3ec 100644 --- a/core/src/apimodel.cpp +++ b/core/src/apimodel.cpp @@ -19,81 +19,70 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include "JellyfinQt/apimodel.h" +#include "JellyfinQt/DTO/item.h" #include "JellyfinQt/DTO/userdata.h" +#include "JellyfinQt/DTO/user.h" namespace Jellyfin { -ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) - : QAbstractListModel (parent), + +// BaseApiModel + +BaseApiModel::BaseApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) + : QAbstractListModel(parent), m_path(path), m_hasRecordResponse(hasRecordResponse), - m_addUserId(addUserId){ + m_addUserId(addUserId) { + } -void ApiModel::reload() { +void BaseApiModel::setApiClient(ApiClient *apiClient) { + m_apiClient = apiClient; + emit apiClientChanged(m_apiClient); +} + +void BaseApiModel::setLimit(int newLimit) { + m_limit = newLimit; + emit limitChanged(newLimit); + if (m_apiClient != nullptr && !m_isBeingParsed) { + load(LOAD_MORE); + } +} + +void BaseApiModel::reload() { this->setStatus(Loading); m_startIndex = 0; load(RELOAD); } -void ApiModel::load(LoadType type) { + +void BaseApiModel::load(LoadType type) { qDebug() << (type == RELOAD ? "RELOAD" : "LOAD_MORE"); if (m_apiClient == nullptr) { qWarning() << "Please set the apiClient property before (re)loading"; return; } - if (m_path.contains("{{user}}")) { - m_path = m_path.replace("{{user}}", m_apiClient->userId()); - } - if (m_path.contains("{{show}}") && !m_show.isEmpty()) { - m_path = m_path.replace("{{show}}", m_show); - } + + QString path(m_path); + replacePathPlaceholders(path); QUrlQuery query; - if (m_limit >= 0) { - query.addQueryItem("Limit", QString::number(m_limit)); - } else { - query.addQueryItem("Limit", QString::number(DEFAULT_LIMIT)); - } - if (m_startIndex > 0) { - query.addQueryItem("StartIndex", QString::number(m_startIndex)); - } - if (!m_parentId.isEmpty()) { - query.addQueryItem("ParentId", m_parentId); - } - if (!m_sortBy.empty()) { - query.addQueryItem("SortBy", m_sortBy.join(",")); - } - if (m_sortOrder != Unspecified) { - query.addQueryItem("SortOrder", m_sortOrder == Ascending ? "Ascending" : "Descending"); - } - if (!m_imageTypes.empty()) { - query.addQueryItem("ImageTypes", m_imageTypes.join(",")); - } - if (!m_includeItemTypes.empty()) { - query.addQueryItem("IncludeItemTypes", m_includeItemTypes.join(",")); - } - if (!m_fields.empty()) { - query.addQueryItem("Fields", m_fields.join(",")); - } - if (!m_seasonId.isEmpty()) { - query.addQueryItem("seasonId", m_seasonId); - } - if (m_addUserId) { - query.addQueryItem("userId", m_apiClient->userId()); - } - if (m_recursive) { - query.addQueryItem("Recursive", "true"); - } addQueryParameters(query); - QNetworkReply *rep = m_apiClient->get(m_path, query); + + QNetworkReply *rep = m_apiClient->get(path, query); connect(rep, &QNetworkReply::finished, this, [this, type, rep]() { + qDebug() << rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() << ": " << rep->request().url(); QJsonDocument doc = QJsonDocument::fromJson(rep->readAll()); + if (doc.isNull()) { + qWarning() << "JSON parse error"; + this->setStatus(Error); + } if (!m_hasRecordResponse) { if (!doc.isArray()) { qWarning() << "Object is not an array!"; this->setStatus(Error); return; } - this->m_array = doc.array(); + QJsonArray items = doc.array(); + setModelData(items); } else { if (!doc.isObject()) { qWarning() << "Object is not an object!"; @@ -125,42 +114,109 @@ void ApiModel::load(LoadType type) { QJsonArray items = obj["Items"].toArray(); switch(type) { case RELOAD: - this->m_array = items; + setModelData(items); break; case LOAD_MORE: - this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + items.size() - 1); - // QJsonArray apparently doesn't allow concatenating lists like QList or std::vector - for (auto it = items.begin(); it != items.end(); it++) { - JsonHelper::convertToCamelCase(*it); - } - foreach (const QJsonValue &val, items) { - m_array.append(val); - } - this->endInsertRows(); + appendModelData(items); break; } } - if (type == RELOAD) { - generateFields(); - } this->setStatus(Ready); rep->deleteLater(); }); } -void ApiModel::generateFields() { - if (m_array.size() == 0) return; - this->beginResetModel(); +void BaseApiModel::addQueryParameters(QUrlQuery &query) { + if (m_limit >= 0) { + query.addQueryItem("Limit", QString::number(m_limit)); + } else { + query.addQueryItem("Limit", QString::number(DEFAULT_LIMIT)); + } + if (m_startIndex > 0) { + query.addQueryItem("StartIndex", QString::number(m_startIndex)); + } + if (!m_sortBy.empty()) { + query.addQueryItem("SortBy", m_sortBy.join(",")); + } + if (m_sortOrder != Unspecified) { + query.addQueryItem("SortOrder", m_sortOrder == Ascending ? "Ascending" : "Descending"); + } + if (!m_fields.empty()) { + query.addQueryItem("Fields", m_fields.join(",")); + } + if (m_addUserId) { + query.addQueryItem("userId", m_apiClient->userId()); + } +} + +void BaseApiModel::replacePathPlaceholders(QString &path) { + if (path.contains("{{user}}")) { + path = path.replace("{{user}}", m_apiClient->userId()); + } +} + +void BaseApiModel::classBegin() { + m_isBeingParsed = true; +} + +void BaseApiModel::componentComplete() { + m_isBeingParsed = false; +} + +// ApiModel +template +ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) + : BaseApiModel(path, hasRecordResponse, addUserId, parent) { + // If based on QObject, we know our role names before the first request + generateFields(); +} + +template <> +ApiModel::ApiModel(QString path, bool hasRecordResponse, bool addUserId, QObject *parent) + : BaseApiModel(path, hasRecordResponse, addUserId, parent) { + // But we only know our role names after our first request. +} + + +template +T *ApiModel::deserializeResult(QJsonValueRef source) { + T *result = new T(static_cast(this)); + result->deserialize(source.toObject()); + return result; +} + +template <> +QJsonValue *ApiModel::deserializeResult(QJsonValueRef source) { + QJsonValue *result = new QJsonValue(source); + JsonHelper::convertToCamelCase(*result); + return result; +} + +template +void ApiModel::generateFields() { + const QMetaObject *obj = &T::staticMetaObject; + m_roles[Qt::UserRole + 1] = "qtObject"; + for (int i = 0; i < obj->propertyCount(); i++) { + QMetaProperty property = obj->property(i); + m_roles.insert(Qt::UserRole + 2 + i, property.name()); + } +} + +template <> +void ApiModel::generateFields() { + // We can only generate field names if there is a first item. Redefining role names later leads to + // unexpected results, so prevent it as well. + if (m_array.size() == 0 || m_roles.size() > 0) return; int i = Qt::UserRole + 1; - if (!m_array[0].isObject()) { + if (!m_array[0]->isObject()) { qWarning() << "Iterator is not an object?"; return; } // Walks over the keys in the first record and adds them to the rolenames. // This assumes the back-end has the same keys for every record. I could technically - // go over all records to be really sure, but no-one got time for a O(n²) algorithm, so + // go over all records to be really sure, but no-one got time for a O(n) algorithm, so // this heuristic hopefully suffices. - QJsonObject ob = m_array[0].toObject(); + QJsonObject ob = m_array[0]->toObject(); for (auto jt = ob.begin(); jt != ob.end(); jt++) { QString keyName = jt.key(); keyName[0] = keyName[0].toLower(); @@ -169,20 +225,78 @@ void ApiModel::generateFields() { m_roles.insert(i++, keyArr); } } - for (auto it = m_array.begin(); it != m_array.end(); it++){ - JsonHelper::convertToCamelCase(*it); +} + +template +void ApiModel::setModelData(QJsonArray &data) { + this->beginResetModel(); + for (T* value : m_array) { + value->deleteLater(); + } + m_array.clear(); + for(QJsonValueRef value : data) { + m_array.append(deserializeResult(value)); } this->endResetModel(); } -QVariant ApiModel::data(const QModelIndex &index, int role) const { +template <> +void ApiModel::setModelData(QJsonArray &data) { + generateFields(); + this->beginResetModel(); + for (QJsonValue* value : m_array) { + delete value; + } + m_array.clear(); + for(QJsonValueRef value : data) { + m_array.append(deserializeResult(value)); + } + this->endResetModel(); +} + +template +void ApiModel::appendModelData(QJsonArray &data) { + this->beginInsertRows(QModelIndex(), m_array.size(), m_array.size() + data.size() - 1); + // QJsonArray apparently doesn't allow concatenating lists like QList or std::vector + for (auto it = data.begin(); it != data.end(); it++) { + JsonHelper::convertToCamelCase(*it); + } + for(QJsonValueRef val : data) { + m_array.append(deserializeResult(val)); + } + this->endInsertRows(); +} + +template +QVariant ApiModel::data(const QModelIndex &index, int role) const { // Ignore roles we don't know if (role <= Qt::UserRole || role >= Qt::UserRole + m_roles.size()) return QVariant(); // Ignore invalid indices. if (!index.isValid()) return QVariant(); - QJsonObject obj = m_array.at(index.row()).toObject(); + T* obj = m_array.at(index.row()); + // m_roleNames[role] == "qtObject" + if (role == Qt::UserRole + 1) { + return QVariant::fromValue(obj); + } + + const QString &key = m_roles[role]; + + if (role - Qt::UserRole - 2 < obj->metaObject()->propertyCount() ) { + return obj->property(key.toLocal8Bit()); + } + return QVariant(); +} +template <> +QVariant ApiModel::data(const QModelIndex &index, int role) const { + // Ignore roles we don't know + if (role <= Qt::UserRole || role >= Qt::UserRole + m_roles.size()) return QVariant(); + // Ignore invalid indices. + if (!index.isValid()) return QVariant(); + + + QJsonObject obj = m_array.at(index.row())->toObject(); const QString &key = m_roles[role]; @@ -192,7 +306,9 @@ QVariant ApiModel::data(const QModelIndex &index, int role) const { return QVariant(); } -bool ApiModel::canFetchMore(const QModelIndex &parent) const { + +template +bool ApiModel::canFetchMore(const QModelIndex &parent) const { if (parent.isValid()) return false; switch(m_status) { case Uninitialised: @@ -208,50 +324,99 @@ bool ApiModel::canFetchMore(const QModelIndex &parent) const { } else { return false; } - } -void ApiModel::fetchMore(const QModelIndex &parent) { +template +void ApiModel::fetchMore(const QModelIndex &parent) { if (parent.isValid()) return; this->setStatus(LoadingMore); load(LOAD_MORE); } -void ApiModel::addQueryParameters(QUrlQuery &query) { Q_UNUSED(query)} +template +void ApiModel::addQueryParameters(QUrlQuery &query) { + BaseApiModel::addQueryParameters(query); +} +template +void ApiModel::replacePathPlaceholders(QString &path) { + BaseApiModel::replacePathPlaceholders(path); +} +template +void ApiModel::insert(int index, T* object) { + Q_ASSERT(index >=0 && index <= size()); + this->beginInsertRows(QModelIndex(), index, index); + m_array.insert(index, object); + this->endInsertRows(); +} +template +void ApiModel::removeAt(int index) { + this->beginRemoveRows(QModelIndex(), index, index); + m_array.removeAt(index); + this->endRemoveRows(); +} + +template +void ApiModel::removeOne(T* object) { + int idx = m_array.indexOf(object); + if (idx >= 0) { + removeAt(idx); + } +} // Itemmodel - ItemModel::ItemModel(QString path, bool hasRecordFields, bool replaceUser, QObject *parent) : ApiModel (path, hasRecordFields, replaceUser, parent){ - connect(this, &ApiModel::apiClientChanged, this, [this](ApiClient *newApiClient) { - connect(newApiClient, &ApiClient::userDataChanged, this, &ItemModel::onUserDataChanged); + QObject::connect(this, &BaseApiModel::apiClientChanged, static_cast(this), [this](ApiClient *newApiClient) { + QObject::connect(newApiClient, &ApiClient::userDataChanged, this, &ItemModel::onUserDataChanged); }); } -void ItemModel::onUserDataChanged(const QString &itemId, QSharedPointer userData) { +void ItemModel::onUserDataChanged(const QString &itemId, DTO::UserData *userData) { int i = 0; - for (QJsonValueRef val: m_array) { - QJsonObject item = val.toObject(); - if (item.contains("id") && item["id"].toString() == itemId) { - if (item.contains("userData")) { - QModelIndex cell = this->index(i); - item["userData"] = userData->serialize(false); - val = item; - this->dataChanged(cell, cell); - } + for (Item *val: m_array) { + if (val->userData() != nullptr && val->jellyfinId() == itemId) { + QModelIndex cell = this->index(i); + val->userData()->onUpdated(userData); + this->dataChanged(cell, cell); } i++; } } +void ItemModel::addQueryParameters(QUrlQuery &query) { + ApiModel::addQueryParameters(query); + if (!m_parentId.isEmpty()) { + query.addQueryItem("ParentId", m_parentId); + } + if (!m_imageTypes.empty()) { + query.addQueryItem("ImageTypes", m_imageTypes.join(",")); + } + if (!m_includeItemTypes.empty()) { + query.addQueryItem("IncludeItemTypes", m_includeItemTypes.join(",")); + } + if (!m_seasonId.isEmpty()) { + query.addQueryItem("seasonId", m_seasonId); + } + if (m_recursive) { + query.addQueryItem("Recursive", "true"); + } +} + +void ItemModel::replacePathPlaceholders(QString &path) { + ApiModel::replacePathPlaceholders(path); + if (path.contains("{{show}}") && !m_show.isEmpty()) { + path = m_path.replace("{{show}}", m_show); + } +} + PublicUserModel::PublicUserModel(QObject *parent) : ApiModel ("/users/public", false, false, parent) { } UserViewModel::UserViewModel(QObject *parent) - : ApiModel ("/Users/{{user}}/Views", true, false, parent) {} + : ItemModel ("/Users/{{user}}/Views", true, false, parent) {} UserItemModel::UserItemModel(QObject *parent) : ItemModel ("/Users/{{user}}/Items", true, false, parent) {} @@ -272,7 +437,7 @@ ShowEpisodesModel::ShowEpisodesModel(QObject *parent) : ItemModel ("/Shows/{{show}}/Episodes", true, true, parent) {} void registerModels(const char *URI) { - qmlRegisterUncreatableType(URI, 1, 0, "ApiModel", "Is enum and base class"); + qmlRegisterUncreatableType(URI, 1, 0, "ApiModel", "Is enum and base class"); qmlRegisterUncreatableType(URI, 1, 0, "SortOptions", "Is enum"); qmlRegisterType(URI, 1, 0, "PublicUserModel"); qmlRegisterType(URI, 1, 0, "UserViewModel"); diff --git a/core/src/jsonhelper.cpp b/core/src/jsonhelper.cpp index e214e0f..7273ebc 100644 --- a/core/src/jsonhelper.cpp +++ b/core/src/jsonhelper.cpp @@ -51,6 +51,37 @@ void convertToCamelCase(QJsonValueRef val) { break; } } +void convertToCamelCase(QJsonValue &val) { + switch(val.type()) { + case QJsonValue::Object: { + QJsonObject obj = val.toObject(); + for(const QString &key: obj.keys()) { + QJsonValueRef ref = obj[key]; + convertToCamelCase(ref); + obj[convertToCamelCaseHelper(key)] = ref; + if (key[0].isLower() || !key[0].isLetter()) { + obj[key] = ref; + } else { + obj[convertToCamelCaseHelper(key)] = ref; + obj.remove(key); + } + } + val = obj; + break; + } + case QJsonValue::Array: { + QJsonArray arr = val.toArray(); + for (auto it = arr.begin(); it != arr.end(); it++) { + convertToCamelCase(*it); + } + val = arr; + break; + } + default: + break; + } +} + QString convertToCamelCaseHelper(const QString &str) { QString res(str); diff --git a/core/src/playbackmanager.cpp b/core/src/playbackmanager.cpp index e922f3c..c545afc 100644 --- a/core/src/playbackmanager.cpp +++ b/core/src/playbackmanager.cpp @@ -19,25 +19,29 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include "JellyfinQt/playbackmanager.h" +#include "JellyfinQt/apimodel.h" + #include "JellyfinQt/DTO/dto.h" #include "JellyfinQt/DTO/userdata.h" namespace Jellyfin { +class ItemModel; PlaybackManager::PlaybackManager(QObject *parent) - : QObject(parent) { + : QObject(parent), m_mediaPlayer1(new QMediaPlayer(this)), m_mediaPlayer2(new QMediaPlayer(this)) { + // Set up connections. + swapMediaPlayer(); m_updateTimer.setInterval(10000); // 10 seconds m_updateTimer.setSingleShot(false); connect(&m_updateTimer, &QTimer::timeout, this, &PlaybackManager::updatePlaybackInfo); } -void PlaybackManager::fetchStreamUrl() { - if (m_item == nullptr || m_apiClient == nullptr) { +void PlaybackManager::fetchStreamUrl(const Item *item, bool autoOpen, const FetchCallback &callback) { + if (item == nullptr || m_apiClient == nullptr) { qDebug() << "Item or apiClient not set"; - return; } m_resumePosition = 0; - if (m_resumePlayback && !m_item->property("userData").isNull()) { + if (m_resumePlayback && !item->property("userData").isNull()) { UserData* userData = qvariant_cast(m_item->property("userData")); if (userData != nullptr) { m_resumePosition = userData->playbackPositionTicks(); @@ -47,16 +51,16 @@ void PlaybackManager::fetchStreamUrl() { params.addQueryItem("UserId", m_apiClient->userId()); params.addQueryItem("StartTimeTicks", QString::number(m_resumePosition)); params.addQueryItem("IsPlayback", "true"); - params.addQueryItem("AutoOpenLiveStream", this->m_autoOpen ? "true" : "false"); - params.addQueryItem("MediaSourceId", this->m_item->jellyfinId()); + params.addQueryItem("AutoOpenLiveStream", autoOpen? "true" : "false"); + params.addQueryItem("MediaSourceId", item->jellyfinId()); params.addQueryItem("SubtitleStreamIndex", QString::number(m_subtitleIndex)); params.addQueryItem("AudioStreamIndex", QString::number(m_audioIndex)); QJsonObject root; root["DeviceProfile"] = m_apiClient->playbackDeviceProfile(); - QNetworkReply *rep = m_apiClient->post("/Items/" + this->m_item->jellyfinId() + "/PlaybackInfo", QJsonDocument(root), params); - connect(rep, &QNetworkReply::finished, this, [this, rep]() { + QNetworkReply *rep = m_apiClient->post("/Items/" + item->jellyfinId() + "/PlaybackInfo", QJsonDocument(root), params); + connect(rep, &QNetworkReply::finished, this, [this, rep, callback]() { QJsonObject root = QJsonDocument::fromJson(rep->readAll()).object(); this->m_playSessionId = root["PlaySessionId"].toString(); qDebug() << "Session id: " << this->m_playSessionId; @@ -81,25 +85,35 @@ void PlaybackManager::fetchStreamUrl() { } QString streamUrl = this->m_apiClient->baseUrl() + "/" + mediaType + "/" + m_item->jellyfinId() + "/stream." + firstMediaSource["Container"].toString() + "?" + query.toString(QUrl::EncodeReserved); - setStreamUrl(streamUrl); - this->m_playMethod = DirectPlay; + callback(QUrl(streamUrl), DirectPlay); } else if (firstMediaSource["SupportsTranscoding"].toBool() && !firstMediaSource["TranscodingUrl"].isNull()) { QString streamUrl = this->m_apiClient->baseUrl() + firstMediaSource["TranscodingUrl"].toString(); this->m_playMethod = Transcode; - setStreamUrl(streamUrl); + callback(QUrl(streamUrl), Transcode); } else { qDebug() << "No stream url found"; return; } - qDebug() << "Found stream url: " << this->m_streamUrl; } - rep->deleteLater(); }); } +void PlaybackManager::fetchAndSetStreamUrl(const Item *item) { + fetchStreamUrl(item, m_autoOpen, [this, item](QUrl &&url, PlayMethod playbackMethod) { + if (m_item == item) { + setStreamUrl(url.toString()); + m_playMethod = playbackMethod; + emit playMethodChanged(m_playMethod); + m_mediaPlayer->setMedia(QMediaContent(url)); + m_mediaPlayer->play(); + } + }); + +} + void PlaybackManager::setItem(Item *newItem) { if (m_mediaPlayer != nullptr) m_mediaPlayer->stop(); @@ -109,8 +123,6 @@ void PlaybackManager::setItem(Item *newItem) { } this->m_item = newItem; emit itemChanged(newItem); - // Don't try to start fetching when we're not completely parsed yet. - if (m_qmlIsParsingComponent) return; if (m_apiClient == nullptr) { qWarning() << "apiClient is not set on this MediaSource instance! Aborting."; @@ -130,27 +142,33 @@ void PlaybackManager::setItem(Item *newItem) { newItem->setParent(this); } if (m_item->status() == RemoteData::Ready) { - fetchStreamUrl(); + fetchAndSetStreamUrl(m_item); } else { connect(m_item, &RemoteData::ready, [this]() -> void { - fetchStreamUrl(); + fetchAndSetStreamUrl(m_item); }); } } } + void PlaybackManager::setStreamUrl(const QString &streamUrl) { this->m_streamUrl = streamUrl; // Inspired by PHP naming schemes QUrl realStreamUrl(streamUrl); Q_ASSERT_X(realStreamUrl.isValid(), "setStreamUrl", "StreamURL Jellyfin returned is not valid"); - if (m_mediaPlayer != nullptr) { - m_mediaPlayer->setMedia(QMediaContent(realStreamUrl)); - } emit streamUrlChanged(streamUrl); } +void PlaybackManager::setPlaybackState(QMediaPlayer::State newState) { + if (newState != m_playbackState) { + m_playbackState = newState; + emit playbackStateChanged(newState); + } +} + void PlaybackManager::mediaPlayerPositionChanged(qint64 position) { + emit positionChanged(position); if (position == 0 && m_oldPosition != 0) { // Save the old position when stop gets called. The QMediaPlayer will try to set // position to 0 when stopped, but we don't want to report that to Jellyfin. We @@ -171,6 +189,7 @@ void PlaybackManager::mediaPlayerStateChanged(QMediaPlayer::State newState) { // We've stopped playing the media. Post a stop signal. m_updateTimer.stop(); postPlaybackInfo(Stopped); + setPlaybackState(QMediaPlayer::StoppedState); } else { postPlaybackInfo(Progress); } @@ -178,8 +197,10 @@ void PlaybackManager::mediaPlayerStateChanged(QMediaPlayer::State newState) { } void PlaybackManager::mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus) { + emit mediaStatusChanged(newStatus); if (newStatus == QMediaPlayer::LoadedMedia) { m_mediaPlayer->play(); + setPlaybackState(playbackState()); if (m_resumePlayback) { qDebug() << "Resuming playback by seeking to " << (m_resumePosition / MS_TICK_FACTOR); m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); @@ -187,24 +208,9 @@ void PlaybackManager::mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus ne } } -void PlaybackManager::setMediaPlayer(QObject *qmlMediaPlayer) { - if (m_mediaPlayer != nullptr) { - // Clean up the old media player. - disconnect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); - disconnect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); - disconnect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); - } - - m_qmlMediaPlayer = qmlMediaPlayer; - if (qmlMediaPlayer != nullptr) { - m_mediaPlayer = qvariant_cast(qmlMediaPlayer->property("mediaObject")); - Q_ASSERT_X(m_mediaPlayer != nullptr, "setMediaPlayer", "The mediaPlayer property must contain a qml MediaPlayer with the mediaObject property"); - - // Connect signals from the new media player - connect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); - connect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); - connect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); - } +void PlaybackManager::mediaPlayerError(QMediaPlayer::Error error) { + emit errorChanged(error); + emit errorStringChanged(m_mediaPlayer->errorString()); } void PlaybackManager::updatePlaybackInfo() { @@ -213,17 +219,45 @@ void PlaybackManager::updatePlaybackInfo() { void PlaybackManager::playItem(const QString &itemId) { Item *newItem = new Item(itemId, m_apiClient, this); + QString parentId = newItem->parentId(); setItem(newItem); + ItemModel *queue = new UserItemModel(this); + setQueue(queue); + connect(newItem, &Item::ready, this, [this, queue, parentId](){ + queue->setParentId(parentId); + queue->setLimit(10000); + queue->setApiClient(m_apiClient); + queue->reload(); + }); + connect(queue, &BaseApiModel::ready, this, [this, queue, newItem]() { + for (int i = 0; i < queue->size(); i++) { + if (queue->at(i)->jellyfinId() == newItem->jellyfinId()) { + m_queueIndex = i; + emit queueIndexChanged(m_queueIndex); + break; + } + } + }); + setPlaybackState(QMediaPlayer::PlayingState); +} + +void PlaybackManager::playItemInList(ItemModel *playlist, int itemIdx) { + playlist->setParent(this); + setQueue(playlist); + m_queueIndex = itemIdx; + emit queueIndexChanged(m_queueIndex); + setItem(playlist->at(itemIdx)); } void PlaybackManager::next() { - Q_UNIMPLEMENTED(); + Q_UNIMPLEMENTED(); } void PlaybackManager::previous() { - Q_UNIMPLEMENTED(); + Q_UNIMPLEMENTED(); } + void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) { QJsonObject root; @@ -272,15 +306,62 @@ void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) { m_apiClient->setDefaultErrorHandler(rep); } +void PlaybackManager::swapMediaPlayer() { + if (m_mediaPlayer != nullptr) { + disconnect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::durationChanged, this, &PlaybackManager::durationChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); + disconnect(m_mediaPlayer, &QMediaPlayer::videoAvailableChanged, this, &PlaybackManager::hasVideoChanged); + // I do not like the complicated overload cast + disconnect(m_mediaPlayer, SIGNAL(error(QMediaPlayer::error)), this, SLOT(mediaPlayerError(QmediaPlayer::error))); + } + if (m_mediaPlayer == m_mediaPlayer1) { + m_mediaPlayer = m_mediaPlayer2; + emit mediaPlayerChanged(m_mediaPlayer); + } else { + m_mediaPlayer = m_mediaPlayer1; + emit mediaPlayerChanged(m_mediaPlayer); + } + connect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); + connect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); + connect(m_mediaPlayer, &QMediaPlayer::durationChanged, this, &PlaybackManager::durationChanged); + connect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); + connect(m_mediaPlayer, &QMediaPlayer::videoAvailableChanged, this, &PlaybackManager::hasVideoChanged); + // I do not like the complicated overload cast + connect(m_mediaPlayer, SIGNAL(error(QMediaPlayer::error)), this, SLOT(mediaPlayerError(QmediaPlayer::error))); +} + +Item *PlaybackManager::nextItem() { + if (m_queue == nullptr) return nullptr; + // TODO: shuffle etc. + if (m_queueIndex < m_queue->size()) { + return m_queue->at(m_queueIndex + 1); + } + return nullptr; +} + +void PlaybackManager::setQueue(ItemModel *model) { + if (m_queue != nullptr) { + if (QQmlEngine::objectOwnership(m_queue) == QQmlEngine::CppOwnership) { + m_queue->deleteLater(); + } else { + m_queue->setParent(nullptr); + } + } + m_queue = model; + emit queueChanged(m_queue); +} + void PlaybackManager::componentComplete() { if (m_apiClient == nullptr) qWarning() << "No ApiClient set for PlaybackManager"; m_qmlIsParsingComponent = false; if (m_item != nullptr) { if (m_item->status() == RemoteData::Ready) { - fetchStreamUrl(); + fetchAndSetStreamUrl(m_item); } else { connect(m_item, &RemoteData::ready, [this]() -> void { - fetchStreamUrl(); + fetchAndSetStreamUrl(m_item); }); } } diff --git a/core/src/websocket.cpp b/core/src/websocket.cpp index fe07bd0..050f9f7 100644 --- a/core/src/websocket.cpp +++ b/core/src/websocket.cpp @@ -105,9 +105,11 @@ void WebSocket::textMessageReceived(const QString &message) { } QJsonArray userDataList = data2["UserDataList"].toArray(); for (QJsonValue val: userDataList) { - QSharedPointer userData(new DTO::UserData, &QObject::deleteLater); + UserData* userData =new DTO::UserData; userData->deserialize(val.toObject()); + userData->setParent(this); m_apiClient->onUserDataChanged(userData->itemId(), userData); + userData->deleteLater(); } } diff --git a/sailfish/CMakeLists.txt b/sailfish/CMakeLists.txt index f45763f..be10c20 100644 --- a/sailfish/CMakeLists.txt +++ b/sailfish/CMakeLists.txt @@ -30,7 +30,8 @@ set(sailfin_QML_SOURCES qml/components/MoreSection.qml qml/components/PlainLabel.qml qml/components/PlaybackBar.qml - qml/components/PlayToolbar.qml + qml/components/PlayQueue.qml + qml/components/PlayToolbar.qml qml/components/RemoteImage.qml qml/components/Shim.qml qml/components/UserGridDelegate.qml @@ -65,6 +66,8 @@ target_link_libraries(harbour-sailfin PRIVATE Qt5::Gui Qt5::Qml Qt5::Quick Sailf # Note: this may break when the compiler changes. -rdynamic and -pie seem to be needed for the # invoker/booster to work jellyfin-qt "-Wl,-rpath,${CMAKE_INSTALL_LIBDIR} -rdynamic -pie") +target_compile_definitions(harbour-sailfin + PRIVATE $<$,$>:QT_QML_DEBUG>) install(TARGETS harbour-sailfin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) @@ -107,5 +110,5 @@ install(FILES icons/172x172/harbour-sailfin.png # format. file(WRITE "${CMAKE_BINARY_DIR}/QtCreatorDeployment.txt" "${CMAKE_INSTALL_PREFIX} - sailfish/harbour-sailfin:bin + ${CMAKE_BINARY_DIR}/sailfish/harbour-sailfin:bin ") diff --git a/sailfish/qml/Utils.js b/sailfish/qml/Utils.js index 01c1efe..6ac575e 100644 --- a/sailfish/qml/Utils.js +++ b/sailfish/qml/Utils.js @@ -48,7 +48,7 @@ function ticksToText(ticks, showHours) { } function itemImageUrl(baseUrl, item, type, options) { - if (!item.imageTags[type]) { return "" } + if (item === null || !item.imageTags[type]) { return "" } return itemModelImageUrl(baseUrl, item.jellyfinId, item.imageTags[type], type, options) } diff --git a/sailfish/qml/components/PlayQueue.qml b/sailfish/qml/components/PlayQueue.qml new file mode 100644 index 0000000..73ddffe --- /dev/null +++ b/sailfish/qml/components/PlayQueue.qml @@ -0,0 +1,16 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 + +import nl.netsoj.chris.Jellyfin 1.0 + +import "music" + +SilicaListView { + header: SectionHeader { text: qsTr("Play queue") } + delegate: SongDelegate { + artists: model.artists + name: model.name + width: parent.width + indexNumber: ListView.index + } +} diff --git a/sailfish/qml/components/PlaybackBar.qml b/sailfish/qml/components/PlaybackBar.qml index e9a3d35..5068537 100644 --- a/sailfish/qml/components/PlaybackBar.qml +++ b/sailfish/qml/components/PlaybackBar.qml @@ -43,6 +43,8 @@ PanelBackground { property PlaybackManager manager property bool open property real visibleSize: height + property bool isFullPage: false + property bool showQueue: false property bool _pageWasShowingNavigationIndicator @@ -53,7 +55,7 @@ PanelBackground { id: backgroundItem width: parent.width height: parent.height - onClicked: playbackBar.state = (playbackBar.state == "large" ? "open" : "large") + onClicked: playbackBar.state = "large" RemoteImage { @@ -64,7 +66,10 @@ PanelBackground { top: parent.top } width: height - blurhash: manager.item.imageBlurHashes["Primary"][manager.item.imageTags["Primary"]] + Binding on blurhash { + when: manager.item !== null && "Primary" in manager.item.imageBlurHashes + value: manager.item.imageBlurHashes["Primary"][manager.item.imageTags["Primary"]] + } source: largeAlbumArt.source fillMode: Image.PreserveAspectCrop @@ -77,6 +82,20 @@ PanelBackground { Behavior on opacity { FadeAnimation {} } } } + Loader { + id: queueLoader + source: Qt.resolvedUrl("PlayQueue.qml") + anchors.fill: albumArt + active: false + visible: false + Binding { + when: queueLoader.item !== null + target: queueLoader.item + property: "model" + value: manager.queue + //currentIndex: manager.queueIndex + } + } Column { id: artistInfo @@ -106,6 +125,7 @@ PanelBackground { case "Audio": return manager.item.artists.join(", ") } + return qsTr("Not audio") } width: Math.min(contentWidth, parent.width) font.pixelSize: Theme.fontSizeSmall @@ -146,11 +166,11 @@ PanelBackground { rightMargin: Theme.paddingMedium verticalCenter: parent.verticalCenter } - icon.source: appWindow.mediaPlayer.playbackState === MediaPlayer.PlayingState + icon.source: manager.playbackState === MediaPlayer.PlayingState ? "image://theme/icon-m-pause" : "image://theme/icon-m-play" - onClicked: appWindow.mediaPlayer.playbackState === MediaPlayer.PlayingState - ? appWindow.mediaPlayer.pause() - : appWindow.mediaPlayer.play() + onClicked: manager.playbackState === MediaPlayer.PlayingState + ? manager.pause() + : manager.play() } IconButton { id: nextButton @@ -171,8 +191,10 @@ PanelBackground { verticalCenter: playButton.verticalCenter } icon.source: "image://theme/icon-m-menu" + icon.highlighted: showQueue enabled: false opacity: 0 + onClicked: showQueue = !showQueue } ProgressBar { @@ -182,9 +204,9 @@ PanelBackground { leftMargin: Theme.itemSizeLarge rightMargin: 0 minimumValue: 0 - value: appWindow.mediaPlayer.position - maximumValue: appWindow.mediaPlayer.duration - indeterminate: [MediaPlayer.Loading, MediaPlayer.Buffering].indexOf(appWindow.mediaPlayer.status) >= 0 + value: manager.position + maximumValue: manager.duration + indeterminate: [MediaPlayer.Loading, MediaPlayer.Buffering].indexOf(manager.mediaStatus) >= 0 } Slider { @@ -192,17 +214,17 @@ PanelBackground { animateValue: false anchors.verticalCenter: progressBar.top minimumValue: 0 - value: appWindow.mediaPlayer.position - maximumValue: appWindow.mediaPlayer.duration + value: manager.position + maximumValue: manager.duration width: parent.width stepSize: 1000 valueText: Utils.timeToText(value) enabled: false visible: false onDownChanged: { if (!down) { - appWindow.mediaPlayer.seek(value); + manager.seek(value); // For some reason, the binding breaks when dragging the slider. - value = Qt.binding(function() { return appWindow.mediaPlayer.position}) + value = Qt.binding(function() { return manager.position}) } } } @@ -212,7 +234,7 @@ PanelBackground { states: [ State { name: "" - when: appWindow.mediaPlayer.playbackState !== MediaPlayer.StoppedState && state != "page" && !("__hidePlaybackBar" in pageStack.currentPage) + when: manager.playbackState !== MediaPlayer.StoppedState && !isFullPage && !("__hidePlaybackBar" in pageStack.currentPage) }, State { name: "large" @@ -328,27 +350,46 @@ PanelBackground { } }, - State { - name: "hidden" - when: (appWindow.mediaPlayer.playbackState === MediaPlayer.StoppedState || "__hidePlaybackBar" in pageStack.currentPage) && state != "page" - PropertyChanges { - target: playbackBarTranslate - // + small padding since the ProgressBar otherwise would stick out - y: playbackBar.height + Theme.paddingSmall - } - PropertyChanges { - target: playbackBar - visibleSize: 0 - } - PropertyChanges { - target: albumArt - source: "" - } - }, - State { - name: "page" - extend: "large" - } + State { + name: "hidden" + when: (manager.playbackState === MediaPlayer.StoppedState || "__hidePlaybackBar" in pageStack.currentPage) && !isFullPage + PropertyChanges { + target: playbackBarTranslate + // + small padding since the ProgressBar otherwise would stick out + y: playbackBar.height + Theme.paddingSmall + } + PropertyChanges { + target: playbackBar + visibleSize: 0 + } + PropertyChanges { + target: albumArt + source: "" + } + }, + State { + name: "page" + when: isFullPage && !showQueue + extend: "large" + PropertyChanges { + target: queueLoader + active: true + } + }, + State { + name: "pageQueue" + when: isFullPage && showQueue + extend: "page" + PropertyChanges { + target: queueLoader + visible: true + } + PropertyChanges { + target: largeAlbumArt + opacity: 0 + visible: false + } + } ] Component { @@ -371,7 +412,7 @@ PanelBackground { } Loader { Component.onCompleted: setSource(Qt.resolvedUrl("PlaybackBar.qml"), - {"state": "page", "manager": manager, "y": 0}) + {"isFullPage": true, "manager": manager, "y": 0}) anchors.fill: parent } } @@ -421,23 +462,19 @@ PanelBackground { }, Transition { from: "hidden" - SequentialAnimation { - ParallelAnimation { - NumberAnimation { - targets: [playbackBarTranslate, playbackBar] - properties: "y,visibileSize" - duration: 250 - easing.type: Easing.OutQuad - } + NumberAnimation { + targets: [playbackBarTranslate, playbackBar] + properties: "y,visibileSize" + duration: 250 + easing.type: Easing.OutQuad + } - NumberAnimation { - target: appWindow - property: "bottomMargin" - duration: 250 - to: Theme.itemSizeLarge - easing.type: Easing.OutQuad - } - } + NumberAnimation { + target: appWindow + property: "bottomMargin" + duration: 250 + to: Theme.itemSizeLarge + easing.type: Easing.OutQuad } }, Transition { diff --git a/sailfish/qml/components/VideoPlayer.qml b/sailfish/qml/components/VideoPlayer.qml index b062eb0..827e6ff 100644 --- a/sailfish/qml/components/VideoPlayer.qml +++ b/sailfish/qml/components/VideoPlayer.qml @@ -36,10 +36,10 @@ SilicaItem { property bool resume property int progress readonly property bool landscape: videoOutput.contentRect.width > videoOutput.contentRect.height - property MediaPlayer player - readonly property bool hudVisible: !hud.hidden || player.error !== MediaPlayer.NoError + readonly property bool hudVisible: !hud.hidden || manager.error !== MediaPlayer.NoError property int audioTrack: 0 property int subtitleTrack: 0 + property PlaybackManager manager; // Blackground to prevent the ambience from leaking through Rectangle { @@ -49,27 +49,27 @@ SilicaItem { VideoOutput { id: videoOutput - source: player + source: manager anchors.fill: parent } VideoHud { id: hud anchors.fill: parent - player: playerRoot.player + manager: playerRoot.manager title: videoPlayer.title Label { anchors.fill: parent anchors.margins: Theme.horizontalPageMargin text: item.jellyfinId + "\n" + appWindow.playbackManager.streamUrl + "\n" - + (appWindow.playbackManager.playMethod == PlaybackManager.DirectPlay ? "Direct Play" : "Transcoding") + "\n" - + player.position + "\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" + + (manager.playMethod === PlaybackManager.DirectPlay ? "Direct Play" : "Transcoding") + "\n" + + manager.position + "\n" + + manager.mediaStatus + "\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: appWindow.showDebugInfo @@ -78,17 +78,17 @@ SilicaItem { VideoError { anchors.fill: videoOutput - player: playerRoot.player + player: manager } function start() { - appWindow.playbackManager.audioIndex = audioTrack - appWindow.playbackManager.subtitleIndex = subtitleTrack - appWindow.playbackManager.resumePlayback = resume - appWindow.playbackManager.item = item + manager.audioIndex = audioTrack + manager.subtitleIndex = subtitleTrack + manager.resumePlayback = resume + manager.playItem(item.jellyfinId) } function stop() { - player.stop() + manager.stop(); } } diff --git a/sailfish/qml/components/videoplayer/VideoError.qml b/sailfish/qml/components/videoplayer/VideoError.qml index 3214116..1a1aa84 100644 --- a/sailfish/qml/components/videoplayer/VideoError.qml +++ b/sailfish/qml/components/videoplayer/VideoError.qml @@ -20,9 +20,11 @@ import QtQuick 2.6 import Sailfish.Silica 1.0 import QtMultimedia 5.6 +import nl.netsoj.chris.Jellyfin 1.0 + Rectangle { id: videoError - property MediaPlayer player + property PlaybackManager player color: pal.palette.overlayBackgroundColor opacity: player.error === MediaPlayer.NoError ? 0.0 : 1.0 Behavior on opacity { FadeAnimator {} } diff --git a/sailfish/qml/components/videoplayer/VideoHud.qml b/sailfish/qml/components/videoplayer/VideoHud.qml index 8ea41f8..4876f5f 100644 --- a/sailfish/qml/components/videoplayer/VideoHud.qml +++ b/sailfish/qml/components/videoplayer/VideoHud.qml @@ -20,6 +20,8 @@ import QtQuick 2.6 import QtMultimedia 5.6 import Sailfish.Silica 1.0 +import nl.netsoj.chris.Jellyfin 1.0 + import "../../Utils.js" as Utils /** @@ -28,7 +30,7 @@ import "../../Utils.js" as Utils */ Item { id: videoHud - property MediaPlayer player + property PlaybackManager manager property string title property bool _manuallyActivated: false readonly property bool hidden: opacity == 0.0 @@ -76,19 +78,19 @@ Item { id: busyIndicator anchors.centerIn: parent size: BusyIndicatorSize.Medium - running: [MediaPlayer.Loading, MediaPlayer.Stalled].indexOf(player.status) >= 0 + running: [MediaPlayer.Loading, MediaPlayer.Stalled].indexOf(manager.mediaStatus) >= 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" + icon.source: manager.playbackState === MediaPlayer.PausedState ? "image://theme/icon-l-play" : "image://theme/icon-l-pause" onClicked: { - if (player.playbackState == MediaPlayer.PlayingState) { - player.pause() + if (manager.playbackState === MediaPlayer.PlayingState) { + manager.pause() } else { - player.play() + manager.play() } } visible: !busyIndicator.running @@ -99,7 +101,7 @@ Item { anchors.bottom: parent.bottom width: parent.width height: progress.height - visible: [MediaPlayer.Unavailable, MediaPlayer.Loading, MediaPlayer.NoMedia].indexOf(player.status) == -1 + visible: [MediaPlayer.Unavailable, MediaPlayer.Loading, MediaPlayer.NoMedia].indexOf(manager.mediaStatus) == -1 gradient: Gradient { GradientStop { position: 0.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.15); } @@ -116,19 +118,19 @@ Item { anchors.left: parent.left anchors.leftMargin: Theme.horizontalPageMargin anchors.verticalCenter: progressSlider.verticalCenter - text: Utils.timeToText(player.position) + text: Utils.timeToText(manager.position) } Slider { id: progressSlider - enabled: player.seekable - value: player.position - maximumValue: player.duration + enabled: manager.seekable + value: manager.position + maximumValue: manager.duration stepSize: 1000 anchors.left: playedTime.right anchors.right: totalTime.left anchors.verticalCenter: parent.verticalCenter - onDownChanged: if (!down) { player.seek(value) } + onDownChanged: if (!down) { manager.seek(value) } } Label { @@ -136,7 +138,7 @@ Item { anchors.right: parent.right anchors.rightMargin: Theme.horizontalPageMargin anchors.verticalCenter: progress.verticalCenter - text: Utils.timeToText(player.duration) + text: Utils.timeToText(manager.duration) } } } @@ -144,10 +146,10 @@ Item { Connections { - target: player - onStatusChanged: { - console.log("New mediaPlayer status: " + player.status) - switch(player.status) { + target: manager + onMediaStatusChanged: { + console.log("New mediaPlayer status: " + manager.mediaStatus) + switch(manager.mediaStatus) { case MediaPlayer.Loaded: case MediaPlayer.Buffering: show(false) diff --git a/sailfish/qml/harbour-sailfin.qml b/sailfish/qml/harbour-sailfin.qml index 2522a45..44af789 100644 --- a/sailfish/qml/harbour-sailfin.qml +++ b/sailfish/qml/harbour-sailfin.qml @@ -32,7 +32,7 @@ ApplicationWindow { id: appWindow property bool _hasInitialized: false // The global mediaPlayer instance - readonly property MediaPlayer mediaPlayer: _mediaPlayer + //readonly property MediaPlayer mediaPlayer: _mediaPlayer readonly property PlaybackManager playbackManager: _playbackManager // Data of the currently selected item. For use on the cover. @@ -41,7 +41,7 @@ ApplicationWindow { property string collectionId // Bad way to implement settings, but it'll do for now. - property bool showDebugInfo: false + property bool showDebugInfo: true property bool _hidePlaybackBar: false @@ -65,13 +65,13 @@ ApplicationWindow { } } cover: { - if ([MediaPlayer.NoMedia, MediaPlayer.InvalidMedia, MediaPlayer.UnknownStatus].indexOf(mediaPlayer.status) >= 0) { + if ([MediaPlayer.NoMedia, MediaPlayer.InvalidMedia, MediaPlayer.UnknownStatus].indexOf(playbackManager.status) >= 0) { if (itemData) { return Qt.resolvedUrl("cover/PosterCover.qml") } else { return Qt.resolvedUrl("cover/CoverPage.qml") } - } else if (mediaPlayer.hasVideo){ + } else if (playbackManager.hasVideo){ return Qt.resolvedUrl("cover/VideoCover.qml") } } @@ -87,26 +87,25 @@ ApplicationWindow { } } - MediaPlayer { + /*MediaPlayer { id: _mediaPlayer autoPlay: true - } + }*/ PlaybackManager { id: _playbackManager apiClient: ApiClient - mediaPlayer: _mediaPlayer audioIndex: 0 autoOpen: true } // Keep the sytem alive while playing media KeepAlive { - enabled: _mediaPlayer.playbackState == MediaPlayer.PlayingState + enabled: playbackManager.playbackState === MediaPlayer.PlayingState } DisplayBlanking { - preventBlanking: _mediaPlayer.playbackState == MediaPlayer.PlayingState && _mediaPlayer.hasVideo + preventBlanking: playbackManager.playbackState === MediaPlayer.PlayingState && playbackManager.hasVideo } PlaybackBar { diff --git a/sailfish/qml/pages/MainPage.qml b/sailfish/qml/pages/MainPage.qml index c164b06..6cd44b0 100644 --- a/sailfish/qml/pages/MainPage.qml +++ b/sailfish/qml/pages/MainPage.qml @@ -78,7 +78,7 @@ Page { //- Section header for films and TV shows that an user hasn't completed yet. text: qsTr("Resume watching") clickable: false - busy: userResumeModel.status == ApiModel.Loading + busy: userResumeModel.status === ApiModel.Loading Loader { width: parent.width sourceComponent: carrouselView @@ -97,7 +97,7 @@ Page { //- Section header for next episodes in a TV show that an user was watching. text: qsTr("Next up") clickable: false - busy: showNextUpModel.status == ApiModel.Loading + busy: showNextUpModel.status === ApiModel.Loading Loader { width: parent.width @@ -121,9 +121,9 @@ Page { model: mediaLibraryModel MoreSection { text: model.name - busy: userItemModel.status != ApiModel.Ready + busy: userItemModel.status !== ApiModel.Ready - onHeaderClicked: pageStack.push(Qt.resolvedUrl("itemdetails/CollectionPage.qml"), {"itemId": model.id}) + onHeaderClicked: pageStack.push(Qt.resolvedUrl("itemdetails/CollectionPage.qml"), {"itemId": model.jellyfinId}) Loader { width: parent.width sourceComponent: carrouselView @@ -133,16 +133,12 @@ Page { UserItemLatestModel { id: userItemModel apiClient: ApiClient - parentId: model.id + parentId: jellyfinId limit: 16 } Connections { target: mediaLibraryModel - onStatusChanged: { - if (status == ApiModel.Ready) { - userItemModel.reload() - } - } + onReady: userItemModel.reload() } } } @@ -154,7 +150,6 @@ Page { anchors.fill: parent visible: false opacity: 0 - contentHeight: errorColumn.height Loader { sourceComponent: commonPullDownMenu; } @@ -220,15 +215,18 @@ Page { rightMargin: Theme.horizontalPageMargin spacing: Theme.paddingLarge delegate: LibraryItemDelegate { - property string id: model.id + property string id: model.jellyfinId title: model.name - poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags["primary"], "Primary", {"maxHeight": height}) - blurhash: model.imageBlurHashes["primary"][model.imageTags["primary"]] + poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.jellyfinId, model.imageTags["Primary"], "Primary", {"maxHeight": height}) + Binding on blurhash { + when: poster !== "" + value: model.imageBlurHashes["Primary"][model.imageTags["Primary"]] + } landscape: !Utils.usePortraitCover(collectionType) progress: (typeof model.userData !== "undefined") ? model.userData.playedPercentage / 100 : 0.0 onClicked: { - pageStack.push(Utils.getPageUrl(model.mediaType, model.type, model.isFolder), {"itemId": model.id}) + pageStack.push(Utils.getPageUrl(model.mediaType, model.type, model.isFolder), {"itemId": model.jellyfinId, "itemData": model.qtObject}) } } } diff --git a/sailfish/qml/pages/VideoPage.qml b/sailfish/qml/pages/VideoPage.qml index 6ebb7d2..4ea545b 100644 --- a/sailfish/qml/pages/VideoPage.qml +++ b/sailfish/qml/pages/VideoPage.qml @@ -44,7 +44,7 @@ Page { VideoPlayer { id: videoPlayer anchors.fill: parent - player: appWindow.mediaPlayer + manager: appWindow.playbackManager title: itemData.name audioTrack: videoPage.audioTrack subtitleTrack: videoPage.subtitleTrack @@ -61,7 +61,7 @@ Page { onStatusChanged: { switch(status) { - case PageStatus.Inactive: + case PageStatus.Deactivating: videoPlayer.stop() break; case PageStatus.Active: diff --git a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml index af6483a..bdcefcc 100644 --- a/sailfish/qml/pages/itemdetails/BaseDetailPage.qml +++ b/sailfish/qml/pages/itemdetails/BaseDetailPage.qml @@ -88,8 +88,7 @@ Page { id: jItem apiClient: ApiClient onStatusChanged: { - console.log("Status changed: " + newStatus, JSON.stringify(jItem)) - console.log(jItem.mediaStreams) + //console.log("Status changed: " + newStatus, JSON.stringify(jItem)) if (status == JellyfinItem.Ready) { updateBackdrop() } diff --git a/sailfish/qml/pages/itemdetails/CollectionPage.qml b/sailfish/qml/pages/itemdetails/CollectionPage.qml index 05bc79a..9a9f89d 100644 --- a/sailfish/qml/pages/itemdetails/CollectionPage.qml +++ b/sailfish/qml/pages/itemdetails/CollectionPage.qml @@ -40,7 +40,7 @@ BaseDetailPage { anchors.fill: parent model: collectionModel cellWidth: Constants.libraryDelegateWidth - cellHeight: Utils.usePortraitCover(itemData.type) ? Constants.libraryDelegatePosterHeight + cellHeight: Utils.usePortraitCover(itemData.collectionType) ? Constants.libraryDelegatePosterHeight : Constants.libraryDelegateHeight visible: itemData.status !== JellyfinItem.Error @@ -54,14 +54,14 @@ BaseDetailPage { text: qsTr("Sort by") onClicked: pageStack.push(sortPageComponent) } - busy: collectionModel.status == ApiModel.Loading + 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}) - blurhash: model.imageBlurHashes.primary[model.imageTags.primary] + source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.jellyfinId, model.imageTags.Primary, "Primary", {"maxWidth": width}) + blurhash: model.imageBlurHashes.Primary[model.imageTags.Primary] fallbackColor: Utils.colorFromString(model.name) fillMode: Image.PreserveAspectCrop clip: true @@ -90,7 +90,7 @@ BaseDetailPage { horizontalAlignment: Text.AlignLeft font.pixelSize: Theme.fontSizeSmall } - onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type, model.isFolder), {"itemId": model.id}) + onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type, model.isFolder), {"itemId": model.jellyfinId}) } ViewPlaceholder { diff --git a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml index 0bee381..37cc64e 100644 --- a/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml +++ b/sailfish/qml/pages/itemdetails/MusicAlbumPage.qml @@ -29,7 +29,6 @@ import "../.." BaseDetailPage { id: albumPageRoot readonly property int _songIndexWidth: 100 - property string _albumArtistText: itemData.albumArtist width: 800 * Theme.pixelRatio readonly property bool _twoColumns: albumPageRoot.width / Theme.pixelRatio >= 800 @@ -78,7 +77,7 @@ BaseDetailPage { artists: model.artists duration: model.runTimeTicks indexNumber: model.indexNumber - onClicked: window.playbackManager.playItem(model.id) + onClicked: window.playbackManager.playItem(model.jellyfinId) } VerticalScrollDecorator {} @@ -88,11 +87,6 @@ BaseDetailPage { Connections { target: itemData onAlbumArtistsChanged: { - console.log(itemData.albumArtists) - _albumArtistText = "" - for (var i = 0; i < itemData.albumArtists.length; i++) { - _albumArtistText += itemData.albumArtists[i]["name"] - } } } @@ -100,7 +94,7 @@ BaseDetailPage { item.albumArt = Qt.binding(function(){ return Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width})}) item.name = Qt.binding(function(){ return itemData.name}) item.releaseYear = Qt.binding(function() { return itemData.productionYear}) - item.albumArtist = Qt.binding(function() { return _albumArtistText}) + item.albumArtist = Qt.binding(function() { return itemData.albumArtist}) item.duration = Qt.binding(function() { return itemData.runTimeTicks}) item.songCount = Qt.binding(function() { return itemData.childCount}) item.listview = Qt.binding(function() { return list}) diff --git a/sailfish/qml/pages/itemdetails/SeasonPage.qml b/sailfish/qml/pages/itemdetails/SeasonPage.qml index 80e94cb..99c690a 100644 --- a/sailfish/qml/pages/itemdetails/SeasonPage.qml +++ b/sailfish/qml/pages/itemdetails/SeasonPage.qml @@ -60,7 +60,8 @@ BaseDetailPage { } width: Constants.libraryDelegateWidth height: Constants.libraryDelegateHeight - source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags.primary, "Primary", {"maxHeight": height}) + source: Utils.itemModelImageUrl(ApiClient.baseUrl, model.jellyfinId, model.imageTags.Primary, "Primary", {"maxHeight": height}) + blurhash: model.imageBlurHashes.Primary[model.imageTags.Primary] fillMode: Image.PreserveAspectCrop clip: true @@ -140,7 +141,7 @@ BaseDetailPage { wrapMode: Text.WordWrap elide: Text.ElideRight } - onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id}) + onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.jellyfinId}) } VerticalScrollDecorator {} diff --git a/sailfish/qml/pages/itemdetails/SeriesPage.qml b/sailfish/qml/pages/itemdetails/SeriesPage.qml index 9a7b9b7..933dc25 100644 --- a/sailfish/qml/pages/itemdetails/SeriesPage.qml +++ b/sailfish/qml/pages/itemdetails/SeriesPage.qml @@ -84,10 +84,10 @@ BaseDetailPage { leftMargin: Theme.horizontalPageMargin rightMargin: Theme.horizontalPageMargin delegate: LibraryItemDelegate { - poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.id, model.imageTags.primary, "Primary", {"maxHeight": height}) - blurhash: model.imageBlurHashes["primary"][model.imageTags.primary] + poster: Utils.itemModelImageUrl(ApiClient.baseUrl, model.jellyfinId, model.imageTags.Primary, "Primary", {"maxHeight": height}) + blurhash: model.imageBlurHashes["Primary"][model.imageTags.Primary] title: model.name - onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.id}) + onClicked: pageStack.push(Utils.getPageUrl(model.mediaType, model.type), {"itemId": model.jellyfinId}) } } diff --git a/sailfish/qml/pages/itemdetails/VideoPage.qml b/sailfish/qml/pages/itemdetails/VideoPage.qml index bf2f0eb..4c8bc4f 100644 --- a/sailfish/qml/pages/itemdetails/VideoPage.qml +++ b/sailfish/qml/pages/itemdetails/VideoPage.qml @@ -33,6 +33,7 @@ BaseDetailPage { property alias subtitle: pageHeader.description default property alias _data: content.data property real _playbackProsition: itemData.userData.playbackPositionTicks + readonly property bool _userdataReady: itemData.status == JellyfinItem.Ready && itemData.userData != null SilicaFlickable { anchors.fill: parent contentHeight: content.height + Theme.paddingLarge @@ -57,8 +58,14 @@ BaseDetailPage { imageSource: Utils.itemImageUrl(ApiClient.baseUrl, itemData, "Primary", {"maxWidth": parent.width}) imageAspectRatio: Constants.horizontalVideoAspectRatio imageBlurhash: itemData.imageBlurHashes["Primary"][itemData.imageTags["Primary"]] - favourited: itemData.userData.isFavorite - playProgress: itemData.userData.playedPercentage / 100 + Binding on favourited { + when: _userdataReady + value: itemData.userData.isFavorite + } + Binding on playProgress { + when: _userdataReady + value: itemData.userData.playedPercentage / 100 + } onPlayPressed: pageStack.push(Qt.resolvedUrl("../VideoPage.qml"), {"itemData": itemData, "audioTrack": trackSelector.audioTrack, diff --git a/sailfish/qml/pages/setup/LoginDialog.qml b/sailfish/qml/pages/setup/LoginDialog.qml index 44fdafd..569bfe9 100644 --- a/sailfish/qml/pages/setup/LoginDialog.qml +++ b/sailfish/qml/pages/setup/LoginDialog.qml @@ -31,6 +31,7 @@ Dialog { id: loginDialog property string loginMessage property Page firstPage + property User selectedUser: null property string error @@ -92,16 +93,25 @@ Dialog { width: parent.width Flow { + id: userList width: parent.width Repeater { + id: userRepeater 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) : "" + image: model.primaryImageTag ? "%1/Users/%2/Images/Primary?tag=%3".arg(ApiClient.baseUrl).arg(model.jellyfinId).arg(model.primaryImageTag) : "" highlighted: model.name === username.text + onHighlightedChanged: { + if (highlighted) { + selectedUser = model.qtObject + } + } onClicked: { username.text = model.name - password.focus = true + if (!password.activeFocus) { + password.focus = true + } } } } @@ -119,18 +129,23 @@ Dialog { placeholderText: qsTr("Username") label: placeholderText errorHighlight: error - EnterKey.iconSource: "image://theme/icon-m-enter-next" - EnterKey.onClicked: password.focus = true + EnterKey.iconSource: "image://theme/icon-m-enter-" + (password.enabled ? "next" : "accept") + EnterKey.onClicked: password.enabled ? password.focus = true : accept() + onTextChanged: { + // Wil be executed before the onHighlightChanged of the UserDelegate + // This is done to update the UI after an user is selected and this field has + // been changed, to not let the UI be in an invalid state (e.g. no user selected, password field disabled) + selectedUser = null + } } - TextField { + PasswordField { id: password width: parent.width //: Label placeholder for password field placeholderText: qsTr("Password") label: placeholderText - echoMode: TextInput.Password errorHighlight: error EnterKey.iconSource: "image://theme/icon-m-enter-accept" @@ -169,4 +184,41 @@ Dialog { } } canAccept: username.text + + states: [ + State { + name: "noUsers" + when: userRepeater.count == 0 + PropertyChanges { + target: userList + visible: false + } + }, + State { + name: "users" + when: userRepeater.count != 0 && selectedUser === null + PropertyChanges { + target: userList + visible: true + } + }, + State { + name: "selectedUserPassword" + when: selectedUser !== null && selectedUser.hasPassword + extend: "users" + PropertyChanges { + target: password + enabled: true + } + }, + State { + name: "selectedUserNoPassword" + when: selectedUser !== null && !selectedUser.hasPassword + extend: "users" + PropertyChanges { + target: password + enabled: false + } + } + ] } diff --git a/sailfish/src/harbour-sailfin.cpp b/sailfish/src/harbour-sailfin.cpp index c379d2c..c5111ed 100644 --- a/sailfish/src/harbour-sailfin.cpp +++ b/sailfish/src/harbour-sailfin.cpp @@ -21,6 +21,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #include #endif +#include #include #include #include @@ -37,6 +38,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA static const char *SANDBOX_PROGRAM = "/usr/bin/sailjail"; int main(int argc, char *argv[]) { + QQmlDebuggingEnabler enabler; + enabler.startTcpDebugServer(9999); // SailfishApp::main() will display "qml/harbour-sailfin.qml", if you need more // control over initialization, you can use: //