From c72c10bad48ecfeb128e0762a105cbaf89746f80 Mon Sep 17 00:00:00 2001 From: Henk Kalkwater Date: Wed, 5 Jan 2022 21:24:52 +0100 Subject: [PATCH] core: Split PlaybackManager up into smaller parts The PlaybackManager was a giant class that handled UI bindings, fetching stream URLS, playback logic. It now has been split up into: - ViewModel::PlaybackManager, which handles UI interfacing and allowing to swap out the Model::Playback implementation on the fly. - Model::PlaybackManager, which is an interface for what a PlaybackManager must do, handling queues/playlists, and controlling a player. - Model::LocalPlaybackManager, which is an Model::PlaybackManager implementation for playing back Jellyfin media within the application. - Model::PlaybackReporter, which reports the current playback state to the Jellyfin server, for keeping track of played items. - Model::Player, which handles playing back media from an URL and the usual play/pause et cetera. In a future commit, this would allow for introducing a Model::RemoteJellyfinPlaybackManager, to control other Jellyfin instances. --- core/CMakeLists.txt | 6 + core/include/JellyfinQt/apimodel.h | 4 + .../JellyfinQt/model/playbackmanager.h | 218 ++++++ .../JellyfinQt/model/playbackreporter.h | 58 ++ core/include/JellyfinQt/model/player.h | 152 ++++ core/include/JellyfinQt/model/playlist.h | 6 +- .../platform/freedesktop/mediaplayer2player.h | 8 +- .../JellyfinQt/viewmodel/playbackmanager.h | 185 ++--- core/src/jellyfin.cpp | 3 + core/src/model/playbackmanager.cpp | 700 +++++++++++++++++ core/src/model/playbackreporter.cpp | 200 +++++ core/src/model/player.cpp | 236 ++++++ core/src/model/playlist.cpp | 8 +- .../freedesktop/mediaplayer2player.cpp | 18 +- core/src/viewmodel/playbackmanager.cpp | 725 ++++++------------ qtquick/qml/main.qml | 2 +- sailfish/qml/components/PlaybackBar.qml | 36 +- sailfish/qml/components/VideoPlayer.qml | 17 +- .../qml/components/videoplayer/VideoHud.qml | 15 +- sailfish/qml/harbour-sailfin.qml | 3 +- 20 files changed, 1916 insertions(+), 684 deletions(-) create mode 100644 core/include/JellyfinQt/model/playbackmanager.h create mode 100644 core/include/JellyfinQt/model/playbackreporter.h create mode 100644 core/include/JellyfinQt/model/player.h create mode 100644 core/src/model/playbackmanager.cpp create mode 100644 core/src/model/playbackreporter.cpp create mode 100644 core/src/model/player.cpp diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 3ecc7b1..3b444bc 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -15,6 +15,9 @@ include(GeneratedSources.cmake) set(JellyfinQt_SOURCES src/model/deviceprofile.cpp src/model/item.cpp + src/model/player.cpp + src/model/playbackmanager.cpp + src/model/playbackreporter.cpp src/model/playlist.cpp src/model/shuffle.cpp src/model/user.cpp @@ -48,6 +51,9 @@ list(APPEND JellyfinQt_SOURCES ${openapi_SOURCES}) set(JellyfinQt_HEADERS include/JellyfinQt/model/deviceprofile.h include/JellyfinQt/model/item.h + include/JellyfinQt/model/player.h + include/JellyfinQt/model/playbackmanager.h + include/JellyfinQt/model/playbackreporter.h include/JellyfinQt/model/playlist.h include/JellyfinQt/model/shuffle.h include/JellyfinQt/model/user.h diff --git a/core/include/JellyfinQt/apimodel.h b/core/include/JellyfinQt/apimodel.h index b4860c4..59a8f4e 100644 --- a/core/include/JellyfinQt/apimodel.h +++ b/core/include/JellyfinQt/apimodel.h @@ -489,6 +489,10 @@ public: this->endResetModel(); } + const QList> &toList() { + return m_array; + } + // From AbstractListModel, gets implemented in ApiModel //virtual QHash roleNames() const override = 0; /*virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override = 0;*/ diff --git a/core/include/JellyfinQt/model/playbackmanager.h b/core/include/JellyfinQt/model/playbackmanager.h new file mode 100644 index 0000000..7918554 --- /dev/null +++ b/core/include/JellyfinQt/model/playbackmanager.h @@ -0,0 +1,218 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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_MODEL_PLAYBACKMANAGER_H +#define JELLYFIN_MODEL_PLAYBACKMANAGER_H + +#include +#include +#include + +#include +#include + +namespace Jellyfin { +namespace Model { + +class Item; +class Playlist; + +Q_DECLARE_LOGGING_CATEGORY(playbackManager); + +class PlaybackManagerErrorClass { + Q_GADGET +public: + enum Value { + NoError, + PlaybackInfoError, + RemoteClientNotReachable, + PlayerGeneralError + }; + Q_ENUM(Value); +}; + +using PlaybackManagerError = PlaybackManagerErrorClass::Value; + +class PlaybackManagerPrivate; +/** + * @brief Base class for a playback manager. + * + * Besides some glue code for the properties, + * most of the actual playback logic is implemented in the two subclasses: {@link LocalPlaybackManager} + * and {@link RemotePlaybackManager} + */ +class PlaybackManager : public QObject { + Q_OBJECT + Q_DECLARE_PRIVATE(PlaybackManager); + Q_PROPERTY(bool resumePlayback READ resumePlayback WRITE setResumePlayback NOTIFY resumePlaybackChanged) + Q_PROPERTY(int audioIndex READ audioIndex WRITE setAudioIndex NOTIFY audioIndexChanged) + Q_PROPERTY(int subtitleIndex READ subtitleIndex WRITE setSubtitleIndex NOTIFY subtitleIndexChanged) + Q_PROPERTY(qint64 position READ position NOTIFY positionChanged) + Q_PROPERTY(qint64 duration READ duration NOTIFY durationChanged) + Q_PROPERTY(bool seekable READ seekable NOTIFY seekableChanged) + Q_PROPERTY(bool hasAudio READ hasAudio NOTIFY hasAudioChanged) + Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY hasVideoChanged) + Q_PROPERTY(Jellyfin::Model::PlayerStateClass::Value playbackState READ playbackState NOTIFY playbackStateChanged) + Q_PROPERTY(Jellyfin::Model::MediaStatusClass::Value mediaStatus READ mediaStatus NOTIFY mediaStatusChanged) + Q_PROPERTY(Jellyfin::Model::Playlist *queue READ queue NOTIFY queueChanged) + Q_PROPERTY(int queueIndex READ queueIndex NOTIFY queueIndexChanged) +public: + explicit PlaybackManager(QObject *parent = nullptr); + virtual ~PlaybackManager(); + virtual void swap(PlaybackManager& other) = 0; + + ApiClient * apiClient() const; + void setApiClient(ApiClient *apiClient); + + // Getters + QSharedPointer currentItem() const; + Playlist *queue() const; + int queueIndex() const; + + bool resumePlayback() const; + void setResumePlayback(bool newResumePlayback); + int audioIndex() const; + void setAudioIndex(int newAudioIndex); + int subtitleIndex() const; + void setSubtitleIndex(int newSubtitleIndex); + + virtual PlayerState playbackState() const = 0; + virtual MediaStatus mediaStatus() const = 0; + virtual bool hasNext() const = 0; + virtual bool hasPrevious() const = 0; + virtual PlaybackManagerError error() const = 0; + + virtual const QString &errorString() const = 0; + virtual qint64 position() const = 0; + virtual qint64 duration() const = 0; + virtual bool seekable() const = 0; + virtual bool hasAudio() const = 0; + virtual bool hasVideo() const = 0; + + virtual void playItem(QSharedPointer item) = 0; + virtual void playItemInList(const QList> &items, int index) = 0; + +signals: + void playbackStateChanged(Jellyfin::Model::PlayerStateClass::Value newPlaybackState); + void mediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value newMediaStatus); + void queueChanged(Jellyfin::Model::Playlist *newPlaylist); + void hasNextChanged(bool newHasNext); + void hasPreviousChanged(bool newHasPrevious); + void itemChanged(); + void queueIndexChanged(int index); + void errorChanged(Jellyfin::Model::PlaybackManagerErrorClass::Value newError); + void errorStringChanged(const QString &newErrorString); + void positionChanged(qint64 newPosition); + void durationChanged(qint64 newDuration); + void seekableChanged(bool newSeekable); + void hasAudioChanged(); + void hasVideoChanged(); + void resumePlaybackChanged(bool newPlaybackChanged); + void audioIndexChanged(int newAudioIndex); + void subtitleIndexChanged(int newSubtitleIndex); + +public slots: + virtual void pause() = 0; + virtual void play() = 0; + virtual void playItemId(const QString &id) = 0; + virtual void previous() = 0; + virtual void next() = 0; + /** + * @brief Play the item at the index in the current playlist + * @param index the item to go to; + */ + virtual void goTo(int index) = 0; + virtual void stop() = 0; + virtual void seek(qint64 pos) = 0; +protected: + explicit PlaybackManager(PlaybackManagerPrivate *d, QObject *parent = nullptr); + QScopedPointer d_ptr; +}; + +class LocalPlaybackManagerPrivate; +/** + * @brief Controls playback whithin this app. + * + * This class mostly consists of bookkeeping between the actual media player implementation (which is + * abstracted into yet another class) and the ViewModel. + * + * It does things like: + * * Fetching the actual media URL of an item and deciding which playback method to use + * * Managing the current playlist state and instructing the media player which item to play next + * * Keeping track of the playback state that the user would expect from a media player, + * instead of what the media player implementation reports. + * + */ +class LocalPlaybackManager : public PlaybackManager { + Q_DECLARE_PRIVATE(LocalPlaybackManager); + Q_OBJECT + Q_PROPERTY(Jellyfin::Model::Player *player READ player NOTIFY playerChanged) + Q_PROPERTY(Jellyfin::DTO::PlayMethodClass::Value playMethod READ playMethod NOTIFY playMethodChanged) + Q_PROPERTY(QUrl streamUrl READ streamUrl NOTIFY streamUrlChanged) +public: + explicit LocalPlaybackManager(QObject *parent = nullptr); + + void swap(PlaybackManager& other) override; + + Player *player() const; + QString sessionId() const; + DTO::PlayMethod playMethod() const; + const QUrl &streamUrl() const; + + PlayerState playbackState() const override; + MediaStatus mediaStatus() const override; + PlaybackManagerError error() const override; + const QString& errorString() const override; + qint64 position() const override; + qint64 duration() const override; + bool seekable() const override; + bool hasAudio() const override; + bool hasVideo() const override; + + bool hasNext() const override; + bool hasPrevious() const override; + + void playItemInList(const QList> &items, int index) override; + +public slots: + void pause() override; + void play() override; + void playItem(QSharedPointer item) override; + void playItemId(const QString &itemId) override; + void next() override; + void previous() override; + void stop() override; + void seek(qint64 pos) override; + void goTo(int index) override; +signals: + void playerChanged(Jellyfin::Model::Player *newPlayer); + void playMethodChanged(Jellyfin::DTO::PlayMethodClass::Value newPlayMethod); + void streamUrlChanged(const QUrl &newStreamUrl); +}; + +/** + * @brief Controls playback for remote devices, such as other Jellyfin clients, over the network. + */ +class RemoteJellyfinPlaybackManager { + +}; + +} // NS Model +} // NS Jellyfin + +#endif // JELLYFIN_MODEL_PLAYBACKMANAGER_H diff --git a/core/include/JellyfinQt/model/playbackreporter.h b/core/include/JellyfinQt/model/playbackreporter.h new file mode 100644 index 0000000..2fa01e1 --- /dev/null +++ b/core/include/JellyfinQt/model/playbackreporter.h @@ -0,0 +1,58 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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_MODEL_PLAYBACKREPORTER_H +#define JELLYFIN_MODEL_PLAYBACKREPORTER_H + +#include +#include +#include + +namespace Jellyfin { + +class ApiClient; + +namespace Model { + +Q_DECLARE_LOGGING_CATEGORY(playbackReporter); + +class LocalPlaybackManager; + +class PlaybackReporterPrivate; +/** + * @brief Reports the current playback state to the Jellyfin server + * + * Set a playbackManager using setPlaybackmanager() and this class + * will do its job. + */ +class PlaybackReporter : public QObject { + Q_OBJECT + Q_DECLARE_PRIVATE(PlaybackReporter); +public: + explicit PlaybackReporter(QObject *parent = nullptr); + + void setPlaybackManager(LocalPlaybackManager *playbackManager); +private: + PlaybackReporterPrivate *d_ptr; +}; + +} +} + +#endif //JELLYFIN_MODEL_PLAYBACKREPORTER_H + diff --git a/core/include/JellyfinQt/model/player.h b/core/include/JellyfinQt/model/player.h new file mode 100644 index 0000000..ec248c2 --- /dev/null +++ b/core/include/JellyfinQt/model/player.h @@ -0,0 +1,152 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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_MODEL_PLAYER_H +#define JELLYFIN_MODEL_PLAYER_H + +#include +#include +#include + +namespace Jellyfin { +namespace Model { + +Q_DECLARE_LOGGING_CATEGORY(player) + +class PlayerStateClass { + Q_GADGET +public: + enum Value { + Stopped, + Playing, + Paused + }; + Q_ENUM(Value); +private: + PlayerStateClass() {} +}; + +class MediaStatusClass { + Q_GADGET +public: + enum Value { + NoMedia, + Loading, + Loaded, + Stalled, + Buffering, + Buffered, + EndOfMedia, + Error + }; + Q_ENUM(Value); +private: + MediaStatusClass() {} +}; + +using PlayerState = PlayerStateClass::Value; +using MediaStatus = MediaStatusClass::Value; + +/** + * @brief Abstract class for a player + */ +class Player : public QObject { + Q_OBJECT + Q_PROPERTY(Jellyfin::Model::PlayerStateClass::Value state READ state NOTIFY stateChanged) + Q_PROPERTY(Jellyfin::Model::MediaStatusClass::Value mediaStatus READ mediaStatus NOTIFY mediaStatusChanged) + Q_PROPERTY(qint64 position READ position NOTIFY positionChanged) + Q_PROPERTY(qint64 duration READ duration NOTIFY durationChanged) + Q_PROPERTY(bool seekable READ seekable NOTIFY seekableChanged) + Q_PROPERTY(bool hasAudio READ hasAudio NOTIFY hasAudioChanged) + Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY hasVideoChanged) + Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) + // Used in QML by the VideoOutput + Q_PROPERTY(QObject* videoOutputSource READ videoOutputSource NOTIFY videoOutputSourceChanged); +public: +public: + ~Player(); + virtual PlayerState state() const = 0; + virtual MediaStatus mediaStatus() const = 0; + virtual qint64 position() const = 0; + virtual qint64 duration() const = 0; + virtual bool seekable() const = 0; + virtual bool hasVideo() const = 0; + virtual bool hasAudio() const = 0; + virtual QString errorString() const = 0; + virtual QObject *videoOutputSource() const = 0; +public slots: + virtual void pause() = 0; + virtual void play(qint64 startPos = 0) = 0; + virtual void stop() = 0; + virtual void seek(qint64 position) = 0; + virtual void setMedia(const QUrl &url, int audioIndex = -1, int subTitleIndex = -1) = 0; + +signals: + void stateChanged(Jellyfin::Model::PlayerStateClass::Value newState); + void mediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value newMediaStatus); + void positionChanged(qint64 newPosition); + void durationChanged(qint64 newDuration); + void errorStringChanged(); + /** + * @brief Sent when the position changed due to calling the seek method. + */ + void seeked(); + void seekableChanged(bool seekable); + void hasAudioChanged(); + void hasVideoChanged(); + void aboutToFinish(); + void videoOutputSourceChanged(); +}; + +#define USE_QTMULTIMEDIA_PLAYER +#ifdef USE_QTMULTIMEDIA_PLAYER +class QtMultimediaPlayerPrivate; +/** + * @brief Player implementation that uses QtMultimedia + */ +class QtMultimediaPlayer : public Player { + Q_OBJECT + Q_DECLARE_PRIVATE(QtMultimediaPlayer); +public: + explicit QtMultimediaPlayer(QObject *parent = nullptr); + virtual ~QtMultimediaPlayer(); + PlayerState state() const override; + MediaStatus mediaStatus() const override; + qint64 position() const override; + qint64 duration() const override; + bool seekable() const override; + bool hasVideo() const override; + bool hasAudio() const override; + QString errorString() const override; + QObject *videoOutputSource() const override; +public slots: + void pause() override; + void play(qint64 startPos = 0) override; + void stop() override; + void seek(qint64 position) override; + void setMedia(const QUrl &url, int audioIndex, int subtitleIndex) override; +private: + QScopedPointer d_ptr; +}; +#endif // ifdef USE_QTMULTIMEDIA_PLAYER + +} // NS Model +} // NS Jellyfin + +#endif // JELLYFIN_MODEL_PLAYER_H diff --git a/core/include/JellyfinQt/model/playlist.h b/core/include/JellyfinQt/model/playlist.h index 73cf4a8..49468f2 100644 --- a/core/include/JellyfinQt/model/playlist.h +++ b/core/include/JellyfinQt/model/playlist.h @@ -1,6 +1,6 @@ /* * Sailfin: a Jellyfin client written using Qt - * Copyright (C) 2021 Chris Josten and the Sailfin Contributors. + * Copyright (C) 2021-2022 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 @@ -96,9 +96,9 @@ public: void clearList(); /** - * @brief Appends all items from the given itemModel to this list + * @brief Appends all items from the given item list to this list */ - void appendToList(ViewModel::ItemModel &model); + void appendToList(const QList> &model); /** * @brief appendToList Appends a single item to the current list diff --git a/core/include/JellyfinQt/platform/freedesktop/mediaplayer2player.h b/core/include/JellyfinQt/platform/freedesktop/mediaplayer2player.h index 7336404..67a161f 100644 --- a/core/include/JellyfinQt/platform/freedesktop/mediaplayer2player.h +++ b/core/include/JellyfinQt/platform/freedesktop/mediaplayer2player.h @@ -16,6 +16,8 @@ #include #include #include +#include "JellyfinQt/model/player.h" + QT_BEGIN_NAMESPACE class QByteArray; template class QList; @@ -190,9 +192,9 @@ private: ViewModel::PlatformMediaControl *m_mediaControl; void notifyPropertiesChanged(QStringList properties); private slots: - void onCurrentItemChanged(ViewModel::Item *newItem); - void onPlaybackStateChanged(QMediaPlayer::State state); - void onMediaStatusChanged(QMediaPlayer::MediaStatus status); + void onCurrentItemChanged(); + void onPlaybackStateChanged(Jellyfin::Model::PlayerStateClass::Value state); + void onMediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value status); void onPositionChanged(qint64 position); void onSeekableChanged(bool seekable); void onPlaybackManagerChanged(ViewModel::PlaybackManager *newPlaybackManager); diff --git a/core/include/JellyfinQt/viewmodel/playbackmanager.h b/core/include/JellyfinQt/viewmodel/playbackmanager.h index 81613ed..3d36907 100644 --- a/core/include/JellyfinQt/viewmodel/playbackmanager.h +++ b/core/include/JellyfinQt/viewmodel/playbackmanager.h @@ -1,6 +1,6 @@ /* * Sailfin: a Jellyfin client written using Qt - * Copyright (C) 2021 Chris Josten and the Sailfin Contributors. + * Copyright (C) 2021-2022 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 @@ -39,6 +39,7 @@ #include "../dto/playbackinforesponse.h" #include "../dto/playmethod.h" #include "../loader/requesttypes.h" +#include "../model/player.h" #include "../model/playlist.h" #include "../support/jsonconv.h" #include "../viewmodel/item.h" @@ -60,31 +61,34 @@ class PlaystateRequest; namespace ViewModel { Q_DECLARE_LOGGING_CATEGORY(playbackManager); -// Later defined in this file -class ItemUrlFetcherThread; +class PlaybackManagerPrivate; /** - * @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, contains the actual media player and so on. + * @brief The PlaybackManager class manages the playback of Jellyfin items. * - * 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. + * It is a small wrapper around an instance of Jellyfin::Model::PlaybackManager, + * which do the actual work. The Jellyfin::Model::PlaybackManager can be switched + * on the fly, allowing this class to switch between controlling the playback locally + * or remote. */ class PlaybackManager : public QObject, public QQmlParserStatus { - friend class ItemUrlFetcherThread; Q_OBJECT + Q_DECLARE_PRIVATE(PlaybackManager); Q_INTERFACES(QQmlParserStatus) public: - using ItemUrlLoader = Support::Loader; explicit PlaybackManager(QObject *parent = nullptr); + virtual ~PlaybackManager(); - Q_PROPERTY(ApiClient *apiClient MEMBER m_apiClient WRITE setApiClient) + Q_PROPERTY(ApiClient *apiClient READ apiClient WRITE setApiClient) + Q_PROPERTY(int audioIndex READ audioIndex WRITE setAudioIndex NOTIFY audioIndexChanged) + Q_PROPERTY(int subtitleIndex READ subtitleIndex WRITE setSubtitleIndex NOTIFY subtitleIndexChanged) 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(bool autoOpen MEMBER m_autoOpen NOTIFY autoOpenChanged) + /** + * Whether the player should resume playback. + */ + Q_PROPERTY(bool resumePlayback READ resumePlayback WRITE setResumePlayback NOTIFY resumePlaybackChanged) Q_PROPERTY(Jellyfin::DTO::PlayMethodClass::Value playMethod READ playMethod NOTIFY playMethodChanged) // Current Item and queue informatoion @@ -98,42 +102,50 @@ public: Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) Q_PROPERTY(bool hasVideo READ hasVideo NOTIFY hasVideoChanged) Q_PROPERTY(bool seekable READ seekable NOTIFY seekableChanged) - 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(QObject* mediaObject READ mediaObject NOTIFY mediaObjectChanged); + Q_PROPERTY(Jellyfin::Model::MediaStatusClass::Value mediaStatus READ mediaStatus NOTIFY mediaStatusChanged) + Q_PROPERTY(Jellyfin::Model::PlayerStateClass::Value playbackState READ playbackState NOTIFY playbackStateChanged) Q_PROPERTY(qint64 position READ position NOTIFY positionChanged) Q_PROPERTY(bool hasNext READ hasNext NOTIFY hasNextChanged) Q_PROPERTY(bool hasPrevious READ hasPrevious NOTIFY hasPreviousChanged) /// Whether playstate commands received over the websocket should be handled Q_PROPERTY(bool handlePlaystateCommands READ handlePlaystateCommands WRITE setHandlePlaystateCommands NOTIFY handlePlaystateCommandsChanged) - ViewModel::Item *item() const { return m_displayItem; } - QSharedPointer dataItem() const { return m_item; } - ApiClient *apiClient() const { return m_apiClient; } + // R/W props + ApiClient *apiClient() const; void setApiClient(ApiClient *apiClient); + bool resumePlayback() const; + void setResumePlayback(bool newResumePlayback); + int audioIndex() const; + void setAudioIndex(int newAudioIndex); + int subtitleIndex() const; + void setSubtitleIndex(int newAudioIndex); - 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(); } - ViewModel::Playlist *queue() const { return m_displayQueue; } - int queueIndex() const { return m_queueIndex; } - bool hasNext() const { return m_queue->hasNext(); } - bool hasPrevious() const { return m_queue->hasPrevious(); } + ViewModel::Item *item() const; + QSharedPointer dataItem() const; + + QString streamUrl() const; + PlayMethod playMethod() const; + qint64 position() const; + qint64 duration() const; + ViewModel::Playlist *queue() const; + int queueIndex() const; + bool hasNext() const; + bool hasPrevious() const; // 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(); } - bool seekable() const { return m_mediaPlayer->isSeekable(); } + QObject* mediaObject() const; + Model::PlayerState playbackState() const; + Model::MediaStatus mediaStatus() const; + bool hasVideo() const; + bool seekable() const; QMediaPlayer::Error error () const; QString errorString() const; - bool handlePlaystateCommands() const { return m_handlePlaystateCommands; } - void setHandlePlaystateCommands(bool newHandlePlaystateCommands) { m_handlePlaystateCommands = newHandlePlaystateCommands; emit handlePlaystateCommandsChanged(m_handlePlaystateCommands); } + bool handlePlaystateCommands() const; + void setHandlePlaystateCommands(bool newHandlePlaystateCommands); signals: - void itemChanged(ViewModel::Item *newItemId); + void itemChanged(); void streamUrlChanged(const QString &newStreamUrl); void autoOpenChanged(bool autoOpen); void audioIndexChanged(int audioIndex); @@ -145,20 +157,21 @@ signals: // Emitted when seek has been called. void seeked(qint64 newPosition); + void hasNextChanged(bool newHasNext); + void hasPreviousChanged(bool newHasPrevious); + // Current media player related property signals - void mediaObjectChanged(QObject *newMediaObject); + void mediaObjectChanged(QObject *newPlayer); void positionChanged(qint64 newPosition); void durationChanged(qint64 newDuration); void queueChanged(QAbstractItemModel *newQueue); void queueIndexChanged(int newIndex); - void playbackStateChanged(QMediaPlayer::State newState); - void mediaStatusChanged(QMediaPlayer::MediaStatus newMediaStatus); + void playbackStateChanged(Jellyfin::Model::PlayerStateClass::Value newState); + void mediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value newMediaStatus); void hasVideoChanged(bool newHasVideo); void seekableChanged(bool newSeekable); void errorChanged(QMediaPlayer::Error newError); void errorStringChanged(const QString &newErrorString); - void hasNextChanged(bool newHasNext); - void hasPreviousChanged(bool newHasPrevious); void handlePlaystateCommandsChanged(bool newHandlePlaystateCommands); public slots: /** @@ -187,7 +200,7 @@ public slots: */ void skipToItemIndex(int index); void play(); - void pause() { m_mediaPlayer->pause(); setPlaybackState(QMediaPlayer::PausedState); } + void pause(); void seek(qint64 pos); void stop(); @@ -204,105 +217,21 @@ public slots: void handlePlaystateRequest(const DTO::PlaystateRequest &request); private slots: - void mediaPlayerStateChanged(QMediaPlayer::State newState); - void mediaPlayerPositionChanged(qint64 position); - void mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus); - void mediaPlayerError(QMediaPlayer::Error error); - void mediaPlayerDurationChanged(qint64 newDuration); - void mediaPlayerSeekableChanged(bool seekable); - /** - * @brief updatePlaybackInfo Updates the Jellyfin server with the current playback progress etc. - */ - void updatePlaybackInfo(); - - /// Called when we have fetched the playback URL and playSession - void onItemUrlReceived(const QString &itemId, const QUrl &url, const QString &playSession, - // Fully specify class to please MOC - Jellyfin::DTO::PlayMethodClass::Value playMethod); - /// Called when we have encountered an error - void onItemErrorReceived(const QString &itemId, const QString &errorString); - + void mediaPlayerItemChanged(); private: /// Factor to multiply with when converting from milliseconds to ticks. const static int MS_TICK_FACTOR = 10000; - enum PlaybackInfoType { Started, Stopped, Progress }; - /// Timer used to update the play progress on the Jellyfin server - QTimer m_updateTimer; - /// Timer used to notify ourselves when we need to preload the next item - QTimer m_preloadTimer; - ApiClient *m_apiClient = nullptr; - /// The currently playing item - QSharedPointer m_item; - /// The item that will be played next - QSharedPointer m_nextItem; - /// The currently played item that will be shown in the GUI - ViewModel::Item *m_displayItem = new ViewModel::Item(this); - /// The currently played queue that will be shown in the GUI - ViewModel::Playlist *m_displayQueue = nullptr; - // Properties for making the streaming request. - QString m_streamUrl; - QString m_nextStreamUrl; - QString m_playSessionId; - QString m_nextPlaySessionId; - QString m_errorString; QMediaPlayer::Error m_error = QMediaPlayer::NoError; - /// 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; - /// 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; - - bool m_seekToResumedPosition = false; - - QMediaPlayer::State m_oldState = QMediaPlayer::StoppedState; - /// State of the playbackManager. While the internal media player stops after a - /// song has ended, this will not do so. - QMediaPlayer::State m_playbackState = QMediaPlayer::StoppedState; - PlayMethod m_playMethod = PlayMethod::Transcode; - /// Pointer to the current media player. - QMediaPlayer *m_mediaPlayer = nullptr; - - Model::Playlist *m_queue = nullptr; - int m_queueIndex = 0; - bool m_resumePlayback = false; - bool m_handlePlaystateCommands = true; - - // Helper methods - void setItem(QSharedPointer newItem); - - void setStreamUrl(const QUrl &streamUrl); - void setPlaybackState(QMediaPlayer::State newState); - - /** - * @brief Posts the playback information - */ - void postPlaybackInfo(PlaybackInfoType type); - - void requestItemUrl(QSharedPointer item); - void handlePlaybackInfoResponse(QString itemId, QString mediaType, DTO::PlaybackInfoResponse &response); - // QQmlParserListener interface void classBegin() override { m_qmlIsParsingComponent = true; } void componentComplete() override; bool m_qmlIsParsingComponent = false; - /// Time in ms at what moment this playbackmanager should start loading the next item. - const qint64 PRELOAD_DURATION = 15 * 1000; - QTimer m_forceSeekTimer; + QScopedPointer d_ptr; }; } // NS ViewModel diff --git a/core/src/jellyfin.cpp b/core/src/jellyfin.cpp index aac72cb..843a62c 100644 --- a/core/src/jellyfin.cpp +++ b/core/src/jellyfin.cpp @@ -30,6 +30,7 @@ #include "JellyfinQt/eventbus.h" #include "JellyfinQt/serverdiscoverymodel.h" #include "JellyfinQt/websocket.h" +#include "JellyfinQt/model/player.h" #include "JellyfinQt/viewmodel/item.h" #include "JellyfinQt/viewmodel/itemmodel.h" #include "JellyfinQt/viewmodel/loader.h" @@ -87,6 +88,8 @@ void JellyfinPlugin::registerTypes(const char *uri) { qmlRegisterUncreatableType(uri, 1, 0, "ItemFields", "Is an enum"); qmlRegisterUncreatableType(uri, 1, 0, "ImageType", "Is an enum"); qmlRegisterUncreatableType(uri, 1, 0, "NowPlayingSection", "Is an enum"); + qmlRegisterUncreatableType(uri, 1, 0, "PlayerState", "Is an enum"); + qmlRegisterUncreatableType(uri, 1, 0, "MediaStatus", "Is an enum"); qRegisterMetaType(); } diff --git a/core/src/model/playbackmanager.cpp b/core/src/model/playbackmanager.cpp new file mode 100644 index 0000000..26fc8ea --- /dev/null +++ b/core/src/model/playbackmanager.cpp @@ -0,0 +1,700 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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 + +#include + +#include +#include +#include +#include +#include +#include + +namespace Jellyfin { +namespace Model { + +Q_LOGGING_CATEGORY(playbackManager, "jellyfinqt.model.playbackmanager"); + +class PlaybackManagerPrivate { + Q_DECLARE_PUBLIC(PlaybackManager); +public: + PlaybackManagerPrivate(PlaybackManager *q); + ApiClient *m_apiClient = nullptr; + + /// Timer used to notify ourselves when we need to preload the next item + QTimer m_preloadTimer; + + PlaybackManagerError m_error; + QString m_errorString; + QString m_playSessionId; + QString m_nextPlaySessionId; + + /// 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 currently playing item + QSharedPointer m_item; + /// The item that will be played next + QSharedPointer m_nextItem; + + PlayerState m_state; + + Model::Playlist *m_queue = nullptr; + int m_queueIndex = 0; + + bool m_resumePlayback = false; + /// The position in ticks to resume playback from + qint64 m_resumePosition = 0; + + bool m_handlePlaystateCommands = true; + + PlaybackManager *q_ptr; + + virtual void setItem(QSharedPointer newItem); + void skipToItemIndex(int index); + void setState(PlayerState newState); +}; + +PlaybackManagerPrivate::PlaybackManagerPrivate(PlaybackManager *q) + : q_ptr(q) { +} + + +void PlaybackManagerPrivate::setItem(QSharedPointer newItem) { + Q_Q(PlaybackManager); + this->m_item = newItem; + emit q->itemChanged(); +} + +void PlaybackManagerPrivate::skipToItemIndex(int index) { + Q_Q(PlaybackManager); + if (index < m_queue->queueSize()) { + // Skip until we hit the right number in the queue + index++; + while(index != 0) { + m_queue->next(); + index--; + } + } else { + m_queue->play(index); + } + setItem(m_queue->currentItem()); + emit q->hasNextChanged(m_queue->hasNext()); + emit q->hasPreviousChanged(m_queue->hasPrevious()); + +} + +void PlaybackManagerPrivate::setState(PlayerState newState) { + Q_Q(PlaybackManager); + + m_state = newState; + emit q->playbackStateChanged(newState); +} +/***************************************************************************** + * PlaybackManager * + *****************************************************************************/ + +PlaybackManager::PlaybackManager(QObject *parent) + : PlaybackManager(new PlaybackManagerPrivate(this), parent) { + Q_D(PlaybackManager); +} + +PlaybackManager::PlaybackManager(PlaybackManagerPrivate *d, QObject *parent) + : QObject(parent) { + QScopedPointer foo(d); + d_ptr.swap(foo); + d_ptr->m_queue = new Playlist(this); +} + +PlaybackManager::~PlaybackManager() {} + +ApiClient *PlaybackManager::apiClient() const { + const Q_D(PlaybackManager); + return d->m_apiClient; +} + +void PlaybackManager::setApiClient(ApiClient *apiClient) { + Q_D(PlaybackManager); + d->m_apiClient = apiClient; +} + +QSharedPointer PlaybackManager::currentItem() const { + const Q_D(PlaybackManager); + return d->m_item; +} + +Playlist *PlaybackManager::queue() const { + const Q_D(PlaybackManager); + return d->m_queue; +} + +int PlaybackManager::queueIndex() const { + const Q_D(PlaybackManager); + return d->m_queueIndex; +} + +void PlaybackManager::playItemId(const QString &id) {} + +bool PlaybackManager::resumePlayback() const { + const Q_D(PlaybackManager); + return d->m_resumePlayback; +} + +void PlaybackManager::setResumePlayback(bool newResumePlayback) { + Q_D(PlaybackManager); + d->m_resumePlayback = newResumePlayback; + emit resumePlaybackChanged(newResumePlayback); +} + +int PlaybackManager::audioIndex() const { + const Q_D(PlaybackManager); + return d->m_audioIndex; +} + +void PlaybackManager::setAudioIndex(int newAudioIndex) { + Q_D(PlaybackManager); + d->m_audioIndex = newAudioIndex; + emit audioIndexChanged(newAudioIndex); +} + +int PlaybackManager::subtitleIndex() const { + const Q_D(PlaybackManager); + return d->m_subtitleIndex; +} + +void PlaybackManager::setSubtitleIndex(int newSubtitleIndex) { + Q_D(PlaybackManager); + d->m_subtitleIndex = newSubtitleIndex; + emit subtitleIndexChanged(newSubtitleIndex); +} + +/***************************************************************************** + * LocalPlaybackManagerPrivate * + *****************************************************************************/ + +class LocalPlaybackManagerPrivate : public PlaybackManagerPrivate { + Q_DECLARE_PUBLIC(LocalPlaybackManager); +public: + explicit LocalPlaybackManagerPrivate(LocalPlaybackManager *q); + Player *m_mediaPlayer; + // Properties for making the streaming request. + QUrl m_streamUrl; + QUrl m_nextStreamUrl; + DTO::PlayMethod m_playMethod = DTO::PlayMethod::Transcode; + + + void setItem(QSharedPointer newItem) override; + void setStreamUrl(const QUrl &streamUrl); + void requestItemUrl(QSharedPointer item); + + // slots + void handlePlaybackInfoResponse(QString itemId, QString mediaType, DTO::PlaybackInfoResponse &response); + /// Called when we have fetched the playback URL and playSession + void onItemUrlReceived(const QString &itemId, const QUrl &url, const QString &playSession, + // Fully specify class to please MOC + Jellyfin::DTO::PlayMethodClass::Value playMethod); + /// Called when we have encountered an error + void onItemErrorReceived(const QString &itemId, const QString &errorString); + + + + /** + * @brief Whether to automatically open the livestream of the item; + */ + bool m_autoOpen = false; + PlaybackReporter *m_reporter = nullptr; +public slots: + void onPlayerError(); + void onMediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value newMediaStatus); +}; + +LocalPlaybackManagerPrivate::LocalPlaybackManagerPrivate(LocalPlaybackManager *q) + : PlaybackManagerPrivate(q), + m_reporter(new PlaybackReporter()){ +} + +void LocalPlaybackManagerPrivate::setStreamUrl(const QUrl &streamUrl) { + Q_Q(LocalPlaybackManager); + + m_streamUrl = streamUrl; + Q_ASSERT_X(streamUrl.isValid() || streamUrl.isEmpty(), "setStreamUrl", "StreamURL Jellyfin returned is not valid"); + emit q->streamUrlChanged(m_streamUrl); +} + +void LocalPlaybackManagerPrivate::requestItemUrl(QSharedPointer item) { + Q_Q(LocalPlaybackManager); + using ItemUrlLoader = Support::Loader; + ItemUrlLoader *loader = new Jellyfin::Loader::HTTP::GetPostedPlaybackInfoLoader(m_apiClient); + Jellyfin::Loader::GetPostedPlaybackInfoParams params; + + + // Check if we'd prefer to transcode if the video file contains multiple audio tracks + // or if a subtitle track was selected. + // This has to be done due to the lack of support of selecting audio tracks within QtMultimedia + bool allowTranscoding = m_apiClient->settings()->allowTranscoding(); + bool transcodePreferred = m_subtitleIndex > 0; + int audioTracks = 0; + const QList &streams = item->mediaStreams(); + for(int i = 0; i < streams.size(); i++) { + const DTO::MediaStream &stream = streams[i]; + if (stream.type() == MediaStreamType::Audio) { + audioTracks++; + } + } + if (audioTracks > 1) { + transcodePreferred = true; + } + + bool forceTranscoding = allowTranscoding && transcodePreferred; + + QSharedPointer playbackInfo = QSharedPointer::create(m_apiClient->deviceProfile()); + params.setItemId(item->jellyfinId()); + params.setUserId(m_apiClient->userId()); + playbackInfo->setEnableDirectPlay(true); + playbackInfo->setEnableDirectStream(!forceTranscoding); + playbackInfo->setEnableTranscoding(forceTranscoding || allowTranscoding); + playbackInfo->setAudioStreamIndex(this->m_audioIndex); + playbackInfo->setSubtitleStreamIndex(this->m_subtitleIndex); + params.setBody(playbackInfo); + + loader->setParameters(params); + q->connect(loader, &ItemUrlLoader::ready, q, [this, loader, item] { + DTO::PlaybackInfoResponse result = loader->result(); + handlePlaybackInfoResponse(item->jellyfinId(), item->mediaType(), result); + loader->deleteLater(); + }); + q->connect(loader, &ItemUrlLoader::error, q, [this, loader, item](QString message) { + onItemErrorReceived(item->jellyfinId(), message); + loader->deleteLater(); + }); + loader->load(); +} + +void LocalPlaybackManagerPrivate::setItem(QSharedPointer newItem) { + Q_Q(LocalPlaybackManager); + if (m_mediaPlayer != nullptr) m_mediaPlayer->stop(); + bool shouldFetchStreamUrl = !newItem.isNull() + && ((m_streamUrl.isEmpty() || (!m_item.isNull() + && m_item->jellyfinId() != newItem->jellyfinId())) + || (m_nextStreamUrl.isEmpty() || (!m_nextItem.isNull() + && m_nextItem->jellyfinId() != newItem->jellyfinId()))); + + this->m_item = newItem; + + if (!newItem.isNull()) { + if (!newItem->userData().isNull()) { + m_resumePosition = newItem->userData()->playbackPositionTicks(); + } + } + emit q->itemChanged(); + + emit q->hasNextChanged(m_queue->hasNext()); + emit q->hasPreviousChanged(m_queue->hasPrevious()); + + if (m_apiClient == nullptr) { + + qCWarning(playbackManager) << "apiClient is not set on this playbackmanager instance! Aborting."; + return; + } + // Deinitialize the streamUrl + if (shouldFetchStreamUrl) { + qCDebug(playbackManager) << "Fetching streamUrl before playing"; + setStreamUrl(QUrl()); + requestItemUrl(m_item); + } else { + qCDebug(playbackManager) << "StreamUrl already fetched, playing!"; + setStreamUrl(m_nextStreamUrl); + if (m_mediaPlayer != nullptr) m_mediaPlayer->play(); + } +} + +void LocalPlaybackManagerPrivate::handlePlaybackInfoResponse(QString itemId, QString mediaType, DTO::PlaybackInfoResponse &response) { + Q_Q(LocalPlaybackManager); + //TODO: move the item URL fetching logic out of this function, into MediaSourceInfo? + QList mediaSources = response.mediaSources(); + QUrl resultingUrl; + QString playSession = response.playSessionId(); + PlayMethod playMethod = PlayMethod::EnumNotSet; + bool transcodingAllowed = m_apiClient->settings()->allowTranscoding(); + + for (int i = 0; i < mediaSources.size(); i++) { + const DTO::MediaSourceInfo &source = mediaSources.at(i); + + // Check if we'd prefer to transcode if the video file contains multiple audio tracks + // or if a subtitle track was selected. + // This has to be done due to the lack of support of selecting audio tracks within QtMultimedia + bool transcodePreferred = false; + if (transcodingAllowed) { + transcodePreferred = m_subtitleIndex > 0; + int audioTracks = 0; + const QList &streams = source.mediaStreams(); + for (int i = 0; i < streams.size(); i++) { + DTO::MediaStream stream = streams[i]; + if (stream.type() == MediaStreamType::Audio) { + audioTracks++; + } + } + if (audioTracks > 1) { + transcodePreferred = false; + } + } + + + qCDebug(playbackManager()) << "Media source: " << source.name() << "\n" + << "Prefer transcoding: " << transcodePreferred << "\n" + << "DirectPlay supported: " << source.supportsDirectPlay() << "\n" + << "DirectStream supported: " << source.supportsDirectStream() << "\n" + << "Transcode supported: " << source.supportsTranscoding() << source.transcodingUrl(); + + if (source.supportsDirectPlay() && QFile::exists(source.path())) { + resultingUrl = QUrl::fromLocalFile(source.path()); + playMethod = PlayMethod::DirectPlay; + } else if (source.supportsDirectStream() && !transcodePreferred) { + if (mediaType == "Video") { + mediaType.append('s'); + } + QUrlQuery query; + query.addQueryItem("mediaSourceId", source.jellyfinId()); + query.addQueryItem("deviceId", m_apiClient->deviceId()); + query.addQueryItem("api_key", m_apiClient->token()); + query.addQueryItem("Static", "True"); + resultingUrl = QUrl(m_apiClient->baseUrl() + "/" + mediaType + "/" + itemId + + "/stream." + source.container() + "?" + query.toString(QUrl::EncodeReserved)); + playMethod = PlayMethod::DirectStream; + } else if (source.supportsTranscoding() && !source.transcodingUrlNull() && transcodingAllowed) { + qCDebug(playbackManager) << "Transcoding url: " << source.transcodingUrl(); + resultingUrl = QUrl(m_apiClient->baseUrl() + source.transcodingUrl()); + playMethod = PlayMethod::Transcode; + } else { + qCDebug(playbackManager) << "No suitable sources for item " << itemId; + } + if (!resultingUrl.isEmpty()) break; + } + if (resultingUrl.isEmpty()) { + qCWarning(playbackManager) << "Could not find suitable media source for item " << itemId; + onItemErrorReceived(itemId, q->tr("Could not find a suitable media source.")); + } else { + emit q->playMethodChanged(playMethod); + onItemUrlReceived(itemId, resultingUrl, playSession, playMethod); + } +} + +void LocalPlaybackManagerPrivate::onItemUrlReceived(const QString &itemId, const QUrl &url, const QString &playSession, Jellyfin::DTO::PlayMethodClass::Value playMethod) { + Q_Q(LocalPlaybackManager); + Q_UNUSED(playSession) + qCDebug(playbackManager) << "Item URL received for item" << itemId; + if (!m_item.isNull() && m_item->jellyfinId() == itemId) { + // We want to play the item probably right now + m_playSessionId = playSession; + m_playMethod = playMethod; + m_resumePosition = m_item->userData()->playbackPositionTicks(); + setStreamUrl(url); + qCDebug(playbackManager) << "Starting playback!"; + emit q->playMethodChanged(m_playMethod); + + // Clear the error string if it is currently set + if (!m_errorString.isEmpty()) { + m_errorString.clear(); + emit q->errorStringChanged(m_errorString); + } + + if (m_error != PlaybackManagerError::NoError) { + m_error = PlaybackManagerError::NoError; + emit q->errorChanged(m_error); + } + + m_mediaPlayer->setMedia(url, m_audioIndex, m_subtitleIndex); + m_mediaPlayer->play(m_resumePosition); + m_resumePosition = 0; + } else { + qDebug() << "Late reply for " << itemId << " received, ignoring"; + } +} + +void LocalPlaybackManagerPrivate::onItemErrorReceived(const QString &itemId, const QString &errorString) { + Q_Q(LocalPlaybackManager); + qWarning() << "Error while fetching streaming url for " << itemId << ": " << errorString; + if (!m_item.isNull() && m_item->jellyfinId() == itemId) { + setStreamUrl(QUrl()); + m_error = PlaybackManagerError::PlaybackInfoError; + emit q->errorChanged(PlaybackManagerError::PlaybackInfoError); + m_errorString = errorString; + emit q->errorStringChanged(errorString); + } +} + +void LocalPlaybackManagerPrivate::onPlayerError() { + Q_Q(LocalPlaybackManager); + m_error = PlaybackManagerError::PlayerGeneralError; + m_errorString = m_mediaPlayer->errorString(); + emit q->errorChanged(m_error); + emit q->errorStringChanged(m_errorString); + qWarning() << "Player error: " << m_errorString; +} + +void LocalPlaybackManagerPrivate::onMediaStatusChanged(MediaStatus newStatus) { + Q_Q(LocalPlaybackManager); + if (m_state == PlayerState::Stopped) return; + if (newStatus == MediaStatus::Loaded) { + m_mediaPlayer->play(); + } else if (newStatus == MediaStatus::EndOfMedia) { + if (m_queue->hasNext() && m_queue->totalSize() > 1) { + q->next(); + } else { + // End of the playlist + setState(PlayerState::Stopped); + } + } +} + + +/***************************************************************************** + * LocalPlaybackManager * + *****************************************************************************/ +LocalPlaybackManager::LocalPlaybackManager(QObject *parent) + : PlaybackManager(new LocalPlaybackManagerPrivate(this), parent) { + Q_D(LocalPlaybackManager); + d->m_mediaPlayer = new QtMultimediaPlayer(this); + d->m_reporter->setPlaybackManager(this); + connect(d->m_mediaPlayer, &Player::positionChanged, this, &LocalPlaybackManager::positionChanged); + connect(d->m_mediaPlayer, &Player::durationChanged, this, &LocalPlaybackManager::durationChanged); + connect(d->m_mediaPlayer, &Player::stateChanged, this, &LocalPlaybackManager::playbackStateChanged); + connect(d->m_mediaPlayer, &Player::seekableChanged, this, &LocalPlaybackManager::seekableChanged); + connect(d->m_mediaPlayer, &Player::mediaStatusChanged, this, [this, d](MediaStatus newStatus) -> void { + d->onMediaStatusChanged(newStatus); + emit mediaStatusChanged(d->m_mediaPlayer->mediaStatus()); + }); + connect(d->m_mediaPlayer, &Player::hasAudioChanged, this, &LocalPlaybackManager::hasAudioChanged); + connect(d->m_mediaPlayer, &Player::hasVideoChanged, this, &LocalPlaybackManager::hasVideoChanged); + connect(d->m_mediaPlayer, &Player::errorStringChanged, this, [d]() { + d->onPlayerError(); + }); +} + +void LocalPlaybackManager::swap(PlaybackManager &other) { + Q_UNIMPLEMENTED(); +} + +Player* LocalPlaybackManager::player() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer; +} + +QString LocalPlaybackManager::sessionId() const { + const Q_D(LocalPlaybackManager); + return d->m_playSessionId; +} + +DTO::PlayMethod LocalPlaybackManager::playMethod() const { + const Q_D(LocalPlaybackManager); + return d->m_playMethod; +} + +const QUrl& LocalPlaybackManager::streamUrl() const { + const Q_D(LocalPlaybackManager); + return d->m_streamUrl; +} + +PlayerState LocalPlaybackManager::playbackState() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->state(); +} + +MediaStatus LocalPlaybackManager::mediaStatus() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->mediaStatus(); +} + +PlaybackManagerError LocalPlaybackManager::error() const { + const Q_D(LocalPlaybackManager); + return d->m_error; +} + +const QString &LocalPlaybackManager::errorString() const { + const Q_D(LocalPlaybackManager); + return d->m_errorString; +} + +qint64 LocalPlaybackManager::position() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->position(); +} + +qint64 LocalPlaybackManager::duration() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->duration(); +} + +bool LocalPlaybackManager::seekable() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->seekable(); +} + +void LocalPlaybackManager::pause() { + Q_D(LocalPlaybackManager); + d->m_mediaPlayer->pause(); +} + +void LocalPlaybackManager::play() { + Q_D(LocalPlaybackManager); + if (d->m_queue->totalSize() > 0) { + d->m_mediaPlayer->play(); + d->setState(PlayerState::Playing); + } +} + +void LocalPlaybackManager::playItem(QSharedPointer item) { + Q_D(LocalPlaybackManager); + d->m_queue->clearList(); + d->m_queue->appendToList(item); + d->m_queue->play(); + d->m_queueIndex = 0; + + d->setItem(item); + + emit hasNextChanged(d->m_queue->hasNext()); + emit hasPreviousChanged(d->m_queue->hasPrevious()); + d->setState(PlayerState::Playing); +} + +void LocalPlaybackManager::playItemId(const QString &itemId) { + Q_D(PlaybackManager); + Jellyfin::Loader::HTTP::GetItemLoader *loader = new Jellyfin::Loader::HTTP::GetItemLoader(d->m_apiClient); + connect(loader, &Support::LoaderBase::error, this, [loader]() { + // TODO: error handling + loader->deleteLater(); + }); + connect(loader, &Support::LoaderBase::ready, this, [this, loader](){ + this->playItem(QSharedPointer::create(loader->result())); + loader->deleteLater(); + }); + Jellyfin::Loader::GetItemParams params; + params.setUserId(d->m_apiClient->userId()); + params.setItemId(itemId); + loader->setParameters(params); + loader->load(); +} + +void LocalPlaybackManager::playItemInList(const QList> &items, int index) { + Q_D(LocalPlaybackManager); + d->m_queue->clearList(); + d->m_queue->appendToList(items); + d->m_queue->play(index); + d->m_queueIndex = index; + + emit queueIndexChanged(d->m_queueIndex); + + d->setItem(items.at(index)); + emit hasNextChanged(d->m_queue->hasNext()); + emit hasPreviousChanged(d->m_queue->hasPrevious()); + d->setState(PlayerState::Playing); +} + +void LocalPlaybackManager::goTo(int index) { + Q_D(LocalPlaybackManager); + d->m_queue->play(index); + d->m_queueIndex = index; + emit queueIndexChanged(index); + + d->setItem(d->m_queue->currentItem()); + emit hasNextChanged(d->m_queue->hasNext()); + emit hasPreviousChanged(d->m_queue->hasPrevious()); + d->setState(PlayerState::Playing); +} + +bool LocalPlaybackManager::hasNext() const { + const Q_D(LocalPlaybackManager); + return d->m_queue->hasNext(); +} + +bool LocalPlaybackManager::hasPrevious() const { + const Q_D(LocalPlaybackManager); + return d->m_queue->hasPrevious(); +} + +void LocalPlaybackManager::next() { + Q_D(LocalPlaybackManager); + d->m_mediaPlayer->stop(); + d->m_mediaPlayer->setMedia(QUrl()); + + if (d->m_nextItem.isNull() || !d->m_queue->nextItem()->sameAs(*d->m_nextItem)) { + d->setItem(d->m_queue->nextItem()); + d->m_nextStreamUrl = QString(); + d->m_queue->next(); + d->m_nextItem.clear(); + } else { + d->m_item = d->m_nextItem; + d->m_streamUrl = d->m_nextStreamUrl; + + d->m_nextItem.clear(); + d->m_nextStreamUrl = QString(); + + d->m_queue->next(); + d->setItem(d->m_nextItem); + } + emit hasNextChanged(d->m_queue->hasNext()); + emit hasPreviousChanged(d->m_queue->hasPrevious()); +} + +void LocalPlaybackManager::previous() { + Q_D(LocalPlaybackManager); + d->m_mediaPlayer->stop(); + d->m_mediaPlayer->seek(0); + + d->m_item.clear(); + d->m_streamUrl = QString(); + + d->m_nextStreamUrl = d->m_streamUrl; + d->m_nextItem = d->m_queue->nextItem(); + + d->m_queue->previous(); + d->setItem(d->m_queue->currentItem()); + + emit hasNextChanged(d->m_queue->hasNext()); + emit hasPreviousChanged(d->m_queue->hasPrevious()); +} + +void LocalPlaybackManager::stop() { + Q_D(LocalPlaybackManager); + d->m_queue->clearList(); + d->m_mediaPlayer->stop(); + d->setState(PlayerState::Stopped); +} + +void LocalPlaybackManager::seek(qint64 newPosition) { + Q_D(LocalPlaybackManager); + d->m_mediaPlayer->seek(newPosition); +} + +bool LocalPlaybackManager::hasAudio() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->hasAudio(); +} + +bool LocalPlaybackManager::hasVideo() const { + const Q_D(LocalPlaybackManager); + return d->m_mediaPlayer->hasVideo(); +} + +} // NS Model +} // NS Jellyfin diff --git a/core/src/model/playbackreporter.cpp b/core/src/model/playbackreporter.cpp new file mode 100644 index 0000000..8d26b10 --- /dev/null +++ b/core/src/model/playbackreporter.cpp @@ -0,0 +1,200 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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 + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace Jellyfin { +namespace Model { + +Q_LOGGING_CATEGORY(playbackReporter, "jellyfin.model.playbackReporter"); + +class PlaybackReporterPrivate : public QObject { + Q_DECLARE_PUBLIC(PlaybackReporter); +public: + explicit PlaybackReporterPrivate(PlaybackReporter *parent) + : QObject(parent) { + q_ptr = parent; + } + + PlaybackReporter *q_ptr; + LocalPlaybackManager *m_playbackManager = nullptr; + + /// Timer used to update the play progress on the Jellyfin server + QTimer m_updateTimer; + enum PlaybackInfoType { Started, Stopped, Progress }; + qint64 m_oldPosition = 0; + quint64 m_stopPosition = 0; + + PlayerState m_oldState; + + static const int MS_TICK_FACTOR = 10000; +public slots: + void positionChanged(qint64 position); + void playerStateChanged(Jellyfin::Model::PlayerStateClass::Value newState); + + /** + * @brief updatePlaybackInfo Updates the Jellyfin server with the current playback progress etc. + */ + void updatePlaybackInfo(); + void postPlaybackInfo(Jellyfin::Model::PlaybackReporterPrivate::PlaybackInfoType type); +}; + +// PlaybackReporter + +PlaybackReporter::PlaybackReporter(QObject *parent) + : QObject(parent), + d_ptr(new PlaybackReporterPrivate(this)){ + Q_D(PlaybackReporter); + + d->m_updateTimer.setInterval(10000); // 10 seconds + d->m_updateTimer.setSingleShot(false); + connect(&d->m_updateTimer, &QTimer::timeout, d, &PlaybackReporterPrivate::updatePlaybackInfo); +} + +void PlaybackReporter::setPlaybackManager(LocalPlaybackManager *playbackManager) { + Q_D(PlaybackReporter); + if (d->m_playbackManager != nullptr) { + // Disconnect + disconnect(d->m_playbackManager, &PlaybackManager::playbackStateChanged, d, &PlaybackReporterPrivate::playerStateChanged); + disconnect(d->m_playbackManager->player(), &Player::seeked, d, &PlaybackReporterPrivate::updatePlaybackInfo); + disconnect(d->m_playbackManager->player(), &Player::positionChanged, d, &PlaybackReporterPrivate::positionChanged); + } + + d->m_playbackManager = playbackManager; + if (d->m_playbackManager != nullptr) { + this->setParent(d->m_playbackManager); + connect(d->m_playbackManager, &PlaybackManager::playbackStateChanged, d, &PlaybackReporterPrivate::playerStateChanged); + connect(d->m_playbackManager->player(), &Player::seeked, d, &PlaybackReporterPrivate::updatePlaybackInfo); + connect(d->m_playbackManager->player(), &Player::positionChanged, d, &PlaybackReporterPrivate::positionChanged); + } else { + this->setParent(nullptr); + } +} + +// Private + +void PlaybackReporterPrivate::positionChanged(qint64 newPosition) { + if (newPosition == 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 + // want the old position. + m_stopPosition = m_oldPosition; + } + m_oldPosition = newPosition; +} + +void PlaybackReporterPrivate::playerStateChanged(PlayerState newState) { + if (m_oldState == newState) return; + + if (m_oldState == PlayerState::Stopped) { + // We're transitioning from stopped to either playing or paused. + // Set up the recurring timer + m_updateTimer.start(); + postPlaybackInfo(Started); + } else if (newState == PlayerState::Stopped) { + // We've stopped playing the media. Post a stop signal. + m_updateTimer.stop(); + postPlaybackInfo(Stopped); + } else { + postPlaybackInfo(Progress); + } + m_oldState = newState; +} + +void PlaybackReporterPrivate::updatePlaybackInfo() { + postPlaybackInfo(Progress); +} + +void PlaybackReporterPrivate::postPlaybackInfo(PlaybackInfoType type) { + if (m_playbackManager == nullptr) { + qCWarning(playbackReporter) << "PlaybackManager not set. Not posting playback info"; + return; + } else if (m_playbackManager->apiClient() == nullptr) { + qCWarning(playbackReporter) << "Set PlaybackManager does not have a apiClient set. Not posting playback info"; + return; + } else if (m_playbackManager->currentItem().isNull()) { + qCWarning(playbackReporter) << "Item is null. Not posting playback info"; + return; + } + DTO::PlaybackProgressInfo progress( + m_playbackManager->player()->seekable(), + m_playbackManager->currentItem(), + m_playbackManager->currentItem()->jellyfinId(), + m_playbackManager->player()->state() == PlayerState::Paused, + false, // is muted? + m_playbackManager->playMethod(), + DTO::RepeatMode::RepeatNone); + + progress.setSessionId(m_playbackManager->sessionId()); + + switch(type) { + case Started: // FALLTHROUGH + case Progress: { + progress.setAudioStreamIndex(m_playbackManager->audioIndex()); + progress.setSubtitleStreamIndex(m_playbackManager->subtitleIndex()); + progress.setPositionTicks(m_playbackManager->player()->position() * MS_TICK_FACTOR); + + Playlist *playlist = m_playbackManager->queue(); + QList queue; + for (int i = 0; i < playlist->listSize(); i++) { + DTO::QueueItem queueItem(playlist->listAt(i)->jellyfinId()); + queue.append(queueItem); + } + progress.setNowPlayingQueue(queue); + break; + } + case Stopped: + progress.setPositionTicks(m_stopPosition * MS_TICK_FACTOR); + break; + } + + QString path; + switch (type) { + case Started: + path = "/Sessions/Playing"; + break; + case Progress: + path = "/Sessions/Playing/Progress"; + break; + case Stopped: + path = "/Sessions/Playing/Stopped"; + break; + } + + // client is never null, checked at the start of this function. + ApiClient *client = m_playbackManager->apiClient(); + QNetworkReply *rep = client->post(path, QJsonDocument(progress.toJson())); + connect(rep, &QNetworkReply::finished, this, [rep](){ + rep->deleteLater(); + }); + client->setDefaultErrorHandler(rep); +} + +} // NS Model +} // NS Jellyfin diff --git a/core/src/model/player.cpp b/core/src/model/player.cpp new file mode 100644 index 0000000..e1213d7 --- /dev/null +++ b/core/src/model/player.cpp @@ -0,0 +1,236 @@ +/* + * Sailfin: a Jellyfin client written using Qt + * Copyright (C) 2021-2022 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 + + +#ifdef USE_QTMULTIMEDIA_PLAYER +#include +#include +#include +#endif // USE_QTMULTIMEDIA_PLAYER + +namespace Jellyfin { +namespace Model { + +Q_LOGGING_CATEGORY(player, "jellyfin.model.player"); + +Player::~Player() {} + +#ifdef USE_QTMULTIMEDIA_PLAYER +class QtMultimediaPlayerPrivate { + Q_DECLARE_PUBLIC(QtMultimediaPlayer); +public: + explicit QtMultimediaPlayerPrivate(QtMultimediaPlayer *q); + QMediaPlayer *m_mediaPlayer; + QMediaStreamsControl *m_mediaStreamsControl; + QTimer m_forceSeekTimer; + bool m_seekToResumePosition = false; + qint64 m_resumePosition; + int m_audioIndex = -1; + int m_subtitleIndex = -1; + + static const qint64 MS_TICK_FACTOR = 10000; +protected: + QtMultimediaPlayer *q_ptr; +}; + + +QtMultimediaPlayerPrivate::QtMultimediaPlayerPrivate(QtMultimediaPlayer *q) + : m_mediaPlayer(new QMediaPlayer(q, QMediaPlayer::VideoSurface)), + q_ptr(q) { + + m_mediaStreamsControl = m_mediaPlayer->service()->requestControl(); + // Yes, this is a very ugly way of forcing the video player to seek to the resume position + q->connect(&m_forceSeekTimer, &QTimer::timeout, q, [this]() { + if (m_seekToResumePosition && m_mediaPlayer->isSeekable()) { + qCDebug(player) << "Trying to seek to the resume position" << (m_resumePosition / MS_TICK_FACTOR); + if (m_mediaPlayer->position() > m_resumePosition / MS_TICK_FACTOR - 500) { + m_seekToResumePosition = false; + m_forceSeekTimer.stop(); + } else { + m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); + } + } + }); + q->connect(m_mediaPlayer, &QMediaPlayer::seekableChanged, q, [this](bool newSeekable) { + if (newSeekable && m_seekToResumePosition) { + m_forceSeekTimer.start(); + } + }); + m_forceSeekTimer.setInterval(500); + m_forceSeekTimer.setSingleShot(false); + + // Connect other properties + q->connect(m_mediaPlayer, &QMediaPlayer::stateChanged, q, [q](QMediaPlayer::State /*newState*/){ + emit q->stateChanged(q->state()); + }); + q->connect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, q, [q, this](QMediaPlayer::MediaStatus newMediaStatus) { + emit q->mediaStatusChanged(q->mediaStatus()); + }); + q->connect(m_mediaPlayer, &QMediaPlayer::positionChanged, q, &QtMultimediaPlayer::positionChanged); + q->connect(m_mediaPlayer, &QMediaPlayer::durationChanged, q, &QtMultimediaPlayer::durationChanged); + q->connect(m_mediaPlayer, &QMediaPlayer::seekableChanged, q, &QtMultimediaPlayer::seekableChanged); + q->connect(m_mediaPlayer, &QMediaPlayer::audioAvailableChanged, q, &QtMultimediaPlayer::hasAudioChanged); + q->connect(m_mediaPlayer, &QMediaPlayer::videoAvailableChanged, q, &QtMultimediaPlayer::hasVideoChanged); + q->connect(m_mediaPlayer, SIGNAL(error(QMediaPlayer::Error)), q, SLOT(errorStringChanged)); + if (m_mediaStreamsControl != nullptr) { + q->connect(m_mediaStreamsControl, &QMediaStreamsControl::streamsChanged, q, [this](){ + qCDebug(player) << m_mediaStreamsControl->streamCount() << " streams in the medi source"; + if (m_audioIndex >= 0) { + m_mediaStreamsControl->setActive(m_audioIndex, true); + } + if (m_subtitleIndex >= 0) { + m_mediaStreamsControl->setActive(m_subtitleIndex, true); + } + }); + } + +} +QtMultimediaPlayer::QtMultimediaPlayer(QObject *parent) + : d_ptr(new QtMultimediaPlayerPrivate(this)){ +} + +QtMultimediaPlayer::~QtMultimediaPlayer() {} + +PlayerState QtMultimediaPlayer::state() const { + const Q_D(QtMultimediaPlayer); + switch(d->m_mediaPlayer->state()) { + case QMediaPlayer::StoppedState: + return PlayerState::Stopped; + case QMediaPlayer::PlayingState: + return PlayerState::Playing; + case QMediaPlayer::PausedState: + return PlayerState::Paused; + default: + Q_ASSERT_X(false, "QtMultimediaPlayer::state()", "Invalid switch case"); + return PlayerState::Stopped; + } +} + +MediaStatus QtMultimediaPlayer::mediaStatus() const { + const Q_D(QtMultimediaPlayer); + switch(d->m_mediaPlayer->mediaStatus()) { + case QMediaPlayer::UnknownMediaStatus: + return MediaStatus::Error; + case QMediaPlayer::NoMedia: + return MediaStatus::NoMedia; + case QMediaPlayer::LoadingMedia: + return MediaStatus::Loading; + case QMediaPlayer::LoadedMedia: + return MediaStatus::Loaded; + case QMediaPlayer::StalledMedia: + return MediaStatus::Stalled; + case QMediaPlayer::BufferingMedia: + return MediaStatus::Buffering; + case QMediaPlayer::BufferedMedia: + return MediaStatus::Buffered; + case QMediaPlayer::EndOfMedia: + return MediaStatus::EndOfMedia; + case QMediaPlayer::InvalidMedia: + default: + return MediaStatus::Error; + } +} + +qint64 QtMultimediaPlayer::position() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->position(); +} + +qint64 QtMultimediaPlayer::duration() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->duration(); +} + +bool QtMultimediaPlayer::seekable() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->isSeekable(); +} + +bool QtMultimediaPlayer::hasAudio() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->isAudioAvailable(); +} + +bool QtMultimediaPlayer::hasVideo() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->isVideoAvailable(); +} + +QString QtMultimediaPlayer::errorString() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer->errorString(); +} + +void QtMultimediaPlayer::pause() { + Q_D(QtMultimediaPlayer); + d->m_mediaPlayer->pause(); +} + +void QtMultimediaPlayer::play(qint64 startPosition) { + Q_D(QtMultimediaPlayer); + qCDebug(player) << "Play from position " << startPosition; + d->m_mediaPlayer->play(); + d->m_resumePosition = startPosition; + if (startPosition > 0) { + d->m_seekToResumePosition = true; + } +} + +void QtMultimediaPlayer::stop() { + Q_D(QtMultimediaPlayer); + d->m_mediaPlayer->stop(); +} + +void QtMultimediaPlayer::seek(qint64 pos) { + Q_D(QtMultimediaPlayer); + d->m_mediaPlayer->setPosition(pos); +} + +void QtMultimediaPlayer::setMedia(const QUrl &url, int audioIndex, int subtitleIndex) { + Q_D(QtMultimediaPlayer); + qCDebug(player) << "Media set to " << url; + if (url.isEmpty()) { + d->m_mediaPlayer->setMedia(QMediaContent()); + } else { + d->m_mediaPlayer->setMedia(QMediaContent(url)); + } + d->m_audioIndex = audioIndex; + d->m_subtitleIndex = subtitleIndex; + if (d->m_mediaStreamsControl != nullptr) { + qCDebug(player) << "Total stream count: " << d->m_mediaStreamsControl->streamCount(); + if (audioIndex >= 0) { + d->m_mediaStreamsControl->setActive(audioIndex, true); + } + if (subtitleIndex >= 0) { + d->m_mediaStreamsControl->setActive(subtitleIndex, true); + } + } +} + +QObject *QtMultimediaPlayer::videoOutputSource() const { + const Q_D(QtMultimediaPlayer); + return d->m_mediaPlayer; +} + +#endif // USE_QTMULTIMEDIA_PLAYER + +} // NS Model +} // NS Jellfyin diff --git a/core/src/model/playlist.cpp b/core/src/model/playlist.cpp index 4768374..bca2c97 100644 --- a/core/src/model/playlist.cpp +++ b/core/src/model/playlist.cpp @@ -1,6 +1,6 @@ /* * Sailfin: a Jellyfin client written using Qt - * Copyright (C) 2021 Chris Josten and the Sailfin Contributors. + * Copyright (C) 2021-2022 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 @@ -132,13 +132,13 @@ QSharedPointer Playlist::nextItem() { return m_nextItem; } -void Playlist::appendToList(ViewModel::ItemModel &model) { +void Playlist::appendToList(const QList> &items) { int start = m_list.size(); - int count = model.size(); + int count = items.size(); m_list.reserve(count); emit beforeItemsAddedToList(start, count); for (int i = 0; i < count; i++) { - m_list.append(QSharedPointer(model.at(i))); + m_list.append(QSharedPointer(items.at(i))); } emit itemsAddedToList(); reshuffle(); diff --git a/core/src/platform/freedesktop/mediaplayer2player.cpp b/core/src/platform/freedesktop/mediaplayer2player.cpp index 6ad854c..090237d 100644 --- a/core/src/platform/freedesktop/mediaplayer2player.cpp +++ b/core/src/platform/freedesktop/mediaplayer2player.cpp @@ -165,12 +165,13 @@ QString PlayerAdaptor::playbackStatus() const if (m_mediaControl == nullptr || m_mediaControl->playbackManager() == nullptr) { return "Stopped"; } + using PlayerState = Jellyfin::Model::PlayerState; switch(m_mediaControl->playbackManager()->playbackState()) { - case QMediaPlayer::StoppedState: + case PlayerState::Stopped: return "Stopped"; - case QMediaPlayer::PlayingState: + case PlayerState::Playing: return "Playing"; - case QMediaPlayer::PausedState: + case PlayerState::Paused: return "Paused"; default: return "Stopped"; @@ -246,7 +247,8 @@ void PlayerAdaptor::Play() void PlayerAdaptor::PlayPause() { // handle method call org.mpris.MediaPlayer2.Player.PlayPause - if (m_mediaControl->playbackManager()->playbackState() == QMediaPlayer::PlayingState) { + using PlayerState = Jellyfin::Model::PlayerState; + if (m_mediaControl->playbackManager()->playbackState() == PlayerState::Playing) { m_mediaControl->playbackManager()->pause(); } else { m_mediaControl->playbackManager()->play(); @@ -290,14 +292,12 @@ void PlayerAdaptor::notifyPropertiesChanged(QStringList properties) { QDBusConnection::sessionBus().send(signal); } -void PlayerAdaptor::onCurrentItemChanged(ViewModel::Item *item) { - Q_UNUSED(item) - +void PlayerAdaptor::onCurrentItemChanged() { QStringList properties; properties << "Metadata" << "Position" << "CanPlay" << "CanPause" << "CanGoNext" << "CanGoPrevious"; notifyPropertiesChanged(properties); } -void PlayerAdaptor::onPlaybackStateChanged(QMediaPlayer::State state) { +void PlayerAdaptor::onPlaybackStateChanged(Jellyfin::Model::PlayerStateClass::Value state) { Q_UNUSED(state) QStringList properties; properties << "PlaybackStatus" << "Position"; @@ -305,7 +305,7 @@ void PlayerAdaptor::onPlaybackStateChanged(QMediaPlayer::State state) { } -void PlayerAdaptor::onMediaStatusChanged(QMediaPlayer::MediaStatus status) { +void PlayerAdaptor::onMediaStatusChanged(Jellyfin::Model::MediaStatusClass::Value status) { Q_UNUSED(status) QStringList properties; properties << "PlaybackStatus" << "Position"; diff --git a/core/src/viewmodel/playbackmanager.cpp b/core/src/viewmodel/playbackmanager.cpp index 41c3534..c296534 100644 --- a/core/src/viewmodel/playbackmanager.cpp +++ b/core/src/viewmodel/playbackmanager.cpp @@ -1,6 +1,6 @@ /* * Sailfin: a Jellyfin client written using Qt - * Copyright (C) 2021 Chris Josten and the Sailfin Contributors. + * Copyright (C) 2021-2022 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 @@ -20,13 +20,12 @@ #include "JellyfinQt/viewmodel/playbackmanager.h" #include "JellyfinQt/apimodel.h" -#include "JellyfinQt/loader/http/mediainfo.h" #include #include // #include "JellyfinQt/DTO/dto.h" -#include #include +#include #include #include @@ -41,348 +40,288 @@ namespace ViewModel { Q_LOGGING_CATEGORY(playbackManager, "jellyfin.viewmodel.playbackmanager") +class PlaybackManagerPrivate { + Q_DECLARE_PUBLIC(PlaybackManager); +public: + explicit PlaybackManagerPrivate(PlaybackManager *q); + PlaybackManager *q_ptr = nullptr; + + ApiClient *m_apiClient = nullptr; + Model::PlaybackManager *m_impl = nullptr; + + /// The currently played item that will be shown in the GUI + ViewModel::Item *m_displayItem = nullptr; + /// The currently played queue that will be shown in the GUI + ViewModel::Playlist *m_displayQueue = nullptr; + + bool m_handlePlaystateCommands; +}; + +PlaybackManagerPrivate::PlaybackManagerPrivate(PlaybackManager *q) + : q_ptr(q), + m_impl(new Model::LocalPlaybackManager(q)), + m_displayItem(new ViewModel::Item(q)), + m_displayQueue(new ViewModel::Playlist(m_impl->queue())) { +} + +// PlaybackManager + PlaybackManager::PlaybackManager(QObject *parent) - : QObject(parent), - m_item(nullptr), - m_mediaPlayer(new QMediaPlayer(this)), - m_queue(new Model::Playlist(this)) { + : QObject(parent) { + QScopedPointer foo(new PlaybackManagerPrivate(this)); + d_ptr.swap(foo); - m_displayQueue = new ViewModel::Playlist(m_queue, this); + Q_D(PlaybackManager); // Set up connections. - m_updateTimer.setInterval(10000); // 10 seconds - m_updateTimer.setSingleShot(false); + connect(d->m_impl, &Model::PlaybackManager::positionChanged, this, &PlaybackManager::positionChanged); + connect(d->m_impl, &Model::PlaybackManager::durationChanged, this, &PlaybackManager::durationChanged); + connect(d->m_impl, &Model::PlaybackManager::hasNextChanged, this, &PlaybackManager::hasNextChanged); + connect(d->m_impl, &Model::PlaybackManager::hasPreviousChanged, this, &PlaybackManager::hasPreviousChanged); + connect(d->m_impl, &Model::PlaybackManager::seekableChanged, this, &PlaybackManager::seekableChanged); + connect(d->m_impl, &Model::PlaybackManager::queueIndexChanged, this, &PlaybackManager::queueIndexChanged); + connect(d->m_impl, &Model::PlaybackManager::itemChanged, this, &PlaybackManager::mediaPlayerItemChanged); + connect(d->m_impl, &Model::PlaybackManager::playbackStateChanged, this, &PlaybackManager::playbackStateChanged); + if (auto localImp = qobject_cast(d->m_impl)) { + connect(localImp, &Model::LocalPlaybackManager::streamUrlChanged, this, [this](const QUrl& newUrl){ + this->streamUrlChanged(newUrl.toString()); + }); + connect(localImp, &Model::LocalPlaybackManager::playMethodChanged, this, &PlaybackManager::playMethodChanged); + } + connect(d->m_impl, &Model::PlaybackManager::mediaStatusChanged, this, &PlaybackManager::mediaStatusChanged); +} - m_preloadTimer.setSingleShot(true); - - connect(&m_updateTimer, &QTimer::timeout, this, &PlaybackManager::updatePlaybackInfo); - - connect(m_mediaPlayer, &QMediaPlayer::stateChanged, this, &PlaybackManager::mediaPlayerStateChanged); - connect(m_mediaPlayer, &QMediaPlayer::positionChanged, this, &PlaybackManager::mediaPlayerPositionChanged); - connect(m_mediaPlayer, &QMediaPlayer::durationChanged, this, &PlaybackManager::mediaPlayerDurationChanged); - connect(m_mediaPlayer, &QMediaPlayer::mediaStatusChanged, this, &PlaybackManager::mediaPlayerMediaStatusChanged); - connect(m_mediaPlayer, &QMediaPlayer::videoAvailableChanged, this, &PlaybackManager::hasVideoChanged); - connect(m_mediaPlayer, &QMediaPlayer::seekableChanged, this, &PlaybackManager::mediaPlayerSeekableChanged); - // I do not like the complicated overload cast - connect(m_mediaPlayer, SIGNAL(error(QMediaPlayer::Error)), this, SLOT(mediaPlayerError(QMediaPlayer::Error))); - - m_forceSeekTimer.setInterval(500); - m_forceSeekTimer.setSingleShot(false); - - // Yes, this is a very ugly way of forcing the video player to seek to the resume position - connect(&m_forceSeekTimer, &QTimer::timeout, this, [this]() { - if (m_seekToResumedPosition && m_mediaPlayer->isSeekable()) { - qCDebug(playbackManager) << "Trying to seek to the resume position" << (m_resumePosition / MS_TICK_FACTOR); - if (m_mediaPlayer->position() > m_resumePosition / MS_TICK_FACTOR - 500) { - m_seekToResumedPosition = false; - m_forceSeekTimer.stop(); - } else { - m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); - } - } - - }); +PlaybackManager::~PlaybackManager() { } void PlaybackManager::setApiClient(ApiClient *apiClient) { - if (m_apiClient != nullptr) { - disconnect(m_apiClient->eventbus(), &EventBus::playstateCommandReceived, this, &PlaybackManager::handlePlaystateRequest); + Q_D(PlaybackManager); + if (d->m_apiClient != nullptr) { + disconnect(d->m_apiClient->eventbus(), &EventBus::playstateCommandReceived, this, &PlaybackManager::handlePlaystateRequest); } - if (!m_item.isNull()) { - m_item->setApiClient(apiClient); + if (!d->m_displayItem->data().isNull()) { + d->m_displayItem->data()->setApiClient(apiClient); } - m_apiClient = apiClient; + d->m_apiClient = apiClient; + d->m_impl->setApiClient(apiClient); - if (m_apiClient != nullptr) { - connect(m_apiClient->eventbus(), &EventBus::playstateCommandReceived, this, &PlaybackManager::handlePlaystateRequest); + if (d->m_apiClient != nullptr) { + connect(d->m_apiClient->eventbus(), &EventBus::playstateCommandReceived, this, &PlaybackManager::handlePlaystateRequest); } } -void PlaybackManager::setItem(QSharedPointer newItem) { - if (m_mediaPlayer != nullptr) m_mediaPlayer->stop(); - bool shouldFetchStreamUrl = !newItem.isNull() - && ((m_streamUrl.isEmpty() || (!m_item.isNull() - && m_item->jellyfinId() != newItem->jellyfinId())) - || (m_nextStreamUrl.isEmpty() || (!m_nextItem.isNull() - && m_nextItem->jellyfinId() != newItem->jellyfinId()))); +bool PlaybackManager::resumePlayback() const { + const Q_D(PlaybackManager); + return d->m_impl->resumePlayback(); +} - this->m_item = newItem; +void PlaybackManager::setResumePlayback(bool newResumePlayback) { + Q_D(PlaybackManager); + return d->m_impl->setResumePlayback(newResumePlayback); +} - if (newItem.isNull()) { - m_displayItem->setData(QSharedPointer::create()); +int PlaybackManager::audioIndex() const { + const Q_D(PlaybackManager); + return d->m_impl->audioIndex(); +} + +void PlaybackManager::setAudioIndex(int newAudioIndex){ + Q_D(PlaybackManager); + d->m_impl->setAudioIndex(newAudioIndex); +} + +int PlaybackManager::subtitleIndex() const { + const Q_D(PlaybackManager); + return d->m_impl->subtitleIndex(); +} + +void PlaybackManager::setSubtitleIndex(int newSubtitleIndex){ + Q_D(PlaybackManager); + d->m_impl->setSubtitleIndex(newSubtitleIndex); +} + +ViewModel::Item *PlaybackManager::item() const { + const Q_D(PlaybackManager); + return d->m_displayItem; +} +QSharedPointer PlaybackManager::dataItem() const { + const Q_D(PlaybackManager); + return d->m_displayItem->data(); +} + +ApiClient * PlaybackManager::apiClient() const { + const Q_D(PlaybackManager); + return d->m_apiClient; +} + +QString PlaybackManager::streamUrl() const { + const Q_D(PlaybackManager); + if (Model::LocalPlaybackManager *lpm = qobject_cast(d->m_impl)) { + return lpm->streamUrl().toString(); } else { - m_displayItem->setData(newItem); - if (!newItem->userData().isNull()) { - this->m_resumePosition = newItem->userData()->playbackPositionTicks(); - } + return QStringLiteral(""); } - emit itemChanged(m_displayItem); +} - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); - - this->m_seekToResumedPosition = m_resumePlayback; - - if (m_apiClient == nullptr) { - - qCWarning(playbackManager) << "apiClient is not set on this MediaSource instance! Aborting."; - return; - } - // Deinitialize the streamUrl - if (shouldFetchStreamUrl) { - setStreamUrl(QUrl()); - requestItemUrl(m_item); +PlayMethod PlaybackManager::playMethod() const { + const Q_D(PlaybackManager); + if (Model::LocalPlaybackManager *lpm = qobject_cast(d->m_impl)) { + return lpm->playMethod(); } else { - setStreamUrl(m_nextStreamUrl); - m_mediaPlayer->play(); + return PlayMethod::EnumNotSet; } } +Model::MediaStatus PlaybackManager::mediaStatus() const { + const Q_D(PlaybackManager); + return d->m_impl->mediaStatus(); +} + +qint64 PlaybackManager::position() const { + const Q_D(PlaybackManager); + return d->m_impl->position(); +} + +qint64 PlaybackManager::duration() const { + const Q_D(PlaybackManager); + return d->m_impl->duration(); +} + +ViewModel::Playlist *PlaybackManager::queue() const { + const Q_D(PlaybackManager); + return d->m_displayQueue; +} + +int PlaybackManager::queueIndex() const { + const Q_D(PlaybackManager); + return d->m_impl->queueIndex(); +} + +bool PlaybackManager::hasNext() const { + const Q_D(PlaybackManager); + return d->m_impl->hasNext(); +} + +bool PlaybackManager::hasPrevious() const { + const Q_D(PlaybackManager); + return d->m_impl->hasPrevious(); +} + +QObject* PlaybackManager::mediaObject() const { + const Q_D(PlaybackManager); + if (auto localPb = qobject_cast(d->m_impl)) { + return localPb->player()->videoOutputSource(); + } else { + return nullptr; + } +} + +Model::PlayerState PlaybackManager::playbackState() const { + const Q_D(PlaybackManager); + return d->m_impl->playbackState(); +} + +bool PlaybackManager::hasVideo() const { + const Q_D(PlaybackManager); + return d->m_impl->hasVideo(); +} + +bool PlaybackManager::seekable() const { + const Q_D(PlaybackManager); + return d->m_impl->seekable(); +} + +bool PlaybackManager::handlePlaystateCommands() const { + const Q_D(PlaybackManager); + return d->m_handlePlaystateCommands; +} + +void PlaybackManager::setHandlePlaystateCommands(bool newHandlePlaystateCommands) { + Q_D(PlaybackManager); + d->m_handlePlaystateCommands = newHandlePlaystateCommands; + emit handlePlaystateCommandsChanged(newHandlePlaystateCommands); +} + QMediaPlayer::Error PlaybackManager::error() const { - if (m_error != QMediaPlayer::NoError) { - return m_error; - } else { - return m_mediaPlayer->error(); - } + return QMediaPlayer::NoError; } QString PlaybackManager::errorString() const { - if (!m_errorString.isEmpty()) { - return m_errorString; - } else { - return m_mediaPlayer->errorString(); - } + const Q_D(PlaybackManager); + return d->m_impl->errorString(); } - -void PlaybackManager::setStreamUrl(const QUrl &streamUrl) { - m_streamUrl = streamUrl.toString(); - // Inspired by PHP naming schemes - Q_ASSERT_X(streamUrl.isValid() || streamUrl.isEmpty(), "setStreamUrl", "StreamURL Jellyfin returned is not valid"); - emit streamUrlChanged(m_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 - // want the old position. - m_stopPosition = m_oldPosition; - } - m_oldPosition = position; -} - -void PlaybackManager::mediaPlayerStateChanged(QMediaPlayer::State newState) { - if (m_oldState == newState) return; - - if (newState == QMediaPlayer::PlayingState) { - if (m_seekToResumedPosition) { - if (m_mediaPlayer->isSeekable()) { - qCDebug(playbackManager) << "Resuming playback by seeking to " << (m_resumePosition / MS_TICK_FACTOR); - m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); - m_seekToResumedPosition = false; - } - } - } - - if (m_oldState == QMediaPlayer::StoppedState) { - // We're transitioning from stopped to either playing or paused. - // Set up the recurring timer - m_updateTimer.start(); - postPlaybackInfo(Started); - } else if (newState == QMediaPlayer::StoppedState && m_playbackState == QMediaPlayer::StoppedState) { - // We've stopped playing the media. Post a stop signal. - m_updateTimer.stop(); - postPlaybackInfo(Stopped); - } else { - postPlaybackInfo(Progress); - } - m_oldState = newState; - emit playbackStateChanged(newState); -} - -void PlaybackManager::mediaPlayerMediaStatusChanged(QMediaPlayer::MediaStatus newStatus) { - emit mediaStatusChanged(newStatus); - if (m_playbackState == QMediaPlayer::StoppedState) return; - if (newStatus == QMediaPlayer::LoadedMedia) { - m_mediaPlayer->play(); - } else if (newStatus == QMediaPlayer::EndOfMedia) { - if (m_queue->hasNext() && m_queue->totalSize() > 1) { - next(); - } else { - // End of the playlist - setPlaybackState(QMediaPlayer::StoppedState); - } - } -} - -void PlaybackManager::mediaPlayerDurationChanged(qint64 newDuration) { - emit durationChanged(newDuration); - if (newDuration > 0 && !m_nextItem.isNull()) { - m_preloadTimer.stop(); - m_preloadTimer.start(std::max(static_cast(newDuration - PRELOAD_DURATION), 0)); - } -} - -void PlaybackManager::mediaPlayerSeekableChanged(bool newSeekable) { - emit seekableChanged(newSeekable); - if (m_seekToResumedPosition) { - m_forceSeekTimer.start(); - } - /*if (m_seekToResumedPosition && newSeekable) { - qCDebug(playbackManager) << "Trying to seek to the resume position"; - - m_mediaPlayer->setPosition(m_resumePosition / MS_TICK_FACTOR); - if (m_mediaPlayer->position() > 1000) { - m_seekToResumedPosition = false; - } - }*/ -} - -void PlaybackManager::mediaPlayerError(QMediaPlayer::Error error) { - emit errorChanged(error); - emit errorStringChanged(m_mediaPlayer->errorString()); -} - -void PlaybackManager::updatePlaybackInfo() { - postPlaybackInfo(Progress); +void PlaybackManager::mediaPlayerItemChanged() { + Q_D(PlaybackManager); + d->m_displayItem->setData(d->m_impl->currentItem()); + emit itemChanged(); } void PlaybackManager::playItem(Item *item) { - this->playItem(item->data()); + playItem(item->data()); } void PlaybackManager::playItem(QSharedPointer item) { - m_queue->clearList(); - m_queue->appendToList(item); - setItem(item); - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); - setPlaybackState(QMediaPlayer::PlayingState); + Q_D(PlaybackManager); + d->m_impl->playItem(item); } void PlaybackManager::playItemId(const QString &id) { - Jellyfin::Loader::HTTP::GetItemLoader *loader = new Jellyfin::Loader::HTTP::GetItemLoader(m_apiClient); - connect(loader, &Support::LoaderBase::error, this, [loader]() { - // TODO: error handling - loader->deleteLater(); - }); - connect(loader, &Support::LoaderBase::ready, this, [this, loader](){ - this->playItem(QSharedPointer::create(loader->result())); - loader->deleteLater(); - }); - Jellyfin::Loader::GetItemParams params; - params.setUserId(m_apiClient->userId()); - params.setItemId(id); - loader->setParameters(params); - loader->load(); + Q_D(PlaybackManager); + d->m_impl->playItemId(id); } void PlaybackManager::playItemInList(ItemModel *playlist, int index) { - m_queue->clearList(); - m_queue->appendToList(*playlist); - m_queue->play(index); - m_queueIndex = index; - emit queueIndexChanged(m_queueIndex); - setItem(playlist->itemAt(index)); - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); - setPlaybackState(QMediaPlayer::PlayingState); + Q_D(PlaybackManager); + d->m_impl->playItemInList(playlist->toList(), index); } void PlaybackManager::skipToItemIndex(int index) { - if (index < m_queue->queueSize()) { - // Skip until we hit the right number in the queue - index++; - while(index != 0) { - m_queue->next(); - index--; - } - } else { - m_queue->play(index); - } - setItem(m_queue->currentItem()); - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); + Q_D(PlaybackManager); + d->m_impl->goTo(index); } void PlaybackManager::play() { - m_mediaPlayer->play(); - if (m_queue->totalSize() != 0) { - setPlaybackState(QMediaPlayer::PlayingState); - } + Q_D(PlaybackManager); + d->m_impl->play(); } -void PlaybackManager::next() { - m_mediaPlayer->stop(); - m_mediaPlayer->setMedia(QMediaContent()); - - if (m_nextItem.isNull() || !m_queue->nextItem()->sameAs(*m_nextItem)) { - setItem(m_queue->nextItem()); - m_nextStreamUrl = QString(); - m_queue->next(); - m_nextItem.clear(); - } else { - m_item = m_nextItem; - m_streamUrl = m_nextStreamUrl; - - m_nextItem.clear(); - m_nextStreamUrl = QString(); - - m_queue->next(); - setItem(m_nextItem); - } - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); -} - -void PlaybackManager::previous() { - m_mediaPlayer->stop(); - m_mediaPlayer->setPosition(0); - - m_item.clear(); - m_streamUrl = QString(); - - m_nextStreamUrl = m_streamUrl; - m_nextItem = m_queue->nextItem(); - - m_queue->previous(); - setItem(m_queue->currentItem()); - - emit hasNextChanged(m_queue->hasNext()); - emit hasPreviousChanged(m_queue->hasPrevious()); -} - -void PlaybackManager::stop() { - setPlaybackState(QMediaPlayer::StoppedState); - m_queue->clearList(); - m_mediaPlayer->stop(); +void PlaybackManager::pause() { + Q_D(PlaybackManager); + return d->m_impl->pause(); } void PlaybackManager::seek(qint64 pos) { - m_mediaPlayer->setPosition(pos); - postPlaybackInfo(Progress); + Q_D(PlaybackManager); + d->m_impl->seek(pos); emit seeked(pos); } +void PlaybackManager::stop() { + Q_D(PlaybackManager); + d->m_impl->stop(); +} + +void PlaybackManager::next() { + Q_D(PlaybackManager); + d->m_impl->next(); +} + +void PlaybackManager::previous() { + Q_D(PlaybackManager); + d->m_impl->previous(); +} + void PlaybackManager::handlePlaystateRequest(const DTO::PlaystateRequest &request) { - if (!m_handlePlaystateCommands) return; + //if (!m_handlePlaystateCommands) return; switch(request.command()) { case DTO::PlaystateCommand::Pause: pause(); break; case DTO::PlaystateCommand::PlayPause: - if (playbackState() != QMediaPlayer::PlayingState) { + if (playbackState() != Model::PlayerState::Playing) { play(); } else { pause(); @@ -413,229 +352,11 @@ void PlaybackManager::handlePlaystateRequest(const DTO::PlaystateRequest &reques } } -void PlaybackManager::postPlaybackInfo(PlaybackInfoType type) { - if (m_item == nullptr) { - qCWarning(playbackManager) << "Item is null. Not posting playback info"; - return; - } - DTO::PlaybackProgressInfo progress( - seekable(), - m_item, - m_item->jellyfinId(), - playbackState() == QMediaPlayer::PausedState, - false, // is muted? - m_playMethod, - DTO::RepeatMode::RepeatNone); - - progress.setSessionId(m_playSessionId); - - switch(type) { - case Started: // FALLTHROUGH - case Progress: { - progress.setAudioStreamIndex(m_audioIndex); - progress.setSubtitleStreamIndex(m_subtitleIndex); - progress.setPositionTicks(m_mediaPlayer->position() * MS_TICK_FACTOR); - - QList queue; - for (int i = 0; i < m_queue->listSize(); i++) { - DTO::QueueItem queueItem(m_queue->listAt(i)->jellyfinId()); - queue.append(queueItem); - } - progress.setNowPlayingQueue(queue); - break; - } - case Stopped: - progress.setPositionTicks(m_stopPosition * MS_TICK_FACTOR); - break; - } - - QString path; - switch (type) { - case Started: - path = "/Sessions/Playing"; - break; - case Progress: - path = "/Sessions/Playing/Progress"; - break; - case Stopped: - path = "/Sessions/Playing/Stopped"; - break; - } - - QNetworkReply *rep = m_apiClient->post(path, QJsonDocument(progress.toJson())); - connect(rep, &QNetworkReply::finished, this, [rep](){ - rep->deleteLater(); - }); - m_apiClient->setDefaultErrorHandler(rep); -} - void PlaybackManager::componentComplete() { - if (m_apiClient == nullptr) qCWarning(playbackManager) << "No ApiClient set for PlaybackManager"; + Q_D(PlaybackManager); + if (d->m_apiClient == nullptr) qCWarning(playbackManager) << "No ApiClient set for PlaybackManager"; m_qmlIsParsingComponent = false; } -void PlaybackManager::requestItemUrl(QSharedPointer item) { - ItemUrlLoader *loader = new Jellyfin::Loader::HTTP::GetPostedPlaybackInfoLoader(m_apiClient); - Jellyfin::Loader::GetPostedPlaybackInfoParams params; - - - // Check if we'd prefer to transcode if the video file contains multiple audio tracks - // or if a subtitle track was selected. - // This has to be done due to the lack of support of selecting audio tracks within QtMultimedia - bool allowTranscoding = m_apiClient->settings()->allowTranscoding(); - bool transcodePreferred = m_subtitleIndex > 0; - int audioTracks = 0; - const QList &streams = item->mediaStreams(); - for(int i = 0; i < streams.size(); i++) { - const DTO::MediaStream &stream = streams[i]; - if (stream.type() == MediaStreamType::Audio) { - audioTracks++; - } - } - if (audioTracks > 1) { - transcodePreferred = true; - } - - bool forceTranscoding = allowTranscoding && transcodePreferred; - - QSharedPointer playbackInfo = QSharedPointer::create(m_apiClient->deviceProfile()); - params.setItemId(item->jellyfinId()); - params.setUserId(m_apiClient->userId()); - playbackInfo->setEnableDirectPlay(true); - playbackInfo->setEnableDirectStream(!forceTranscoding); - playbackInfo->setEnableTranscoding(forceTranscoding || allowTranscoding); - playbackInfo->setAudioStreamIndex(this->m_audioIndex); - playbackInfo->setSubtitleStreamIndex(this->m_subtitleIndex); - params.setBody(playbackInfo); - - loader->setParameters(params); - connect(loader, &ItemUrlLoader::ready, this, [this, loader, item] { - DTO::PlaybackInfoResponse result = loader->result(); - handlePlaybackInfoResponse(item->jellyfinId(), item->mediaType(), result); - loader->deleteLater(); - }); - connect(loader, &ItemUrlLoader::error, this, [this, loader, item](QString message) { - onItemErrorReceived(item->jellyfinId(), message); - loader->deleteLater(); - }); - loader->load(); -} - -void PlaybackManager::handlePlaybackInfoResponse(QString itemId, QString mediaType, DTO::PlaybackInfoResponse &response) { - //TODO: move the item URL fetching logic out of this function, into MediaSourceInfo? - QList mediaSources = response.mediaSources(); - QUrl resultingUrl; - QString playSession = response.playSessionId(); - PlayMethod playMethod = PlayMethod::EnumNotSet; - bool transcodingAllowed = m_apiClient->settings()->allowTranscoding(); - - - - for (int i = 0; i < mediaSources.size(); i++) { - const DTO::MediaSourceInfo &source = mediaSources.at(i); - - // Check if we'd prefer to transcode if the video file contains multiple audio tracks - // or if a subtitle track was selected. - // This has to be done due to the lack of support of selecting audio tracks within QtMultimedia - bool transcodePreferred = false; - if (transcodingAllowed) { - transcodePreferred = m_subtitleIndex > 0; - int audioTracks = 0; - const QList &streams = source.mediaStreams(); - for (int i = 0; i < streams.size(); i++) { - DTO::MediaStream stream = streams[i]; - if (stream.type() == MediaStreamType::Audio) { - audioTracks++; - } - } - if (audioTracks > 1) { - transcodePreferred = true; - } - } - - - qCDebug(playbackManager()) << "Media source: " << source.name() << "\n" - << "Prefer transcoding: " << transcodePreferred << "\n" - << "DirectPlay supported: " << source.supportsDirectPlay() << "\n" - << "DirectStream supported: " << source.supportsDirectStream() << "\n" - << "Transcode supported: " << source.supportsTranscoding(); - - if (source.supportsDirectPlay() && QFile::exists(source.path())) { - resultingUrl = QUrl::fromLocalFile(source.path()); - playMethod = PlayMethod::DirectPlay; - } else if (source.supportsDirectStream() && !transcodePreferred) { - if (mediaType == "Video") { - mediaType.append('s'); - } - QUrlQuery query; - query.addQueryItem("mediaSourceId", source.jellyfinId()); - query.addQueryItem("deviceId", m_apiClient->deviceId()); - query.addQueryItem("api_key", m_apiClient->token()); - query.addQueryItem("Static", "True"); - resultingUrl = QUrl(m_apiClient->baseUrl() + "/" + mediaType + "/" + itemId - + "/stream." + source.container() + "?" + query.toString(QUrl::EncodeReserved)); - playMethod = PlayMethod::DirectStream; - } else if (source.supportsTranscoding() && !source.transcodingUrlNull() && transcodingAllowed) { - qCDebug(playbackManager) << "Transcoding url: " << source.transcodingUrl(); - resultingUrl = QUrl(m_apiClient->baseUrl() + source.transcodingUrl()); - playMethod = PlayMethod::Transcode; - } else { - qCDebug(playbackManager) << "No suitable sources for item " << itemId; - } - if (!resultingUrl.isEmpty()) break; - } - if (resultingUrl.isEmpty()) { - qCWarning(playbackManager) << "Could not find suitable media source for item " << itemId; - onItemErrorReceived(itemId, tr("Could not find a suitable media source.")); - } else { - emit playMethodChanged(playMethod); - onItemUrlReceived(itemId, resultingUrl, playSession, playMethod); - } -} - -void PlaybackManager::onItemUrlReceived(const QString &itemId, const QUrl &url, - const QString &playSession, PlayMethod playMethod) { - Q_UNUSED(url) - Q_UNUSED(playSession) - if (!m_item.isNull() && m_item->jellyfinId() == itemId) { - // We want to play the item probably right now - m_playSessionId = playSession; - m_playMethod = playMethod; - setStreamUrl(url); - emit playMethodChanged(m_playMethod); - - // Clear the error string if it is currently set - if (!m_errorString.isEmpty()) { - m_errorString.clear(); - emit errorStringChanged(m_errorString); - } - - if (m_error != QMediaPlayer::NoError) { - m_error = QMediaPlayer::NoError; - emit errorChanged(error()); - } - - m_mediaPlayer->setMedia(QMediaContent(url)); - m_mediaPlayer->play(); - } else { - qDebug() << "Late reply for " << itemId << " received, ignoring"; - } -} - - -/// Called when the fetcherThread encountered an error -void PlaybackManager::onItemErrorReceived(const QString &itemId, const QString &errorString) { - Q_UNUSED(itemId) - Q_UNUSED(errorString) - qWarning() << "Error while fetching streaming url for " << itemId << ": " << errorString; - if (!m_item.isNull() && m_item->jellyfinId() == itemId) { - setStreamUrl(QUrl()); - m_error = QMediaPlayer::ResourceError; - emit errorChanged(error()); - m_errorString = errorString; - emit errorStringChanged(errorString); - } -} - } // NS ViewModel } // NS Jellyfin diff --git a/qtquick/qml/main.qml b/qtquick/qml/main.qml index a332c07..cec66b3 100644 --- a/qtquick/qml/main.qml +++ b/qtquick/qml/main.qml @@ -110,7 +110,7 @@ ApplicationWindow { enabled: playbackManager.hasPrevious } Button { - readonly property bool _playing: playbackManager.playbackState === MediaPlayer.PlayingState; + readonly property bool _playing: playbackManager.playbackState === PlayerState.Playing anchors.verticalCenter: parent.verticalCenter text: _playing ? "Pause" : "Play" onClicked: _playing ? playbackManager.pause() : playbackManager.play() diff --git a/sailfish/qml/components/PlaybackBar.qml b/sailfish/qml/components/PlaybackBar.qml index 52ab7e5..86bcaec 100644 --- a/sailfish/qml/components/PlaybackBar.qml +++ b/sailfish/qml/components/PlaybackBar.qml @@ -48,7 +48,7 @@ PanelBackground { property bool showQueue: false property bool _pageWasShowingNavigationIndicator - readonly property bool mediaLoading: [MediaPlayer.Loading, MediaPlayer.Buffering].indexOf(manager.mediaStatus) >= 0 + readonly property bool mediaLoading: [J.MediaStatus.Loading, J.MediaStatus.Buffering].indexOf(manager.mediaStatus) >= 0 transform: Translate {id: playbackBarTranslate; y: 0} @@ -134,14 +134,24 @@ PanelBackground { Label { id: artists text: { - //return manager.item.mediaType; if (manager.item === null) return qsTr("Play some media!") switch(manager.item.mediaType) { case "Audio": - return manager.item.artists.join(", ") + var links = []; + var items = manager.item.artistItems; + console.log(items) + for (var i = 0; i < items.length; i++) { + links.push("%2" + .arg(items[i].jellyfinId) + .arg(items[i].name) + .arg(Theme.secondaryColor) + ) + } + return links.join(", ") } return qsTr("No audio") } + width: Math.min(contentWidth, parent.width) font.pixelSize: Theme.fontSizeSmall maximumLineCount: 1 @@ -151,7 +161,7 @@ PanelBackground { onLinkActivated: { appWindow.navigateToItem(link, "Audio", "MusicArtist", true) } - textFormat: Text.RichText + textFormat: Text.StyledText } } @@ -257,7 +267,7 @@ PanelBackground { states: [ State { name: "" - when: manager.playbackState !== MediaPlayer.StoppedState && !isFullPage && !("__hidePlaybackBar" in pageStack.currentPage) + when: manager.playbackState !== J.PlayerState.Stopped && !isFullPage && !("__hidePlaybackBar" in pageStack.currentPage) }, State { name: "large" @@ -354,20 +364,6 @@ PanelBackground { PropertyChanges { target: artists font.pixelSize: Theme.fontSizeMedium - text: { - var links = []; - var items = manager.item.artistItems; - console.log(items) - for (var i = 0; i < items.length; i++) { - links.push("%2" - .arg(items[i].jellyfinId) - .arg(items[i].name) - .arg(Theme.secondaryColor) - ) - } - - return links.join(", ") - } } AnchorChanges { @@ -390,7 +386,7 @@ PanelBackground { }, State { name: "hidden" - when: ((manager.playbackState === MediaPlayer.StoppedState && !mediaLoading) || "__hidePlaybackBar" in pageStack.currentPage) && !isFullPage + when: ((manager.playbackState === J.PlayerState.Stopped && !mediaLoading) || "__hidePlaybackBar" in pageStack.currentPage) && !isFullPage PropertyChanges { target: playbackBarTranslate // + small padding since the ProgressBar otherwise would stick out diff --git a/sailfish/qml/components/VideoPlayer.qml b/sailfish/qml/components/VideoPlayer.qml index cd09d1d..602a2a0 100644 --- a/sailfish/qml/components/VideoPlayer.qml +++ b/sailfish/qml/components/VideoPlayer.qml @@ -42,6 +42,7 @@ SilicaItem { property int subtitleTrack: 0 //FIXME: Once QTBUG-10822 is resolved, change to J.PlaybackManager property var manager; + onManagerChanged: console.log(manager.player) // Blackground to prevent the ambience from leaking through Rectangle { @@ -53,6 +54,9 @@ SilicaItem { id: videoOutput source: manager anchors.fill: parent + Component.onCompleted: { + console.log(manager.player) + } } VideoHud { @@ -71,14 +75,16 @@ SilicaItem { Label { readonly property string _playbackMethod: { switch(manager.playMethod) { - case J.PlaybackManager.DirectPlay: - return"Direct Play" - case J.PlaybackManager.Transcoding: + case J.PlayMethod.EnumNotSet: + return "Enum not set" + case J.PlayMethod.DirectPlay: + return "Direct Play" + case J.PlayMethod.Transcode: return "Transcoding" - case J.PlaybackManager.DirectStream: + case J.PlayMethod.DirectStream: return "Direct Stream" default: - return "Unknown playback method" + return "Unknown playback method '%1'".arg(manager.playMethod) } } anchors.fill: parent @@ -86,6 +92,7 @@ SilicaItem { text: item.jellyfinId + "\n" + appWindow.playbackManager.streamUrl + "\n" + "Playback method: " + _playbackMethod + "\n" + "Media status: " + manager.mediaStatus + "\n" + + "Playback state: " + manager.playbackState + "\n" // + player.bufferProgress + "\n" // + player.metaData.videoCodec + "@" + player.metaData.videoFrameRate + "(" + player.metaData.videoBitRate + ")" + "\n" // + player.metaData.audioCodec + "(" + player.metaData.audioBitRate + ")" + "\n" diff --git a/sailfish/qml/components/videoplayer/VideoHud.qml b/sailfish/qml/components/videoplayer/VideoHud.qml index 17f35d9..8f6c2d5 100644 --- a/sailfish/qml/components/videoplayer/VideoHud.qml +++ b/sailfish/qml/components/videoplayer/VideoHud.qml @@ -79,16 +79,17 @@ Item { id: busyIndicator anchors.centerIn: parent size: BusyIndicatorSize.Medium - running: [MediaPlayer.Loading, MediaPlayer.Stalled].indexOf(manager.mediaStatus) >= 0 + running: [J.MediaStatus.Loading, J.MediaStatus.Stalled].indexOf(manager.mediaStatus) >= 0 } IconButton { id: playPause enabled: !hidden anchors.centerIn: parent - icon.source: manager.playbackState === MediaPlayer.PausedState ? "image://theme/icon-l-play" : "image://theme/icon-l-pause" + icon.source: manager.playbackState === J.PlayerState.Paused ? "image://theme/icon-l-play" : "image://theme/icon-l-pause" onClicked: { - if (manager.playbackState === MediaPlayer.PlayingState) { + console.log(manager.playbackState) + if (manager.playbackState === J.PlayerState.Playing) { manager.pause() } else { manager.play() @@ -102,7 +103,7 @@ Item { anchors.bottom: parent.bottom width: parent.width height: progress.height - visible: [MediaPlayer.Unavailable, MediaPlayer.Loading, MediaPlayer.NoMedia].indexOf(manager.mediaStatus) == -1 + visible: [J.MediaStatus.Unavailable, J.MediaStatus.Loading, J.MediaStatus.NoMedia].indexOf(manager.mediaStatus) == -1 gradient: Gradient { GradientStop { position: 0.0; color: Theme.rgba(palette.overlayBackgroundColor, 0.15); } @@ -151,11 +152,11 @@ Item { onMediaStatusChanged: { console.log("New mediaPlayer status: " + manager.mediaStatus) switch(manager.mediaStatus) { - case MediaPlayer.Loaded: - case MediaPlayer.Buffering: + case J.MediaStatus.Loaded: + case J.MediaStatus.Buffering: show(false) break; - case MediaPlayer.Buffered: + case J.MediaStatus.Buffered: hide(false) break; } diff --git a/sailfish/qml/harbour-sailfin.qml b/sailfish/qml/harbour-sailfin.qml index 0f1fbda..deeebd7 100644 --- a/sailfish/qml/harbour-sailfin.qml +++ b/sailfish/qml/harbour-sailfin.qml @@ -1,6 +1,6 @@ /* Sailfin: a Jellyfin client written using Qt -Copyright (C) 2020 Chris Josten +Copyright (C) 2020-2022 Chris Josten This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public @@ -114,7 +114,6 @@ ApplicationWindow { id: _playbackManager apiClient: appWindow.apiClient audioIndex: 0 - autoOpen: true } Connections {