WIP: Playlist support

This commit is contained in:
Chris Josten 2021-05-21 15:46:30 +02:00
parent 228f81984b
commit fbc154fb56
16 changed files with 481 additions and 27 deletions

View File

@ -7,6 +7,7 @@ set(JellyfinQt_SOURCES
# src/DTO/dto.cpp
src/model/deviceprofile.cpp
src/model/item.cpp
src/model/playlist.cpp
src/support/jsonconv.cpp
src/support/loader.cpp
src/viewmodel/item.cpp
@ -14,6 +15,7 @@ set(JellyfinQt_SOURCES
src/viewmodel/loader.cpp
src/viewmodel/modelstatus.cpp
src/viewmodel/playbackmanager.cpp
src/viewmodel/playlist.cpp
src/apiclient.cpp
src/apimodel.cpp
src/credentialmanager.cpp
@ -28,6 +30,7 @@ list(APPEND JellyfinQt_SOURCES ${openapi_SOURCES})
set(JellyfinQt_HEADERS
include/JellyfinQt/model/deviceprofile.h
include/JellyfinQt/model/item.h
include/JellyfinQt/model/playlist.h
include/JellyfinQt/support/jsonconv.h
include/JellyfinQt/support/loader.h
include/JellyfinQt/viewmodel/item.h
@ -36,6 +39,7 @@ set(JellyfinQt_HEADERS
include/JellyfinQt/viewmodel/modelstatus.h
include/JellyfinQt/viewmodel/propertyhelper.h
include/JellyfinQt/viewmodel/playbackmanager.h
include/JellyfinQt/viewmodel/playlist.h
include/JellyfinQt/apiclient.h
include/JellyfinQt/apimodel.h
include/JellyfinQt/credentialmanager.h

View File

@ -430,7 +430,7 @@ public:
}
// QList-like API
T& at(int index) { return m_array.at(index); }
const T& at(int index) { return m_array.at(index); }
/**
* @return the amount of objects in this model.
*/
@ -453,6 +453,10 @@ public:
this->endInsertRows();
};
QList<T> mid(int pos, int length = -1) {
return m_array.mid(pos, length);
}
void removeAt(int index) {
Q_ASSERT(index < size());
this->beginRemoveRows(QModelIndex(), index, index);

View File

@ -42,6 +42,12 @@ signals:
* @param userData The new userData
*/
void itemUserDataUpdated(const QString &itemId, const DTO::UserItemDataDto &userData);
/**
* @brief The server has requested to display an message to the user
* @param message The message to show.
*/
void displayMessage(const QString &message);
};
}

View File

@ -0,0 +1,42 @@
#ifndef JELLYFIN_MODEL_PLAYLIST_H
#define JELLYFIN_MODEL_PLAYLIST_H
#include <QSharedPointer>
#include <QString>
#include <QUrl>
#include <QVector>
namespace Jellyfin {
namespace Model {
// Forward declaration
class Item;
class Playlist {
public:
explicit Playlist();
/// Start loading data for the next item.
void preloadNext();
private:
/// Extra data about each itemId that this playlist manages
struct ExtendedItem {
QSharedPointer<Item> item;
/// The url from which this item can be streamed.
QUrl url;
/// Playsession that should be reported to Jellyfin's server.
QString playSession;
/// Text to be shown when an error occurred while fetching playback information.
QString errorText;
};
QVector<ExtendedItem> list;
};
}
}
#endif // JELLYFIN_MODEL_PLAYLIST_H

View File

@ -162,12 +162,14 @@ public:
extraType,
// Hand-picked, important ones
imageTags
imageTags,
jellyfinExtendModelAfterHere = Qt::UserRole + 300 // Should be enough for now
};
explicit ItemModel (QObject *parent = nullptr);
QHash<int, QByteArray> roleNames() const override {
virtual QHash<int, QByteArray> roleNames() const override {
return {
JFRN(jellyfinId),
JFRN(name),

View File

@ -94,7 +94,7 @@ public:
Q_PROPERTY(QMediaPlayer::State playbackState READ playbackState NOTIFY playbackStateChanged)
Q_PROPERTY(qint64 position READ position NOTIFY positionChanged)
ViewModel::Item *item() const { return m_displayItem.get(); }
ViewModel::Item *item() const { return m_displayItem; }
void setApiClient(ApiClient *apiClient);
QString streamUrl() const { return m_streamUrl; }
@ -134,7 +134,9 @@ signals:
void errorStringChanged(const QString &newErrorString);
public slots:
/**
* @brief playItem Plays the item with the given id. This will construct the Jellyfin::Item internally
* @brief playItem Replaces the current queue and 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.
*/
@ -166,39 +168,57 @@ private slots:
void updatePlaybackInfo();
private:
/// Factor to multiply with when converting from milliseconds to ticks.
const static int MS_TICK_FACTOR = 10000;
enum PlaybackInfoType { Started, Stopped, Progress };
QTimer m_updateTimer;
ApiClient *m_apiClient = nullptr;
QSharedPointer<Model::Item> m_item;
QScopedPointer<ViewModel::Item> m_displayItem = QScopedPointer<ViewModel::Item>(new ViewModel::Item());
ViewModel::Item *m_displayItem = new ViewModel::Item(this);
// Properties for making the streaming request.
QString m_streamUrl;
QString m_playSessionId;
/// The index of the mediastreams of the to-be-played item containing the audio
int m_audioIndex = 0;
/// The index of the mediastreams of the to-be-played item containing subtitles
int m_subtitleIndex = -1;
/// The position in ticks to resume playback from
qint64 m_resumePosition = 0;
qint64 m_oldPosition = 0;
/// The position in ticks the playback was stopped
qint64 m_stopPosition = 0;
/// Keeps track of latest playback position
qint64 m_oldPosition = 0;
/**
* @brief Whether to automatically open the livestream of the item;
*/
bool m_autoOpen = false;
// Playback-related members
QMediaPlayer::State m_oldState = QMediaPlayer::StoppedState;
PlayMethod m_playMethod = Transcode;
QMediaPlayer::State m_playbackState = QMediaPlayer::StoppedState;
// Pointer to the current media player.
/// Pointer to the current media player.
QMediaPlayer *m_mediaPlayer = nullptr;
// There are 2 media players over here, so one is able to preload the next song
// before the other starts playing
/// Media player 1
QMediaPlayer *m_mediaPlayer1;
/// Media player 2
QMediaPlayer *m_mediaPlayer2;
ItemModel *m_queue = nullptr;
int m_queueIndex = 0;
bool m_resumePlayback = true;
// Helper methods
void setItem(ViewModel::Item *newItem);
void swapMediaPlayer();
bool m_qmlIsParsingComponent = false;
/**
* @brief Whether to automatically open the livestream of the item;
*/
bool m_autoOpen = false;
/**
* @brief Retrieves the URL of the stream to open.
@ -211,10 +231,7 @@ private:
Model::Item *nextItem();
void setQueue(ItemModel *itemModel);
// Factor to multiply with when converting from milliseconds to ticks.
const static int MS_TICK_FACTOR = 10000;
enum PlaybackInfoType { Started, Stopped, Progress };
/**
* @brief Posts the playback information
@ -222,10 +239,10 @@ private:
void postPlaybackInfo(PlaybackInfoType type);
void classBegin() override {
m_qmlIsParsingComponent = true;
}
// QQmlParserListener interface
void classBegin() override { m_qmlIsParsingComponent = true; }
void componentComplete() override;
bool m_qmlIsParsingComponent = false;
};
} // NS ViewModel

View File

@ -0,0 +1,139 @@
/*
* Sailfin: a Jellyfin client written using Qt
* Copyright (C) 2021 Chris Josten and the Sailfin Contributors.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifndef JELLYFIN_VIEWMODEL_PLAYLIST
#define JELLYFIN_VIEWMODEL_PLAYLIST
#include <optional>
#include <QAtomicInteger>
#include <QMutex>
#include <QMutexLocker>
#include <QObject>
#include <QQueue>
#include <QWaitCondition>
#include <QtMultimedia/QMediaPlaylist>
#include "../dto/playbackinfodto.h"
#include "../loader/requesttypes.h"
#include "../loader/http/getpostedplaybackinfo.h"
#include "itemmodel.h"
namespace Jellyfin {
namespace ViewModel {
class ItemUrlFetcherThread;
/**
* @brief Playlist/queue that can be exposed to the UI. It also containts the playlist-related logic,
* which is mostly relevant
*/
class Playlist : public ItemModel {
Q_OBJECT
friend class ItemUrlFetcherThread;
public:
explicit Playlist(ApiClient *apiClient, QObject *parent = nullptr);
enum ExtraRoles {
Url = ItemModel::RoleNames::jellyfinExtendModelAfterHere + 1,
PlaySession,
ErrorText
};
QHash<int, QByteArray> roleNames() const override {
QHash<int, QByteArray> result(ItemModel::roleNames());
result.insert(Url, "url");
result.insert(PlaySession, "playSession");
result.insert(ErrorText, "errorText");
return result;
}
private slots:
void onItemsAdded(const QModelIndex &parent, int startIndex, int endIndex);
void onItemsMoved(const QModelIndex &parent, int startIndex, int endIndex, const QModelIndex &destination, int destinationRow);
void onItemsRemoved(const QModelIndex &parent, int startIndex, int endIndex);
void onItemsReset();
/// Called when the fetcherThread has fetched the playback URL and playSession
void onItemExtraDataReceived(const QString &itemId, const QUrl &url, const QString &playSession);
/// Called when the fetcherThread encountered an error
void onItemErrorReceived(const QString &itemId, const QString &errorString);
private:
/// Map from ItemId to ExtraData
QHash<QString, ExtraData> m_cache;
ApiClient *m_apiClient;
/// Thread that fetches the URLS asynchronously
ItemUrlFetcherThread *m_fetcherThread;
};
/// Thread that fetches the Item's stream URL always in the given order they were requested
class ItemUrlFetcherThread : public QThread {
Q_OBJECT
public:
ItemUrlFetcherThread(Playlist *playlist);
/**
* @brief Adds an item to the queue of items that should be requested
* @param item The item to fetch the URL of
*/
void addItemToQueue(const Model::Item item);
signals:
/**
* @brief Emitted when the url of the item with the itemId has been retrieved.
* @param itemId The id of the item of which the URL has been retrieved
* @param itemUrl The retrieved url
* @param playSession The playsession set by the Jellyfin Server
*/
void itemUrlFetched(QString itemId, QUrl itemUrl, QString playSession);
void itemUrlFetchError(QString itemId, QString errorString);
void prepareLoaderRequested(QPrivateSignal);
public slots:
/**
* @brief Ask the thread nicely to stop running.
*/
void cleanlyStop();
private slots:
void onPrepareLoader();
protected:
void run() override;
private:
Playlist *m_parent;
Support::Loader<DTO::PlaybackInfoResponse, Jellyfin::Loader::GetPostedPlaybackInfoParams> *m_loader;
QMutex m_queueModifyMutex;
QQueue<const Model::Item&> m_queue;
QMutex m_urlWaitConditionMutex;
/// WaitCondition on which this threads waits until an Item is put into the queue
QWaitCondition m_urlWaitCondition;
QMutex m_waitLoaderPreparedMutex;
/// WaitCondition on which this threads waits until the loader has been prepared.
QWaitCondition m_waitLoaderPrepared;
bool m_keepRunning = true;
bool m_loaderPrepared = false;
};
} // NS ViewModel
} // NS Jellyfin
#endif //JELLYFIN_VIEWMODEL_PLAYLIST

0
core/qmldir Normal file
View File

22
core/qrc/3rdparty.xml Normal file
View File

@ -0,0 +1,22 @@
<includes>
<include>
<name>Storeman</name>
<url>https://github.com/mentaljam/harbour-storeman/tree/f64314e7f72550faf35f95f046b52cee42501cf8</url>
<type>SNIPPET</type>
<license>
<type>MIT</type>
<copyright>Copyright (c) 2017 Petr Tsymbarovich</copyright>
<text>licenses/MIT.txt</text>
</license>
</include>
<include>
<name>CMake-qmlplugin</name>
<type>LIBRARY</type>
<url>https://github.com/xarxer/cmake-qmlplugin</url>
<license>
<type>MIT</type>
<copyright>Copyright (c) 2018 Pontus Sjögren</copyright>
<text>licenses/MIT.txt</text>
</license>
</include>
</includes>

17
core/qrc/licenses/MIT.txt Normal file
View File

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -24,6 +24,7 @@ void registerTypes(const char *uri) {
qmlRegisterType<ServerDiscoveryModel>(uri, 1, 0, "ServerDiscoveryModel");
qmlRegisterType<ViewModel::PlaybackManager>(uri, 1, 0, "PlaybackManager");
qmlRegisterUncreatableType<ViewModel::Item>(uri, 1, 0, "Item", "Acquire one via ItemLoader or exposed properties");
qmlRegisterUncreatableType<EventBus>(uri, 1, 0, "EventBus", "Obtain one via your ApiClient");
qmlRegisterUncreatableType<WebSocket>(uri, 1, 0, "WebSocket", "Obtain one via your ApiClient");
// AbstractItemModels

View File

View File

@ -0,0 +1,193 @@
/*
* Sailfin: a Jellyfin client written using Qt
* Copyright (C) 2021 Chris Josten and the Sailfin Contributors.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "JellyfinQt/viewmodel/playlist.h"
namespace Jellyfin {
namespace ViewModel {
Playlist::Playlist(ApiClient *apiClient, QObject *parent)
: ItemModel(parent), m_apiClient(apiClient), m_fetcherThread(new ItemUrlFetcherThread(this)){
connect(this, &QAbstractListModel::rowsInserted, this, &Playlist::onItemsAdded);
connect(this, &QAbstractListModel::rowsRemoved, this, &Playlist::onItemsRemoved);
connect(this, &QAbstractListModel::rowsMoved, this, &Playlist::onItemsMoved);
connect(this, &QAbstractListModel::modelReset, this, &Playlist::onItemsReset);
connect(m_fetcherThread, &ItemUrlFetcherThread::itemUrlFetched, this, &Playlist::onItemExtraDataReceived);
}
void Playlist::onItemsAdded(const QModelIndex &parent, int startIndex, int endIndex) {
if (parent.isValid()) return;
// Retrieve added items.
for (int i = startIndex; i <= endIndex; i++) {
m_fetcherThread->addItemToQueue(at(i));
}
}
void Playlist::onItemsMoved(const QModelIndex &parent, int startIndex, int endIndex, const QModelIndex &destination, int index) {
if (parent.isValid()) return;
if (destination.isValid()) return;
}
void Playlist::onItemsRemoved(const QModelIndex &parent, int startIndex, int endIndex) {
if (parent.isValid()) return;
QSet<QString> removedItemIds;
// Assume almost all items in the playlist are unique
// If you're the kind of person who puts songs multiple time inside of a playlist:
// enjoy your needlessly reserved memory.
removedItemIds.reserve(endIndex - startIndex + 1);
// First collect all the ids of items that are going to be removed and how many of them there are
for (int i = startIndex; i <= endIndex; i++) {
removedItemIds.insert(at(i).jellyfinId());
}
// Look for itemIds which appear outside of the removed range
// these do not need to be removed from the cahe
for (int i = 0; i < startIndex; i++) {
const QString &id = at(i).jellyfinId();
if (removedItemIds.contains(id)) {
removedItemIds.remove(id);
}
}
for (int i = endIndex + 1; i < size(); i++) {
const QString &id = at(i).jellyfinId();
if (removedItemIds.contains(id)) {
removedItemIds.remove(id);
}
}
for (auto it = removedItemIds.cbegin(); it != removedItemIds.cend(); it++) {
m_cache.remove(*it);
}
}
void Playlist::onItemsReset() {
}
void Playlist::onItemExtraDataReceived(const QString &itemId, const QUrl &url, const QString &playSession) {
m_cache.insert(itemId, ExtraData { url, playSession, QString()});
}
void Playlist::onItemErrorReceived(const QString &itemId, const QString &errorString) {
m_cache.insert(itemId, ExtraData {QUrl(), QString(), errorString});
}
ItemUrlFetcherThread::ItemUrlFetcherThread(Playlist *playlist) :
QThread(playlist),
m_parent(playlist),
m_loader(new Loader::HTTP::GetPostedPlaybackInfoLoader(playlist->m_apiClient)) {
connect(this, &ItemUrlFetcherThread::prepareLoaderRequested, this, &ItemUrlFetcherThread::onPrepareLoader);
}
void ItemUrlFetcherThread::addItemToQueue(const Model::Item item) {
QMutexLocker locker(&m_queueModifyMutex);
m_queue.enqueue(item);
m_urlWaitCondition.wakeOne();
}
void ItemUrlFetcherThread::cleanlyStop() {
m_keepRunning = false;
m_urlWaitCondition.wakeAll();
}
void ItemUrlFetcherThread::onPrepareLoader() {
m_loader->prepareLoad();
m_loaderPrepared = true;
m_waitLoaderPrepared.wakeOne();
}
void ItemUrlFetcherThread::run() {
while (m_keepRunning) {
while(m_queue.isEmpty() && m_keepRunning) {
m_urlWaitConditionMutex.lock();
m_urlWaitCondition.wait(&m_urlWaitConditionMutex);
}
if (!m_keepRunning) break;
Jellyfin::Loader::GetPostedPlaybackInfoParams params;
const Model::Item& item = m_queue.dequeue();
m_queueModifyMutex.lock();
params.setItemId(item.jellyfinId());
m_queueModifyMutex.unlock();
params.setUserId(m_parent->m_apiClient->userId());
params.setEnableDirectPlay(true);
params.setEnableDirectStream(true);
params.setEnableTranscoding(true);
m_loaderPrepared = false;
m_loader->setParameters(params);
// We cannot call m_loader->prepareLoad() from this thread, so we must
// emit a signal and hope for the best
emit prepareLoaderRequested(QPrivateSignal());
while (!m_loaderPrepared) {
m_waitLoaderPreparedMutex.lock();
m_waitLoaderPrepared.wait(&m_waitLoaderPreparedMutex);
}
DTO::PlaybackInfoResponse response;
try {
std::optional<DTO::PlaybackInfoResponse> responseOpt = m_loader->load();
if (responseOpt.has_value()) {
response = responseOpt.value();
} else {
qWarning() << "Cannot retrieve URL of " << params.itemId();
continue;
}
} catch (QException e) {
qWarning() << "Cannot retrieve URL of " << params.itemId() << ": " << e.what();
continue;
}
QList<DTO::MediaSourceInfo> mediaSources = response.mediaSources();
QUrl resultingUrl;
QString playSession = response.playSessionId();
for (int i = 0; i < mediaSources.size(); i++) {
const DTO::MediaSourceInfo &source = mediaSources.at(i);
if (source.supportsDirectPlay()) {
resultingUrl = QUrl::fromLocalFile(source.path());
} else if (source.supportsDirectStream()) {
QString mediaType = item.mediaType();
QUrlQuery query;
query.addQueryItem("mediaSourceId", source.jellyfinId());
query.addQueryItem("deviceId", m_parent->m_apiClient->deviceId());
query.addQueryItem("api_key", m_parent->m_apiClient->token());
query.addQueryItem("Static", "True");
resultingUrl = QUrl(this->m_parent->m_apiClient->baseUrl() + "/" + mediaType + "/" + params.itemId()
+ "/stream." + source.container() + "?" + query.toString(QUrl::EncodeReserved));
} else if (source.supportsTranscoding()) {
resultingUrl = QUrl(m_parent->m_apiClient->baseUrl() + source.transcodingUrl());
} else {
qDebug() << "No suitable sources for item " << item.jellyfinId();
}
if (!resultingUrl.isEmpty()) break;
}
if (resultingUrl.isEmpty()) {
qWarning() << "Could not find suitable media source for item " << params.itemId();
emit itemUrlFetchError(item.jellyfinId(), tr("Cannot fetch stream URL"));
}
emit itemUrlFetched(item.jellyfinId(), resultingUrl, playSession);
}
}
} // NS ViewModel
} // NS Jellyfin

View File

@ -19,6 +19,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#include "JellyfinQt/websocket.h"
#include <JellyfinQt/dto/generalcommand.h>
#include <JellyfinQt/dto/generalcommandtype.h>
#include <JellyfinQt/dto/useritemdatadto.h>
namespace Jellyfin {
@ -70,15 +72,21 @@ void WebSocket::textMessageReceived(const QString &message) {
return;
}
QJsonObject messageRoot = doc.object();
if (!messageRoot.contains("MessageType") || !messageRoot.contains("Data")) {
qWarning() << "Malformed message received over WebSocket: no MessageType and Data set.";
if (!messageRoot.contains("MessageType")) {
qWarning() << "Malformed message received over WebSocket: no MessageType set.";
return;
}
// Convert the type so we can use it in our enums.
QString messageTypeStr = messageRoot["MessageType"].toString();
QJsonValue data = messageRoot["Data"];
if (messageTypeStr == QStringLiteral("ForceKeepAlive")) {
setupKeepAlive(data.toInt());
} else {
qDebug() << messageTypeStr;
}
bool ok;
MessageType messageType = static_cast<MessageType>(QMetaEnum::fromType<WebSocket::MessageType>().keyToValue(messageTypeStr.toLatin1(), &ok));
/*MessageType messageType = static_cast<MessageType>(QMetaEnum::fromType<WebSocket::MessageType>().keyToValue(messageTypeStr.toLatin1(), &ok));
if (!ok) {
qWarning() << "Unknown message arrived: " << messageTypeStr;
if (messageRoot.contains("Data")) {
@ -87,7 +95,6 @@ void WebSocket::textMessageReceived(const QString &message) {
return;
}
QJsonValue data = messageRoot["Data"];
qDebug() << "Received message: " << messageTypeStr;
switch (messageType) {
@ -111,7 +118,7 @@ void WebSocket::textMessageReceived(const QString &message) {
}
break;
}
}*/
}

View File

@ -3,5 +3,5 @@ import QtQuick 2.12
import nl.netsoj.chris.Jellyfin 1.0 as J
J.ApiClient {
supportedCommands: [J.GeneralCommandType.Play]
supportedCommands: [J.GeneralCommandType.Play, J.GeneralCommandType.DisplayContent, J.GeneralCommandType.DisplayMessage]
}

View File

@ -53,7 +53,7 @@ Page {
icon.source: ApiClient.baseUrl + "/Items/" + model.jellyfinId + "/Images/Primary?tag=" + model.tag
text: model.name
width: parent.width
onClicked: playbackManager.play(model.jellyfinId)
onClicked: playbackManager.playItem(model.jellyfinId)
}
}
}