From 562c76c799d3ec10d244acd799289b5ebc835aab Mon Sep 17 00:00:00 2001 From: Bart De Vries Date: Thu, 22 Sep 2022 16:59:30 +0200 Subject: [PATCH] Implement streaming support This implements support for streaming episodes rather than downloading them first. This introduces a new setting: prioritizeStreaming. If it's set to false (default) then a streaming play button is only added to the EntryPage. If it is set to true, then the streaming play button will also appear on the Entry delegates instead of the download button. There is a separate setting to decide if streaming is also allowed on metered connections. FEATURE: 438864 --- .reuse/dep5 | 4 ++ icons/media-playback-start-cloud.svg | 10 +++ src/audiomanager.cpp | 59 +++++++++++++++-- src/audiomanager.h | 7 +- src/error.cpp | 8 ++- src/error.h | 3 +- src/qml/EntryPage.qml | 84 ++++++++++++++++-------- src/qml/GenericEntryDelegate.qml | 36 ++++++++-- src/qml/Settings/GeneralSettingsPage.qml | 25 +++++-- src/qml/Settings/NetworkSettingsPage.qml | 7 ++ src/resources.qrc | 1 + src/settingsmanager.kcfg | 8 +++ src/updatefeedjob.cpp | 6 +- 13 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 icons/media-playback-start-cloud.svg diff --git a/.reuse/dep5 b/.reuse/dep5 index 4624818a..0137acc4 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -18,6 +18,10 @@ Files: kasts.svg kasts-android-square.svg logo.png android/ic_launcher-playstore Copyright: 2021 Mathis BrĂ¼chert License: CC-BY-SA-4.0 +Files: icons/media-playback-start-cloud.svg +Copyright: 2022 Bart De Vries +License: CC-BY-SA-4.0 + Files: android/res/mipmap-anydpi-v26/ic_launcher.xml Copyright: None License: CC0-1.0 diff --git a/icons/media-playback-start-cloud.svg b/icons/media-playback-start-cloud.svg new file mode 100644 index 00000000..43bf19d4 --- /dev/null +++ b/icons/media-playback-start-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/audiomanager.cpp b/src/audiomanager.cpp index abc065c0..b13b645b 100644 --- a/src/audiomanager.cpp +++ b/src/audiomanager.cpp @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: LGPL-3.0-or-later */ @@ -22,11 +22,15 @@ #include "powermanagementinterface.h" #include "settingsmanager.h" +#include + class AudioManagerPrivate { private: PowerManagementInterface mPowerInterface; + SolidExtras::NetworkStatus m_networkStatus; + QMediaPlayer m_player; Entry *m_entry = nullptr; @@ -48,6 +52,8 @@ private: qint64 m_sleepTime = -1; qint64 m_remainingSleepTime = -1; + bool m_isStreaming = false; + friend class AudioManager; }; @@ -189,6 +195,11 @@ qreal AudioManager::maximumPlaybackRate() const return MAX_RATE; } +bool AudioManager::isStreaming() const +{ + return d->m_isStreaming; +} + QMediaPlayer::MediaStatus AudioManager::status() const { return d->m_player.mediaStatus(); @@ -221,20 +232,37 @@ void AudioManager::setEntry(Entry *entry) } // do some checks on the new entry to see whether it's valid and not corrupted - if (entry != nullptr && entry->hasEnclosure() && entry->enclosure() && entry->enclosure()->status() == Enclosure::Downloaded) { + if (entry != nullptr && entry->hasEnclosure() && entry->enclosure() + && (entry->enclosure()->status() == Enclosure::Downloaded + || (d->m_networkStatus.connectivity() != SolidExtras::NetworkStatus::No + && (d->m_networkStatus.metered() != SolidExtras::NetworkStatus::Yes || SettingsManager::self()->allowMeteredStreaming())))) { qCDebug(kastsAudio) << "Going to change source"; d->m_entry = entry; Q_EMIT entryChanged(entry); + QUrl loadUrl; + if (entry->enclosure()->status() == Enclosure::Downloaded) { + loadUrl = QUrl::fromLocalFile(d->m_entry->enclosure()->path()); + if (d->m_isStreaming) { + d->m_isStreaming = false; + Q_EMIT isStreamingChanged(); + } + } else { + loadUrl = QUrl(d->m_entry->enclosure()->url()); + if (!d->m_isStreaming) { + d->m_isStreaming = true; + Q_EMIT isStreamingChanged(); + } + } // the gst-pipeline is required to make sure that the pitch is not // changed when speeding up the audio stream // TODO: find a solution for Android (GStreamer not available on android by default) #if !defined Q_OS_ANDROID && !defined Q_OS_WIN qCDebug(kastsAudio) << "use custom pipeline"; - d->m_player.setMedia(QUrl(QStringLiteral("gst-pipeline: playbin uri=file://") + d->m_entry->enclosure()->path() + d->m_player.setMedia(QUrl(QStringLiteral("gst-pipeline: playbin uri=") + loadUrl.toString() + QStringLiteral(" audio_sink=\"scaletempo ! audioconvert ! audioresample ! autoaudiosink\" video_sink=\"fakevideosink\""))); #else qCDebug(kastsAudio) << "regular audio backend"; - d->m_player.setMedia(QUrl::fromLocalFile(d->m_entry->enclosure()->path())); + d->m_player.setMedia(loadUrl); #endif // save the current playing track in the settingsfile for restoring on startup DataManager::instance().setLastPlayingEntry(d->m_entry->id()); @@ -303,6 +331,22 @@ void AudioManager::play() { qCDebug(kastsAudio) << "AudioManager::play"; + // if we're streaming, check that we're still connected and check for metered + // connection + if (isStreaming()) { + if (d->m_networkStatus.connectivity() != SolidExtras::NetworkStatus::Yes + || (d->m_networkStatus.metered() != SolidExtras::NetworkStatus::No && !SettingsManager::self()->allowMeteredStreaming())) { + qCDebug(kastsAudio) << "Refusing to play: no Connection or streaming on metered connection not allowed"; + Q_EMIT logError(Error::Type::MeteredStreamingNotAllowed, + d->m_entry->feed()->url(), + d->m_entry->id(), + 0, + i18n("No connection or streaming on metered connection not allowed"), + QString()); + return; + } + } + // setting m_continuePlayback will make sure that, if the audio stream is // still being prepared, that the playback will start once it's ready d->m_continuePlayback = true; @@ -374,7 +418,6 @@ void AudioManager::skipBackward() bool AudioManager::canGoNext() const { - // TODO: extend with streaming capability if (d->m_entry) { int index = DataManager::instance().queue().indexOf(d->m_entry->id()); if (index >= 0) { @@ -385,6 +428,12 @@ bool AudioManager::canGoNext() const qCDebug(kastsAudio) << "Enclosure status" << next_entry->enclosure()->path() << next_entry->enclosure()->status(); if (next_entry->enclosure()->status() == Enclosure::Downloaded) { return true; + } else { + SolidExtras::NetworkStatus networkStatus; + if (networkStatus.connectivity() == SolidExtras::NetworkStatus::Yes + && (networkStatus.metered() == SolidExtras::NetworkStatus::No || SettingsManager::self()->allowMeteredStreaming())) { + return true; + } } } } diff --git a/src/audiomanager.h b/src/audiomanager.h index edbcf77b..49ebdf12 100644 --- a/src/audiomanager.h +++ b/src/audiomanager.h @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2017 (c) Matthieu Gallien - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: LGPL-3.0-or-later */ @@ -45,6 +45,7 @@ class AudioManager : public QObject Q_PROPERTY(qint64 sleepTime READ sleepTime WRITE setSleepTimer RESET stopSleepTimer NOTIFY sleepTimerChanged) Q_PROPERTY(qint64 remainingSleepTime READ remainingSleepTime NOTIFY remainingSleepTimeChanged) Q_PROPERTY(QString formattedRemainingSleepTime READ formattedRemainingSleepTime NOTIFY remainingSleepTimeChanged) + Q_PROPERTY(bool isStreaming READ isStreaming NOTIFY isStreamingChanged) public: const double MAX_RATE = 1.0; @@ -87,6 +88,8 @@ public: qint64 remainingSleepTime() const; // returns remaining sleep time QString formattedRemainingSleepTime() const; + bool isStreaming() const; + Q_SIGNALS: void entryChanged(Entry *entry); @@ -112,6 +115,8 @@ Q_SIGNALS: void sleepTimerChanged(qint64 duration); void remainingSleepTimeChanged(qint64 duration); + void isStreamingChanged(); + void logError(Error::Type type, const QString &url, const QString &id, const int errorId, const QString &errorString, const QString &title); public Q_SLOTS: diff --git a/src/error.cpp b/src/error.cpp index 895f6d65..e36aefef 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -58,6 +58,8 @@ QString Error::description() const return i18n("Error moving storage path"); case Error::Type::SyncError: return i18n("Error Syncing Feed and/or Episode Status"); + case Error::Type::MeteredStreamingNotAllowed: + return i18n("No Connection or Streaming Not Allowed on Metered Connection"); default: return QString(); } @@ -80,6 +82,8 @@ int Error::typeToDb(Error::Type type) return 5; case Error::Type::SyncError: return 6; + case Error::Type::MeteredStreamingNotAllowed: + return 7; default: return -1; } @@ -102,6 +106,8 @@ Error::Type Error::dbToType(int value) return Error::Type::StorageMoveError; case 6: return Error::Type::SyncError; + case 7: + return Error::Type::MeteredStreamingNotAllowed; default: return Error::Type::Unknown; } diff --git a/src/error.h b/src/error.h index 4364f3b8..bc3481e7 100644 --- a/src/error.h +++ b/src/error.h @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2022 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -24,6 +24,7 @@ public: DiscoverError, StorageMoveError, SyncError, + MeteredStreamingNotAllowed, }; Q_ENUM(Type) diff --git a/src/qml/EntryPage.qml b/src/qml/EntryPage.qml index 4392e48b..4d73cf8f 100644 --- a/src/qml/EntryPage.qml +++ b/src/qml/EntryPage.qml @@ -110,36 +110,62 @@ Kirigami.ScrollablePage { actions: [ Kirigami.Action { - text: !entry.enclosure ? i18n("Open in Browser") : - (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) ? i18n("Download") : - entry.enclosure.status === Enclosure.Downloading ? i18n("Cancel Download") : - !entry.queueStatus ? i18n("Delete Download") : - (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) ? i18n("Pause") : - i18n("Play") - icon.name: !entry.enclosure ? "globe" : - (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) ? "download" : - entry.enclosure.status === Enclosure.Downloading ? "edit-delete-remove" : - !entry.queueStatus ? "delete" : - (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) ? "media-playback-pause" : - "media-playback-start" + text: i18n("Open in Browser") + visible: !entry.enclosure + icon.name: "globe" onTriggered: { - if (!entry.enclosure) { - Qt.openUrlExternally(entry.link) - } else if (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) { - downloadOverlay.entry = entry; - downloadOverlay.run(); - } else if (entry.enclosure.status === Enclosure.Downloading) { - entry.enclosure.cancelDownload() - } else if (!entry.queueStatus) { - entry.enclosure.deleteFile() - } else { - if(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) { - AudioManager.pause() - } else { - AudioManager.entry = entry - AudioManager.play() - } - } + Qt.openUrlExternally(entry.link); + } + }, + Kirigami.Action { + text: i18n("Download") + visible: entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) + icon.name: "download" + onTriggered: { + downloadOverlay.entry = entry; + downloadOverlay.run(); + } + }, + Kirigami.Action { + text: i18n("Cancel Download") + visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloading + icon.name: "edit-delete-remove" + onTriggered: { + entry.enclosure.cancelDownload(); + } + }, + Kirigami.Action { + text: i18n("Delete Download") + visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && !entry.queueStatus + icon.name: "delete" + onTriggered: { + entry.enclosure.deleteFile(); + } + }, + Kirigami.Action { + text: i18n("Pause") + visible: entry.enclosure && entry.queueStatus && (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) + icon.name: "media-playback-pause" + onTriggered: { + AudioManager.pause(); + } + }, + Kirigami.Action { + text: i18n("Play") + visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && entry.queueStatus && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState) + icon.name: "media-playback-start" + onTriggered: { + AudioManager.entry = entry; + AudioManager.play(); + } + }, + Kirigami.Action { + text: i18nc("Action to start playback by streaming the episode rather than downloading it first", "Stream") + visible: entry.enclosure && entry.queueStatus && entry.enclosure.status !== Enclosure.Downloaded && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState) + icon.name: ":/media-playback-start-cloud" + onTriggered: { + AudioManager.entry = entry; + AudioManager.play() } }, Kirigami.Action { diff --git a/src/qml/GenericEntryDelegate.qml b/src/qml/GenericEntryDelegate.qml index 32da8ce5..b1862d3e 100644 --- a/src/qml/GenericEntryDelegate.qml +++ b/src/qml/GenericEntryDelegate.qml @@ -12,6 +12,7 @@ import QtGraphicalEffects 1.15 import QtQml.Models 2.15 import org.kde.kirigami 2.14 as Kirigami +import org.kde.kasts.solidextras 1.0 import org.kde.kasts 1.0 @@ -25,6 +26,18 @@ Kirigami.SwipeListItem { property bool selected: false property int row: model ? model.row : -1 + property bool streamingAllowed: (NetworkStatus.connectivity !== NetworkStatus.No && SettingsManager.prioritizeStreaming && (SettingsManager.allowMeteredStreaming || NetworkStatus.metered !== NetworkStatus.Yes)) + + property bool showRemoveFromQueueButton: !entry.enclosure && entry.queueStatus + property bool showDownloadButton: (!isDownloads || entry.enclosure.status === Enclosure.PartiallyDownloaded) && entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) && !streamingAllowed && !(AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) + property bool showCancelDownloadButton: entry.enclosure && entry.enclosure.status === Enclosure.Downloading + property bool showDeleteDownloadButton: isDownloads && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded + property bool showAddToQueueButton: !isDownloads && !entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded + property bool showPlayButton: !isDownloads && entry.queueStatus && entry.enclosure && (entry.enclosure.status === Enclosure.Downloaded) && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState) + property bool showStreamingPlayButton: !isDownloads && entry.queueStatus && entry.enclosure && (entry.enclosure.status !== Enclosure.Downloaded && streamingAllowed) && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState) + property bool showPauseButton: !isDownloads && entry.queueStatus && entry.enclosure && (AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState) + + highlighted: selected activeBackgroundColor: Qt.lighter(Kirigami.Theme.highlightColor, 1.3) @@ -273,7 +286,7 @@ Kirigami.SwipeListItem { onTriggered: { entry.queueStatus = false; } - visible: !entry.enclosure && entry.queueStatus + visible: showRemoveFromQueueButton }, Kirigami.Action { text: i18n("Download") @@ -282,30 +295,39 @@ Kirigami.SwipeListItem { downloadOverlay.entry = entry; downloadOverlay.run(); } - visible: (!isDownloads || entry.enclosure.status === Enclosure.PartiallyDownloaded) && entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) + visible: showDownloadButton }, Kirigami.Action { text: i18n("Cancel Download") icon.name: "edit-delete-remove" onTriggered: entry.enclosure.cancelDownload() - visible: entry.enclosure && entry.enclosure.status === Enclosure.Downloading + visible: showCancelDownloadButton }, Kirigami.Action { text: i18n("Delete Download") icon.name: "delete" onTriggered: entry.enclosure.deleteFile() - visible: isDownloads && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded + visible: showDeleteDownloadButton }, Kirigami.Action { text: i18n("Add to Queue") icon.name: "media-playlist-append" - visible: !isDownloads && !entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded + visible: showAddToQueueButton onTriggered: entry.queueStatus = true }, Kirigami.Action { text: i18n("Play") icon.name: "media-playback-start" - visible: !isDownloads && entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && (AudioManager.entry !== entry || AudioManager.playbackState !== Audio.PlayingState) + visible: showPlayButton + onTriggered: { + AudioManager.entry = entry + AudioManager.play() + } + }, + Kirigami.Action { + text: i18nc("Action to start playback by streaming the episode rather than downloading it first", "Stream") + icon.name: ":/media-playback-start-cloud" + visible: showStreamingPlayButton onTriggered: { AudioManager.entry = entry AudioManager.play() @@ -314,7 +336,7 @@ Kirigami.SwipeListItem { Kirigami.Action { text: i18n("Pause") icon.name: "media-playback-pause" - visible: !isDownloads && entry.queueStatus && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded && AudioManager.entry === entry && AudioManager.playbackState === Audio.PlayingState + visible: showPauseButton onTriggered: AudioManager.pause() } ] diff --git a/src/qml/Settings/GeneralSettingsPage.qml b/src/qml/Settings/GeneralSettingsPage.qml index ddef1fc6..ed5b95e9 100644 --- a/src/qml/Settings/GeneralSettingsPage.qml +++ b/src/qml/Settings/GeneralSettingsPage.qml @@ -36,25 +36,41 @@ Kirigami.ScrollablePage { } Controls.CheckBox { - id: continuePlayingNextEntry - checked: SettingsManager.continuePlayingNextEntry - text: i18n("Continue playing next episode after current one finishes") - onToggled: SettingsManager.continuePlayingNextEntry = checked + id: showTimeLeft + Kirigami.FormData.label: i18nc("Label for settings related to the play time, e.g. whether the total track time is shown or a countdown of the remaining play time", "Play Time:") + checked: SettingsManager.toggleRemainingTime + text: i18n("Show time left instead of total track time") + onToggled: SettingsManager.toggleRemainingTime = checked } Controls.CheckBox { id: adjustTimeLeft checked: SettingsManager.adjustTimeLeft + enabled: SettingsManager.toggleRemainingTime text: i18n("Adjust time left based on current playback speed") onToggled: SettingsManager.adjustTimeLeft = checked } + Controls.CheckBox { + id: prioritizeStreaming + Kirigami.FormData.label: i18nc("Label for settings related to streaming of episodes (as opposed to playing back locally downloaded files)", "Streaming:") + checked: SettingsManager.prioritizeStreaming + text: i18n("Prioritize streaming over downloading") + onToggled: SettingsManager.prioritizeStreaming = checked + } Kirigami.Heading { Kirigami.FormData.isSection: true text: i18n("Queue Settings") } + Controls.CheckBox { + id: continuePlayingNextEntry + checked: SettingsManager.continuePlayingNextEntry + text: i18n("Continue playing next episode after current one finishes") + onToggled: SettingsManager.continuePlayingNextEntry = checked + } Controls.CheckBox { id: refreshOnStartup + Kirigami.FormData.label: i18nc("Label for settings related to podcast updates", "Update Settings:") checked: SettingsManager.refreshOnStartup text: i18n("Automatically fetch podcast updates on startup") onToggled: SettingsManager.refreshOnStartup = checked @@ -65,6 +81,7 @@ Kirigami.ScrollablePage { text: i18n("Update existing episode data on refresh (slower)") onToggled: SettingsManager.doFullUpdate = checked } + Controls.CheckBox { id: autoQueue Kirigami.FormData.label: i18n("New Episodes:") diff --git a/src/qml/Settings/NetworkSettingsPage.qml b/src/qml/Settings/NetworkSettingsPage.qml index 6aedcf7b..490d5d0e 100644 --- a/src/qml/Settings/NetworkSettingsPage.qml +++ b/src/qml/Settings/NetworkSettingsPage.qml @@ -38,5 +38,12 @@ Kirigami.ScrollablePage { text: i18n("Allow image downloads") onToggled: SettingsManager.allowMeteredImageDownloads = checked } + + Controls.CheckBox { + id: allowMeteredStreaming + checked: SettingsManager.allowMeteredStreaming + text: i18n("Allow streaming") + onToggled: SettingsManager.allowMeteredStreaming = checked + } } } diff --git a/src/resources.qrc b/src/resources.qrc index 9eeb0444..659ec86b 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -37,5 +37,6 @@ qml/SleepTimerDialog.qml qtquickcontrols2.conf ../kasts.svg + ../icons/media-playback-start-cloud.svg diff --git a/src/settingsmanager.kcfg b/src/settingsmanager.kcfg index 91548c59..7cfcb1d6 100644 --- a/src/settingsmanager.kcfg +++ b/src/settingsmanager.kcfg @@ -9,6 +9,10 @@ false + + + false + true @@ -104,6 +108,10 @@ false + + + false + diff --git a/src/updatefeedjob.cpp b/src/updatefeedjob.cpp index 2b89254c..f590134e 100644 --- a/src/updatefeedjob.cpp +++ b/src/updatefeedjob.cpp @@ -282,10 +282,10 @@ bool UpdateFeedJob::processEntry(Syndication::ItemPtr entry) entryDetails.read = m_isNewFeed ? m_markUnreadOnNewFeed : false; // if new feed, then check settings entryDetails.isNew = !m_isNewFeed; // if new feed, then mark none as new - if (!entry->content().isEmpty()) - entryDetails.content = entry->content(); - else + if (!entry->description().isEmpty()) entryDetails.content = entry->description(); + else + entryDetails.content = entry->content(); // Look for image in itunes tags if (otherItems.value(QStringLiteral("http://www.itunes.com/dtds/podcast-1.0.dtdimage")).hasAttribute(QStringLiteral("href"))) {